瀏覽代碼

wip: drag input

Ryan Di 2 年之前
父節點
當前提交
6e577d1308
共有 4 個文件被更改,包括 276 次插入148 次删除
  1. 235 0
      src/components/DragInput.tsx
  2. 1 1
      src/components/Stats.scss
  3. 34 147
      src/components/Stats.tsx
  4. 6 0
      src/css/styles.scss

+ 235 - 0
src/components/DragInput.tsx

@@ -0,0 +1,235 @@
+import throttle from "lodash.throttle";
+import { useEffect, useMemo, useRef } from "react";
+import { EVENT } from "../constants";
+import { getTransformHandles } from "../element";
+import { mutateElement } from "../element/mutateElement";
+import { resizeSingleElement } from "../element/resizeElements";
+import { ExcalidrawElement } from "../element/types";
+import { KEYS } from "../keys";
+import { degreeToRadian, radianToDegree, rotatePoint } from "../math";
+import Scene from "../scene/Scene";
+import { AppState, Point } from "../types";
+import { arrayToMap } from "../utils";
+
+const shouldKeepAspectRatio = (element: ExcalidrawElement) => {
+  return element.type === "image";
+};
+
+type AdjustableProperty = "width" | "height" | "angle" | "x" | "y";
+
+interface DragInputProps {
+  label: string | React.ReactNode;
+  property: AdjustableProperty;
+  element: ExcalidrawElement;
+  zoom: AppState["zoom"];
+}
+
+const DragInput = ({ label, property, element, zoom }: DragInputProps) => {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const labelRef = useRef<HTMLDivElement>(null);
+
+  const originalElementsMap = useMemo(
+    () => arrayToMap(Scene.getScene(element)?.getNonDeletedElements() ?? []),
+    [element],
+  );
+
+  const handleChange = useMemo(
+    () =>
+      (
+        initialValue: number,
+        delta: number,
+        source: "pointerMove" | "keyDown",
+        pointerOffset?: number,
+      ) => {
+        if (inputRef.current) {
+          const keepAspectRatio = shouldKeepAspectRatio(element);
+
+          if (
+            (property === "width" || property === "height") &&
+            source === "pointerMove" &&
+            pointerOffset
+          ) {
+            const handles = getTransformHandles(element, zoom, "mouse");
+
+            let referencePoint: Point | undefined;
+            let handleDirection: "e" | "s" | "se" | undefined;
+
+            if (keepAspectRatio && handles.se) {
+              referencePoint = [handles.se[0], handles.se[1]];
+              handleDirection = "se";
+            } else if (property === "width" && handles.e) {
+              referencePoint = [handles.e[0], handles.e[1]];
+              handleDirection = "e";
+            } else if (property === "height" && handles.s) {
+              referencePoint = [handles.s[0], handles.s[1]];
+              handleDirection = "s";
+            }
+
+            if (referencePoint !== undefined && handleDirection !== undefined) {
+              const pointerRotated = rotatePoint(
+                [
+                  referencePoint[0] +
+                    (property === "width" ? pointerOffset : 0),
+                  referencePoint[1] +
+                    (property === "height" ? pointerOffset : 0),
+                ],
+                referencePoint,
+                element.angle,
+              );
+
+              resizeSingleElement(
+                originalElementsMap,
+                keepAspectRatio,
+                element,
+                handleDirection,
+                false,
+                pointerRotated[0],
+                pointerRotated[1],
+              );
+            }
+          } else if (
+            source === "keyDown" ||
+            (source === "pointerMove" &&
+              property !== "width" &&
+              property !== "height")
+          ) {
+            const incVal = Math.round(
+              Math.sign(delta) * Math.pow(Math.abs(delta) / 10, 1.6),
+            );
+            let newVal = initialValue + incVal;
+
+            newVal =
+              property === "angle"
+                ? // so the degree converted from radian is an integer
+                  degreeToRadian(
+                    Math.round(
+                      radianToDegree(
+                        degreeToRadian(
+                          Math.sign(newVal % 360) === -1
+                            ? (newVal % 360) + 360
+                            : newVal % 360,
+                        ),
+                      ),
+                    ),
+                  )
+                : Math.round(newVal);
+
+            mutateElement(element, {
+              [property]: newVal,
+            });
+          }
+        }
+      },
+    [element, property, zoom, originalElementsMap],
+  );
+
+  const hangleChangeThrottled = useMemo(() => {
+    return throttle(handleChange, 16);
+  }, [handleChange]);
+
+  useEffect(() => {
+    const value =
+      Math.round(
+        property === "angle"
+          ? radianToDegree(element[property]) * 100
+          : element[property] * 100,
+      ) / 100;
+
+    if (inputRef.current) {
+      inputRef.current.value = String(value);
+    }
+  }, [element, element.version, element.versionNonce, property]);
+
+  useEffect(() => {
+    hangleChangeThrottled.cancel();
+  });
+
+  return (
+    <label className="color-input-container">
+      <div
+        className="color-picker-hash"
+        ref={labelRef}
+        style={{
+          width: "20px",
+        }}
+        onPointerDown={(event) => {
+          if (inputRef.current) {
+            const startPosition = event.clientX;
+            let startValue = Number(inputRef.current.value);
+            if (isNaN(startValue)) {
+              startValue = 0;
+            }
+
+            let lastPointerRef: {
+              x: number;
+              y: number;
+            } | null = null;
+
+            document.body.classList.add("dragResize");
+
+            const onPointerMove = (event: PointerEvent) => {
+              if (lastPointerRef) {
+                hangleChangeThrottled(
+                  startValue,
+                  Math.ceil(event.clientX - startPosition),
+                  "pointerMove",
+                  event.clientX - lastPointerRef.x,
+                );
+              }
+
+              lastPointerRef = {
+                x: event.clientX,
+                y: event.clientY,
+              };
+            };
+
+            window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
+            window.addEventListener(
+              EVENT.POINTER_UP,
+              () => {
+                window.removeEventListener(
+                  EVENT.POINTER_MOVE,
+                  onPointerMove,
+                  false,
+                );
+
+                lastPointerRef = null;
+
+                document.body.classList.remove("dragResize");
+              },
+              false,
+            );
+          }
+        }}
+        onPointerEnter={() => {
+          if (labelRef.current) {
+            labelRef.current.style.cursor = "ew-resize";
+          }
+        }}
+      >
+        {label}
+      </div>
+      <input
+        className="color-picker-input"
+        style={{
+          width: "66px",
+          fontSize: "12px",
+        }}
+        autoComplete="off"
+        spellCheck="false"
+        onKeyDown={(event) => {
+          const value = Number(event.target.value);
+          if (isNaN(value)) {
+            return;
+          }
+          if (event.key === KEYS.ENTER) {
+            handleChange(value, 0, "keyDown");
+          }
+        }}
+        ref={inputRef}
+      ></input>
+    </label>
+  );
+};
+
+export default DragInput;

