diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/ToolButton.tsx | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/ToolButton.tsx')
| -rw-r--r-- | packages/excalidraw/components/ToolButton.tsx | 206 |
1 files changed, 206 insertions, 0 deletions
diff --git a/packages/excalidraw/components/ToolButton.tsx b/packages/excalidraw/components/ToolButton.tsx new file mode 100644 index 0000000..09b0b6c --- /dev/null +++ b/packages/excalidraw/components/ToolButton.tsx @@ -0,0 +1,206 @@ +import "./ToolIcon.scss"; + +import type { CSSProperties } from "react"; +import React, { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import { useExcalidrawContainer } from "./App"; +import { AbortError } from "../errors"; +import Spinner from "./Spinner"; +import type { PointerType } from "../element/types"; +import { isPromiseLike } from "../utils"; + +export type ToolButtonSize = "small" | "medium"; + +type ToolButtonBaseProps = { + icon?: React.ReactNode; + "aria-label": string; + "aria-keyshortcuts"?: string; + "data-testid"?: string; + label?: string; + title?: string; + name?: string; + id?: string; + size?: ToolButtonSize; + keyBindingLabel?: string | null; + showAriaLabel?: boolean; + hidden?: boolean; + visible?: boolean; + selected?: boolean; + disabled?: boolean; + className?: string; + style?: CSSProperties; + isLoading?: boolean; +}; + +type ToolButtonProps = + | (ToolButtonBaseProps & { + type: "button"; + children?: React.ReactNode; + onClick?(event: React.MouseEvent): void; + }) + | (ToolButtonBaseProps & { + type: "submit"; + children?: React.ReactNode; + onClick?(event: React.MouseEvent): void; + }) + | (ToolButtonBaseProps & { + type: "icon"; + children?: React.ReactNode; + onClick?(): void; + }) + | (ToolButtonBaseProps & { + type: "radio"; + checked: boolean; + onChange?(data: { pointerType: PointerType | null }): void; + onPointerDown?(data: { pointerType: PointerType }): void; + }); + +export const ToolButton = React.forwardRef( + ( + { + size = "medium", + visible = true, + className = "", + ...props + }: ToolButtonProps, + ref, + ) => { + const { id: excalId } = useExcalidrawContainer(); + const innerRef = React.useRef(null); + React.useImperativeHandle(ref, () => innerRef.current); + const sizeCn = `ToolIcon_size_${size}`; + + const [isLoading, setIsLoading] = useState(false); + + const isMountedRef = useRef(true); + + const onClick = async (event: React.MouseEvent) => { + const ret = "onClick" in props && props.onClick?.(event); + + if (isPromiseLike(ret)) { + try { + setIsLoading(true); + await ret; + } catch (error: any) { + if (!(error instanceof AbortError)) { + throw error; + } else { + console.warn(error); + } + } finally { + if (isMountedRef.current) { + setIsLoading(false); + } + } + } + }; + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const lastPointerTypeRef = useRef<PointerType | null>(null); + + if ( + props.type === "button" || + props.type === "icon" || + props.type === "submit" + ) { + const type = (props.type === "icon" ? "button" : props.type) as + | "button" + | "submit"; + return ( + <button + className={clsx( + "ToolIcon_type_button", + sizeCn, + className, + visible && !props.hidden + ? "ToolIcon_type_button--show" + : "ToolIcon_type_button--hide", + { + ToolIcon: !props.hidden, + "ToolIcon--selected": props.selected, + "ToolIcon--plain": props.type === "icon", + }, + )} + style={props.style} + data-testid={props["data-testid"]} + hidden={props.hidden} + title={props.title} + aria-label={props["aria-label"]} + type={type} + onClick={onClick} + ref={innerRef} + disabled={isLoading || props.isLoading || !!props.disabled} + > + {(props.icon || props.label) && ( + <div + className="ToolIcon__icon" + aria-hidden="true" + aria-disabled={!!props.disabled} + > + {props.icon || props.label} + {props.keyBindingLabel && ( + <span className="ToolIcon__keybinding"> + {props.keyBindingLabel} + </span> + )} + {props.isLoading && <Spinner />} + </div> + )} + {props.showAriaLabel && ( + <div className="ToolIcon__label"> + {props["aria-label"]} {isLoading && <Spinner />} + </div> + )} + {props.children} + </button> + ); + } + + return ( + <label + className={clsx("ToolIcon", className)} + title={props.title} + onPointerDown={(event) => { + lastPointerTypeRef.current = event.pointerType || null; + props.onPointerDown?.({ pointerType: event.pointerType || null }); + }} + onPointerUp={() => { + requestAnimationFrame(() => { + lastPointerTypeRef.current = null; + }); + }} + > + <input + className={`ToolIcon_type_radio ${sizeCn}`} + type="radio" + name={props.name} + aria-label={props["aria-label"]} + aria-keyshortcuts={props["aria-keyshortcuts"]} + data-testid={props["data-testid"]} + id={`${excalId}-${props.id}`} + onChange={() => { + props.onChange?.({ pointerType: lastPointerTypeRef.current }); + }} + checked={props.checked} + ref={innerRef} + /> + <div className="ToolIcon__icon"> + {props.icon} + {props.keyBindingLabel && ( + <span className="ToolIcon__keybinding"> + {props.keyBindingLabel} + </span> + )} + </div> + </label> + ); + }, +); + +ToolButton.displayName = "ToolButton"; |
