Files
excalidraw/src/components/ToolButton.tsx
Marcel Mraz 260706c42f First step towards delta based history
Introducing independent change detection for appState and elements

Generalizing object change, cleanup, refactoring, comments, solving typing issues

Shaping increment, change, delta hierarchy

Structural clone of elements

Introducing store and incremental API

Disabling buttons for canvas actions, smaller store and changes improvements

Update history entry based on latest changes, iterate through the stack for visible changes to limit empty commands

Solving concurrency issues, solving (partly) linear element issues,  introducing commitToStore breaking change

Fixing existing tests, updating snapshots

Trying to be smarter on the appstate change detection

Extending collab test, refactoring action / updateScene params, bugfixes

Resetting snapshots

Resetting snapshots

UI / API tests for history - WIP

Changing actions related to the observed appstate to at least update the store snapshot - WIP

Adding skipping of snapshot update flag for most no-breaking changes compatible solution

Ignoring uncomitted elements from local async actions, updating store directly in updateScene

Bound element issues - WIP
2023-12-11 11:59:24 +01:00

199 lines
5.2 KiB
TypeScript

import "./ToolIcon.scss";
import React, { CSSProperties, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
import { AbortError } from "../errors";
import Spinner from "./Spinner";
import { PointerType } from "../element/types";
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((props: ToolButtonProps, ref) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${props.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 (ret && "then" in 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,
props.className,
props.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", props.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.defaultProps = {
visible: true,
className: "",
size: "medium",
};
ToolButton.displayName = "ToolButton";