ToolButton.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import "./ToolIcon.scss";
  2. import React, { useEffect, useRef, useState } from "react";
  3. import clsx from "clsx";
  4. import { useExcalidrawContainer } from "./App";
  5. import { AbortError } from "../errors";
  6. import Spinner from "./Spinner";
  7. import { PointerType } from "../element/types";
  8. export type ToolButtonSize = "small" | "medium";
  9. type ToolButtonBaseProps = {
  10. icon?: React.ReactNode;
  11. "aria-label": string;
  12. "aria-keyshortcuts"?: string;
  13. "data-testid"?: string;
  14. label?: string;
  15. title?: string;
  16. name?: string;
  17. id?: string;
  18. size?: ToolButtonSize;
  19. keyBindingLabel?: string;
  20. showAriaLabel?: boolean;
  21. hidden?: boolean;
  22. visible?: boolean;
  23. selected?: boolean;
  24. className?: string;
  25. isLoading?: boolean;
  26. };
  27. type ToolButtonProps =
  28. | (ToolButtonBaseProps & {
  29. type: "button";
  30. children?: React.ReactNode;
  31. onClick?(event: React.MouseEvent): void;
  32. })
  33. | (ToolButtonBaseProps & {
  34. type: "submit";
  35. children?: React.ReactNode;
  36. onClick?(event: React.MouseEvent): void;
  37. })
  38. | (ToolButtonBaseProps & {
  39. type: "icon";
  40. children?: React.ReactNode;
  41. onClick?(): void;
  42. })
  43. | (ToolButtonBaseProps & {
  44. type: "radio";
  45. checked: boolean;
  46. onChange?(data: { pointerType: PointerType | null }): void;
  47. onPointerDown?(data: { pointerType: PointerType }): void;
  48. });
  49. export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
  50. const { id: excalId } = useExcalidrawContainer();
  51. const innerRef = React.useRef(null);
  52. React.useImperativeHandle(ref, () => innerRef.current);
  53. const sizeCn = `ToolIcon_size_${props.size}`;
  54. const [isLoading, setIsLoading] = useState(false);
  55. const isMountedRef = useRef(true);
  56. const onClick = async (event: React.MouseEvent) => {
  57. const ret = "onClick" in props && props.onClick?.(event);
  58. if (ret && "then" in ret) {
  59. try {
  60. setIsLoading(true);
  61. await ret;
  62. } catch (error: any) {
  63. if (!(error instanceof AbortError)) {
  64. throw error;
  65. } else {
  66. console.warn(error);
  67. }
  68. } finally {
  69. if (isMountedRef.current) {
  70. setIsLoading(false);
  71. }
  72. }
  73. }
  74. };
  75. useEffect(
  76. () => () => {
  77. isMountedRef.current = false;
  78. },
  79. [],
  80. );
  81. const lastPointerTypeRef = useRef<PointerType | null>(null);
  82. if (
  83. props.type === "button" ||
  84. props.type === "icon" ||
  85. props.type === "submit"
  86. ) {
  87. const type = (props.type === "icon" ? "button" : props.type) as
  88. | "button"
  89. | "submit";
  90. return (
  91. <button
  92. className={clsx(
  93. "ToolIcon_type_button",
  94. sizeCn,
  95. props.className,
  96. props.visible && !props.hidden
  97. ? "ToolIcon_type_button--show"
  98. : "ToolIcon_type_button--hide",
  99. {
  100. ToolIcon: !props.hidden,
  101. "ToolIcon--selected": props.selected,
  102. "ToolIcon--plain": props.type === "icon",
  103. },
  104. )}
  105. data-testid={props["data-testid"]}
  106. hidden={props.hidden}
  107. title={props.title}
  108. aria-label={props["aria-label"]}
  109. type={type}
  110. onClick={onClick}
  111. ref={innerRef}
  112. disabled={isLoading || props.isLoading}
  113. >
  114. {(props.icon || props.label) && (
  115. <div className="ToolIcon__icon" aria-hidden="true">
  116. {props.icon || props.label}
  117. {props.keyBindingLabel && (
  118. <span className="ToolIcon__keybinding">
  119. {props.keyBindingLabel}
  120. </span>
  121. )}
  122. {props.isLoading && <Spinner />}
  123. </div>
  124. )}
  125. {props.showAriaLabel && (
  126. <div className="ToolIcon__label">
  127. {props["aria-label"]} {isLoading && <Spinner />}
  128. </div>
  129. )}
  130. {props.children}
  131. </button>
  132. );
  133. }
  134. return (
  135. <label
  136. className={clsx("ToolIcon", props.className)}
  137. title={props.title}
  138. onPointerDown={(event) => {
  139. lastPointerTypeRef.current = event.pointerType || null;
  140. props.onPointerDown?.({ pointerType: event.pointerType || null });
  141. }}
  142. onPointerUp={() => {
  143. requestAnimationFrame(() => {
  144. lastPointerTypeRef.current = null;
  145. });
  146. }}
  147. >
  148. <input
  149. className={`ToolIcon_type_radio ${sizeCn}`}
  150. type="radio"
  151. name={props.name}
  152. aria-label={props["aria-label"]}
  153. aria-keyshortcuts={props["aria-keyshortcuts"]}
  154. data-testid={props["data-testid"]}
  155. id={`${excalId}-${props.id}`}
  156. onChange={() => {
  157. props.onChange?.({ pointerType: lastPointerTypeRef.current });
  158. }}
  159. checked={props.checked}
  160. ref={innerRef}
  161. />
  162. <div className="ToolIcon__icon">
  163. {props.icon}
  164. {props.keyBindingLabel && (
  165. <span className="ToolIcon__keybinding">{props.keyBindingLabel}</span>
  166. )}
  167. </div>
  168. </label>
  169. );
  170. });
  171. ToolButton.defaultProps = {
  172. visible: true,
  173. className: "",
  174. size: "medium",
  175. };