aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/Stats/DragInput.tsx
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/Stats/DragInput.tsx
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/Stats/DragInput.tsx')
-rw-r--r--packages/excalidraw/components/Stats/DragInput.tsx355
1 files changed, 355 insertions, 0 deletions
diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx
new file mode 100644
index 0000000..82d6419
--- /dev/null
+++ b/packages/excalidraw/components/Stats/DragInput.tsx
@@ -0,0 +1,355 @@
+import { useEffect, useRef, useState } from "react";
+import { EVENT } from "../../constants";
+import { KEYS } from "../../keys";
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import { deepCopyElement } from "../../element/newElement";
+import clsx from "clsx";
+import { useApp } from "../App";
+import { InlineIcon } from "../InlineIcon";
+import type { StatsInputProperty } from "./utils";
+import { SMALLEST_DELTA } from "./utils";
+import { CaptureUpdateAction } from "../../store";
+import type Scene from "../../scene/Scene";
+
+import "./DragInput.scss";
+import type { AppState } from "../../types";
+import { cloneJSON } from "../../utils";
+
+export type DragInputCallbackType<
+ P extends StatsInputProperty,
+ E = ExcalidrawElement,
+> = (props: {
+ accumulatedChange: number;
+ instantChange: number;
+ originalElements: readonly E[];
+ originalElementsMap: ElementsMap;
+ shouldKeepAspectRatio: boolean;
+ shouldChangeByStepSize: boolean;
+ scene: Scene;
+ nextValue?: number;
+ property: P;
+ originalAppState: AppState;
+ setInputValue: (value: number) => void;
+}) => void;
+
+interface StatsDragInputProps<
+ T extends StatsInputProperty,
+ E = ExcalidrawElement,
+> {
+ label: string | React.ReactNode;
+ icon?: React.ReactNode;
+ value: number | "Mixed";
+ elements: readonly E[];
+ editable?: boolean;
+ shouldKeepAspectRatio?: boolean;
+ dragInputCallback: DragInputCallbackType<T, E>;
+ property: T;
+ scene: Scene;
+ appState: AppState;
+ /** how many px you need to drag to get 1 unit change */
+ sensitivity?: number;
+}
+
+const StatsDragInput = <
+ T extends StatsInputProperty,
+ E extends ExcalidrawElement = ExcalidrawElement,
+>({
+ label,
+ icon,
+ dragInputCallback,
+ value,
+ elements,
+ editable = true,
+ shouldKeepAspectRatio,
+ property,
+ scene,
+ appState,
+ sensitivity = 1,
+}: StatsDragInputProps<T, E>) => {
+ const app = useApp();
+ const inputRef = useRef<HTMLInputElement>(null);
+ const labelRef = useRef<HTMLDivElement>(null);
+
+ const [inputValue, setInputValue] = useState(value.toString());
+
+ const stateRef = useRef<{
+ originalAppState: AppState;
+ originalElements: readonly E[];
+ lastUpdatedValue: string;
+ updatePending: boolean;
+ }>(null!);
+ if (!stateRef.current) {
+ stateRef.current = {
+ originalAppState: cloneJSON(appState),
+ originalElements: elements,
+ lastUpdatedValue: inputValue,
+ updatePending: false,
+ };
+ }
+
+ useEffect(() => {
+ const inputValue = value.toString();
+ setInputValue(inputValue);
+ stateRef.current.lastUpdatedValue = inputValue;
+ }, [value]);
+
+ const handleInputValue = (
+ updatedValue: string,
+ elements: readonly E[],
+ appState: AppState,
+ ) => {
+ if (!stateRef.current.updatePending) {
+ return false;
+ }
+ stateRef.current.updatePending = false;
+
+ const parsed = Number(updatedValue);
+ if (isNaN(parsed)) {
+ setInputValue(value.toString());
+ return;
+ }
+
+ const rounded = Number(parsed.toFixed(2));
+ const original = Number(value);
+
+ // only update when
+ // 1. original was "Mixed" and we have a new value
+ // 2. original was not "Mixed" and the difference between a new value and previous value is greater
+ // than the smallest delta allowed, which is 0.01
+ // reason: idempotent to avoid unnecessary
+ if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
+ stateRef.current.lastUpdatedValue = updatedValue;
+ dragInputCallback({
+ accumulatedChange: 0,
+ instantChange: 0,
+ originalElements: elements,
+ originalElementsMap: app.scene.getNonDeletedElementsMap(),
+ shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+ shouldChangeByStepSize: false,
+ scene,
+ nextValue: rounded,
+ property,
+ originalAppState: appState,
+ setInputValue: (value) => setInputValue(String(value)),
+ });
+ app.syncActionResult({
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ }
+ };
+
+ const callbacksRef = useRef<
+ Partial<{
+ handleInputValue: typeof handleInputValue;
+ onPointerUp: (event: PointerEvent) => void;
+ onPointerMove: (event: PointerEvent) => void;
+ }>
+ >({});
+ callbacksRef.current.handleInputValue = handleInputValue;
+
+ // make sure that clicking on canvas (which umounts the component)
+ // updates current input value (blur isn't triggered)
+ useEffect(() => {
+ const input = inputRef.current;
+ const callbacks = callbacksRef.current;
+ return () => {
+ const nextValue = input?.value;
+ if (nextValue) {
+ callbacks.handleInputValue?.(
+ nextValue,
+ stateRef.current.originalElements,
+ stateRef.current.originalAppState,
+ );
+ }
+
+ // generally not needed, but in case `pointerup` doesn't fire and
+ // we don't remove the listeners that way, we should at least remove
+ // on unmount
+ window.removeEventListener(
+ EVENT.POINTER_MOVE,
+ callbacks.onPointerMove!,
+ false,
+ );
+ window.removeEventListener(
+ EVENT.POINTER_UP,
+ callbacks.onPointerUp!,
+ false,
+ );
+ };
+ }, [
+ // we need to track change of `editable` state as mount/unmount
+ // because react doesn't trigger `blur` when a an input is blurred due
+ // to being disabled (https://github.com/facebook/react/issues/9142).
+ // As such, if we keep rendering disabled inputs, then change in selection
+ // to an element that has a given property as non-editable would not trigger
+ // blur/unmount and wouldn't update the value.
+ editable,
+ ]);
+
+ if (!editable) {
+ return null;
+ }
+
+ return (
+ <div
+ className={clsx("drag-input-container", !editable && "disabled")}
+ data-testid={label}
+ >
+ <div
+ className="drag-input-label"
+ ref={labelRef}
+ onPointerDown={(event) => {
+ if (inputRef.current && editable) {
+ document.body.classList.add("excalidraw-cursor-resize");
+
+ let startValue = Number(inputRef.current.value);
+ if (isNaN(startValue)) {
+ startValue = 0;
+ }
+
+ let lastPointer: {
+ x: number;
+ y: number;
+ } | null = null;
+
+ let originalElementsMap: Map<string, ExcalidrawElement> | null =
+ app.scene
+ .getNonDeletedElements()
+ .reduce((acc: ElementsMap, element) => {
+ acc.set(element.id, deepCopyElement(element));
+ return acc;
+ }, new Map());
+
+ let originalElements: readonly E[] | null = elements.map(
+ (element) => originalElementsMap!.get(element.id) as E,
+ );
+
+ const originalAppState: AppState = cloneJSON(appState);
+
+ let accumulatedChange = 0;
+ let stepChange = 0;
+
+ const onPointerMove = (event: PointerEvent) => {
+ if (
+ lastPointer &&
+ originalElementsMap !== null &&
+ originalElements !== null
+ ) {
+ const instantChange = event.clientX - lastPointer.x;
+
+ if (instantChange !== 0) {
+ stepChange += instantChange;
+
+ if (Math.abs(stepChange) >= sensitivity) {
+ stepChange =
+ Math.sign(stepChange) *
+ Math.floor(Math.abs(stepChange) / sensitivity);
+
+ accumulatedChange += stepChange;
+
+ dragInputCallback({
+ accumulatedChange,
+ instantChange: stepChange,
+ originalElements,
+ originalElementsMap,
+ shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+ shouldChangeByStepSize: event.shiftKey,
+ property,
+ scene,
+ originalAppState,
+ setInputValue: (value) => setInputValue(String(value)),
+ });
+
+ stepChange = 0;
+ }
+ }
+ }
+
+ lastPointer = {
+ x: event.clientX,
+ y: event.clientY,
+ };
+ };
+
+ const onPointerUp = () => {
+ window.removeEventListener(
+ EVENT.POINTER_MOVE,
+ onPointerMove,
+ false,
+ );
+
+ app.syncActionResult({
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+
+ lastPointer = null;
+ accumulatedChange = 0;
+ stepChange = 0;
+ originalElements = null;
+ originalElementsMap = null;
+
+ document.body.classList.remove("excalidraw-cursor-resize");
+
+ window.removeEventListener(EVENT.POINTER_UP, onPointerUp, false);
+ };
+
+ callbacksRef.current.onPointerMove = onPointerMove;
+ callbacksRef.current.onPointerUp = onPointerUp;
+
+ window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
+ window.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
+ }
+ }}
+ onPointerEnter={() => {
+ if (labelRef.current) {
+ labelRef.current.style.cursor = "ew-resize";
+ }
+ }}
+ >
+ {icon ? <InlineIcon icon={icon} /> : label}
+ </div>
+ <input
+ className="drag-input"
+ autoComplete="off"
+ spellCheck="false"
+ onKeyDown={(event) => {
+ if (editable) {
+ const eventTarget = event.target;
+ if (
+ eventTarget instanceof HTMLInputElement &&
+ event.key === KEYS.ENTER
+ ) {
+ handleInputValue(eventTarget.value, elements, appState);
+ app.focusContainer();
+ }
+ }
+ }}
+ ref={inputRef}
+ value={inputValue}
+ onChange={(event) => {
+ stateRef.current.updatePending = true;
+ setInputValue(event.target.value);
+ }}
+ onFocus={(event) => {
+ event.target.select();
+ stateRef.current.originalElements = elements;
+ stateRef.current.originalAppState = cloneJSON(appState);
+ }}
+ onBlur={(event) => {
+ if (!inputValue) {
+ setInputValue(value.toString());
+ } else if (editable) {
+ handleInputValue(
+ event.target.value,
+ stateRef.current.originalElements,
+ stateRef.current.originalAppState,
+ );
+ }
+ }}
+ disabled={!editable}
+ />
+ </div>
+ );
+};
+
+export default StatsDragInput;