+ 1 - 1
src/components/Stats.scss

@@ -2,7 +2,7 @@
 
 .excalidraw {
   .Stats {
-    width: 202px;
+    width: 204px;
     position: absolute;
     top: 64px;
     right: 12px;

+ 34 - 147
src/components/Stats.tsx

@@ -1,14 +1,7 @@
-import { nanoid } from "nanoid";
 import React, { useEffect, useMemo, useState } from "react";
 import { getCommonBounds } from "../element/bounds";
-import { mutateElement } from "../element/mutateElement";
-import {
-  ExcalidrawElement,
-  NonDeletedExcalidrawElement,
-} from "../element/types";
+import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
-import { KEYS } from "../keys";
-import { degreeToRadian, radianToDegree } from "../math";
 import { getTargetElements } from "../scene";
 import Scene from "../scene/Scene";
 import { AppState, ExcalidrawProps } from "../types";
@@ -16,6 +9,7 @@ import { CloseIcon } from "./icons";
 import { Island } from "./Island";
 import "./Stats.scss";
 import { throttle } from "lodash";
+import DragInput from "./DragInput";
 
 const STATS_TIMEOUT = 50;
 interface StatsProps {
@@ -28,9 +22,6 @@ interface StatsProps {
 
 type ElementStatItem = {
   label: string;
-  value: number;
-  element: NonDeletedExcalidrawElement;
-  version: string;
   property: "x" | "y" | "width" | "height" | "angle";
 };
 
@@ -70,70 +61,6 @@ export const Stats = (props: StatsProps) => {
     [throttledSetSceneDimension],
   );
 
-  const [elementStats, setElementStats] = useState<ElementStatItem[]>([]);
-
-  const throttledSetElementStats = useMemo(
-    () =>
-      throttle((element: NonDeletedExcalidrawElement | null) => {
-        const stats: ElementStatItem[] = element
-          ? [
-              {
-                label: "X",
-                value: Math.round(element.x),
-                element,
-                property: "x",
-                version: nanoid(),
-              },
-              {
-                label: "Y",
-                value: Math.round(element.y),
-                element,
-                property: "y",
-                version: nanoid(),
-              },
-              {
-                label: "W",
-                value: Math.round(element.width),
-                element,
-                property: "width",
-                version: nanoid(),
-              },
-              {
-                label: "H",
-                value: Math.round(element.height),
-                element,
-                property: "height",
-                version: nanoid(),
-              },
-              {
-                label: "A",
-                value: Math.round(radianToDegree(element.angle) * 100) / 100,
-                element,
-                property: "angle",
-                version: nanoid(),
-              },
-            ]
-          : [];
-
-        setElementStats(stats);
-      }, STATS_TIMEOUT),
-    [],
-  );
-
-  useEffect(() => {
-    throttledSetElementStats(singleElement);
-  }, [
-    singleElement,
-    singleElement?.version,
-    singleElement?.versionNonce,
-    throttledSetElementStats,
-  ]);
-
-  useEffect(
-    () => () => throttledSetElementStats.cancel(),
-    [throttledSetElementStats],
-  );
-
   return (
     <div className="Stats">
       <Island padding={3}>
@@ -185,78 +112,38 @@ export const Stats = (props: StatsProps) => {
                   gap: "4px 8px",
                 }}
               >
-                {elementStats.map((statsItem) => {
-                  return (
-                    <label
-                      className="color-input-container"
-                      key={statsItem.property}
-                    >
-                      <div
-                        className="color-picker-hash"
-                        style={{
-                          width: "30px",
-                        }}
-                      >
-                        {statsItem.label}
-                      </div>
-                      <input
-                        id={statsItem.label}
-                        key={statsItem.version}
-                        defaultValue={statsItem.value}
-                        className="color-picker-input"
-                        style={{
-                          width: "55px",
-                        }}
-                        autoComplete="off"
-                        spellCheck="false"
-                        onKeyDown={(event) => {
-                          let value = Number(event.target.value);
-
-                          if (isNaN(value)) {
-                            return;
-                          }
-
-                          value =
-                            statsItem.property === "angle"
-                              ? degreeToRadian(value)
-                              : value;
-
-                          if (event.key === KEYS.ENTER) {
-                            mutateElement(statsItem.element, {
-                              [statsItem.property]: value,
-                            });
-
-                            event.target.value = statsItem.element[
-                              statsItem.property as keyof ExcalidrawElement
-                            ] as string;
-                          }
-                        }}
-                        onBlur={(event) => {
-                          let value = Number(event.target.value);
-
-                          if (isNaN(value)) {
-                            return;
-                          }
-
-                          value =
-                            statsItem.property === "angle"
-                              ? degreeToRadian(value)
-                              : value;
-
-                          if (!isNaN(value)) {
-                            mutateElement(statsItem.element, {
-                              [statsItem.property]: value,
-                            });
-                          }
-
-                          event.target.value = statsItem.element[
-                            statsItem.property as keyof ExcalidrawElement
-                          ] as string;
-                        }}
-                      ></input>
-                    </label>
-                  );
-                })}
+                {(
+                  [
+                    {
+                      label: "X",
+                      property: "x",
+                    },
+                    {
+                      label: "Y",
+                      property: "y",
+                    },
+                    {
+                      label: "W",
+                      property: "width",
+                    },
+                    {
+                      label: "H",
+                      property: "height",
+                    },
+                    {
+                      label: "A",
+                      property: "angle",
+                    },
+                  ] as ElementStatItem[]
+                ).map((statsItem) => (
+                  <DragInput
+                    key={statsItem.label}
+                    label={statsItem.label}
+                    property={statsItem.property}
+                    element={singleElement}
+                    zoom={props.appState.zoom}
+                  />
+                ))}
               </div>
             </div>
           </div>

+ 6 - 0
src/css/styles.scss

@@ -7,6 +7,12 @@
   --zIndex-layerUI: 3;
 }
 
+body.dragResize,
+body.dragResize a:hover,
+body.dragResize * {
+  cursor: ew-resize;
+}
+
 .excalidraw {
   --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
     Roboto, Helvetica, Arial, sans-serif;