소스 검색

update appState on copy-styles & improve paste

dwelle 4 년 전
부모
커밋
ef82e15ee8
6개의 변경된 파일1076개의 추가작업 그리고 321개의 파일을 삭제
  1. 128 28
      src/actions/actionStyles.ts
  2. 13 0
      src/element/types.ts
  3. 9 0
      src/global.d.ts
  4. 719 234
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  5. 8 2
      src/tests/helpers/api.ts
  6. 199 57
      src/tests/regressionTests.test.tsx

+ 128 - 28
src/actions/actionStyles.ts

@@ -1,28 +1,110 @@
 import {
   isTextElement,
-  isExcalidrawElement,
   redrawTextBoundingBox,
+  getNonDeletedElements,
 } from "../element";
 import { CODES, KEYS } from "../keys";
 import { register } from "./register";
-import { mutateElement, newElementWith } from "../element/mutateElement";
+import { newElementWith } from "../element/mutateElement";
 import {
-  DEFAULT_FONT_SIZE,
-  DEFAULT_FONT_FAMILY,
-  DEFAULT_TEXT_ALIGN,
-} from "../constants";
+  ExcalidrawElement,
+  ExcalidrawElementPossibleProps,
+} from "../element/types";
+import { AppState } from "../types";
+import {
+  canChangeSharpness,
+  getSelectedElements,
+  hasBackground,
+  hasStroke,
+  hasText,
+} from "../scene";
+import { isLinearElement, isLinearElementType } from "../element/typeChecks";
+
+type AppStateStyles = {
+  [K in AssertSubset<
+    keyof AppState,
+    typeof copyableStyles[number][0]
+  >]: AppState[K];
+};
+
+type ElementStyles = {
+  [K in AssertSubset<
+    keyof ExcalidrawElementPossibleProps,
+    typeof copyableStyles[number][1]
+  >]: ExcalidrawElementPossibleProps[K];
+};
+
+type ElemelementStylesByType = Record<ExcalidrawElement["type"], ElementStyles>;
 
 // `copiedStyles` is exported only for tests.
-export let copiedStyles: string = "{}";
+let COPIED_STYLES: {
+  appStateStyles: Partial<AppStateStyles>;
+  elementStyles: Partial<ElementStyles>;
+  elementStylesByType: Partial<ElemelementStylesByType>;
+} | null = null;
+
+/* [AppState prop, ExcalidrawElement prop, predicate] */
+const copyableStyles = [
+  ["currentItemOpacity", "opacity", () => true],
+  ["currentItemStrokeColor", "strokeColor", () => true],
+  ["currentItemStrokeStyle", "strokeStyle", hasStroke],
+  ["currentItemStrokeWidth", "strokeWidth", hasStroke],
+  ["currentItemRoughness", "roughness", hasStroke],
+  ["currentItemBackgroundColor", "backgroundColor", hasBackground],
+  ["currentItemFillStyle", "fillStyle", hasBackground],
+  ["currentItemStrokeSharpness", "strokeSharpness", canChangeSharpness],
+  ["currentItemLinearStrokeSharpness", "strokeSharpness", isLinearElementType],
+  ["currentItemStartArrowhead", "startArrowhead", isLinearElementType],
+  ["currentItemEndArrowhead", "endArrowhead", isLinearElementType],
+  ["currentItemFontFamily", "fontFamily", hasText],
+  ["currentItemFontSize", "fontSize", hasText],
+  ["currentItemTextAlign", "textAlign", hasText],
+] as const;
+
+const getCommonStyleProps = (
+  elements: readonly ExcalidrawElement[],
+): Exclude<typeof COPIED_STYLES, null> => {
+  const appStateStyles = {} as AppStateStyles;
+  const elementStyles = {} as ElementStyles;
+
+  const elementStylesByType = elements.reduce((acc, element) => {
+    // only use the first element of given type
+    if (!acc[element.type]) {
+      acc[element.type] = {} as ElementStyles;
+      copyableStyles.forEach(([appStateProp, prop, predicate]) => {
+        const value = (element as any)[prop];
+        if (value !== undefined && predicate(element.type)) {
+          if (appStateStyles[appStateProp] === undefined) {
+            (appStateStyles as any)[appStateProp] = value;
+          }
+          if (elementStyles[prop] === undefined) {
+            (elementStyles as any)[prop] = value;
+          }
+          (acc as any)[element.type][prop] = value;
+        }
+      });
+    }
+    return acc;
+  }, {} as ElemelementStylesByType);
+
+  // clone in case we ever make some of the props into non-primitives
+  return JSON.parse(
+    JSON.stringify({ appStateStyles, elementStyles, elementStylesByType }),
+  );
+};
 
 export const actionCopyStyles = register({
   name: "copyStyles",
   perform: (elements, appState) => {
-    const element = elements.find((el) => appState.selectedElementIds[el.id]);
-    if (element) {
-      copiedStyles = JSON.stringify(element);
-    }
+    COPIED_STYLES = getCommonStyleProps(
+      getSelectedElements(getNonDeletedElements(elements), appState),
+    );
+
     return {
+      appState: {
+        ...appState,
+        ...COPIED_STYLES.appStateStyles,
+      },
       commitToHistory: false,
     };
   },
@@ -35,31 +117,49 @@ export const actionCopyStyles = register({
 export const actionPasteStyles = register({
   name: "pasteStyles",
   perform: (elements, appState) => {
-    const pastedElement = JSON.parse(copiedStyles);
-    if (!isExcalidrawElement(pastedElement)) {
+    if (!COPIED_STYLES) {
       return { elements, commitToHistory: false };
     }
+    const getStyle = <T extends ExcalidrawElement, K extends keyof T>(
+      element: T,
+      prop: K,
+    ) => {
+      return (COPIED_STYLES?.elementStylesByType[element.type]?.[
+        prop as keyof ElementStyles
+      ] ??
+        COPIED_STYLES?.elementStyles[prop as keyof ElementStyles] ??
+        element[prop]) as T[K];
+    };
     return {
       elements: elements.map((element) => {
         if (appState.selectedElementIds[element.id]) {
-          const newElement = newElementWith(element, {
-            backgroundColor: pastedElement?.backgroundColor,
-            strokeWidth: pastedElement?.strokeWidth,
-            strokeColor: pastedElement?.strokeColor,
-            strokeStyle: pastedElement?.strokeStyle,
-            fillStyle: pastedElement?.fillStyle,
-            opacity: pastedElement?.opacity,
-            roughness: pastedElement?.roughness,
-          });
-          if (isTextElement(newElement)) {
-            mutateElement(newElement, {
-              fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
-              fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
-              textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
+          const commonProps = {
+            backgroundColor: getStyle(element, "backgroundColor"),
+            strokeWidth: getStyle(element, "strokeWidth"),
+            strokeColor: getStyle(element, "strokeColor"),
+            strokeStyle: getStyle(element, "strokeStyle"),
+            fillStyle: getStyle(element, "fillStyle"),
+            opacity: getStyle(element, "opacity"),
+            roughness: getStyle(element, "roughness"),
+            strokeSharpness: getStyle(element, "strokeSharpness"),
+          };
+          if (isTextElement(element)) {
+            const newElement = newElementWith(element, {
+              ...commonProps,
+              fontSize: getStyle(element, "fontSize"),
+              fontFamily: getStyle(element, "fontFamily"),
+              textAlign: getStyle(element, "textAlign"),
             });
             redrawTextBoundingBox(newElement);
+            return newElement;
+          } else if (isLinearElement(element)) {
+            return newElementWith(element, {
+              ...commonProps,
+              startArrowhead: getStyle(element, "startArrowhead"),
+              endArrowhead: getStyle(element, "endArrowhead"),
+            });
           }
-          return newElement;
+          return newElementWith(element, commonProps);
         }
         return element;
       }),

+ 13 - 0
src/element/types.ts

@@ -110,3 +110,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
     startArrowhead: Arrowhead | null;
     endArrowhead: Arrowhead | null;
   }>;
+
+export type ExcalidrawElementTypes = Pick<ExcalidrawElement, "type">["type"];
+
+/** @private */
+type __ExcalidrawElementPossibleProps_withoutType<T> = T extends any
+  ? { [K in keyof Omit<T, "type">]: T[K] }
+  : never;
+
+/** Do not use for anything unless you really need it for some abstract
+    API types */
+export type ExcalidrawElementPossibleProps = UnionToIntersection<
+  __ExcalidrawElementPossibleProps_withoutType<ExcalidrawElement>
+> & { type: ExcalidrawElementTypes };

+ 9 - 0
src/global.d.ts

@@ -46,6 +46,15 @@ type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
 type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
   Required<Pick<T, RK>>;
 
+type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (
+  x: infer R,
+) => any
+  ? R
+  : never;
+
+/** Assert K is a subset of T, and returns K */
+type AssertSubset<T, K extends T> = K;
+
 // PNG encoding/decoding
 // -----------------------------------------------------------------------------
 type TEXtChunk = { name: "tEXt"; data: Uint8Array };

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 719 - 234
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 8 - 2
src/tests/helpers/api.ts

@@ -81,6 +81,12 @@ export class API {
     verticalAlign?: T extends "text"
       ? ExcalidrawTextElement["verticalAlign"]
       : never;
+    startArrowhead?: T extends "arrow" | "line" | "draw"
+      ? ExcalidrawLinearElement["startArrowhead"]
+      : never;
+    endArrowhead?: T extends "arrow" | "line" | "draw"
+      ? ExcalidrawLinearElement["endArrowhead"]
+      : never;
   }): T extends "arrow" | "line" | "draw"
     ? ExcalidrawLinearElement
     : T extends "text"
@@ -130,8 +136,8 @@ export class API {
       case "draw":
         element = newLinearElement({
           type: type as "arrow" | "line" | "draw",
-          startArrowhead: null,
-          endArrowhead: null,
+          startArrowhead: rest.startArrowhead ?? null,
+          endArrowhead: rest.endArrowhead ?? null,
           ...base,
         });
         break;

+ 199 - 57
src/tests/regressionTests.test.tsx

@@ -1,7 +1,7 @@
 import { queryByText } from "@testing-library/react";
 import React from "react";
 import ReactDOM from "react-dom";
-import { copiedStyles } from "../actions/actionStyles";
+import { getDefaultAppState } from "../appState";
 import { ExcalidrawElement } from "../element/types";
 import { setLanguage, t } from "../i18n";
 import { CODES, KEYS } from "../keys";
@@ -768,82 +768,224 @@ describe("regression tests", () => {
     });
   });
 
-  it("selecting 'Copy styles' in context menu copies styles", () => {
-    UI.clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(20, 20);
+  it("copy-styles updates appState defaults", () => {
+    h.app.updateScene({
+      elements: [
+        API.createElement({
+          type: "rectangle",
+          id: "A",
+          x: 0,
+          y: 0,
+          opacity: 90,
+          strokeColor: "#FF0000",
+          strokeStyle: "solid",
+          strokeWidth: 10,
+          roughness: 2,
+          backgroundColor: "#00FF00",
+          fillStyle: "solid",
+          strokeSharpness: "sharp",
+        }),
+        API.createElement({
+          type: "arrow",
+          id: "B",
+          x: 200,
+          y: 200,
+          startArrowhead: "bar",
+          endArrowhead: "bar",
+        }),
+        API.createElement({
+          type: "text",
+          id: "C",
+          x: 200,
+          y: 200,
+          fontFamily: 3,
+          fontSize: 200,
+          textAlign: "center",
+        }),
+      ],
+    });
+
+    h.app.setState({
+      selectedElementIds: { A: true, B: true, C: true },
+    });
+
+    const defaultAppState = getDefaultAppState();
+
+    expect(h.state).toEqual(
+      expect.objectContaining({
+        currentItemOpacity: defaultAppState.currentItemOpacity,
+        currentItemStrokeColor: defaultAppState.currentItemStrokeColor,
+        currentItemStrokeStyle: defaultAppState.currentItemStrokeStyle,
+        currentItemStrokeWidth: defaultAppState.currentItemStrokeWidth,
+        currentItemRoughness: defaultAppState.currentItemRoughness,
+        currentItemBackgroundColor: defaultAppState.currentItemBackgroundColor,
+        currentItemFillStyle: defaultAppState.currentItemFillStyle,
+        currentItemStrokeSharpness: defaultAppState.currentItemStrokeSharpness,
+        currentItemStartArrowhead: defaultAppState.currentItemStartArrowhead,
+        currentItemEndArrowhead: defaultAppState.currentItemEndArrowhead,
+        currentItemFontFamily: defaultAppState.currentItemFontFamily,
+        currentItemFontSize: defaultAppState.currentItemFontSize,
+        currentItemTextAlign: defaultAppState.currentItemTextAlign,
+      }),
+    );
 
     fireEvent.contextMenu(GlobalTestState.canvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
     });
+
     const contextMenu = document.querySelector(".context-menu");
-    expect(copiedStyles).toBe("{}");
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
-    expect(copiedStyles).not.toBe("{}");
-    const element = JSON.parse(copiedStyles);
-    expect(element).toEqual(API.getSelectedElement());
-  });
 
-  it("selecting 'Paste styles' in context menu pastes styles", () => {
-    UI.clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(20, 20);
+    expect(h.state).toEqual(
+      expect.objectContaining({
+        currentItemOpacity: 90,
+        currentItemStrokeColor: "#FF0000",
+        currentItemStrokeStyle: "solid",
+        currentItemStrokeWidth: 10,
+        currentItemRoughness: 2,
+        currentItemBackgroundColor: "#00FF00",
+        currentItemFillStyle: "solid",
+        currentItemStrokeSharpness: "sharp",
+        currentItemStartArrowhead: "bar",
+        currentItemEndArrowhead: "bar",
+        currentItemFontFamily: 3,
+        currentItemFontSize: 200,
+        currentItemTextAlign: "center",
+      }),
+    );
+  });
 
-    UI.clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(20, 20);
+  it("paste-styles action", () => {
+    h.app.updateScene({
+      elements: [
+        API.createElement({
+          type: "rectangle",
+          id: "A",
+          x: 0,
+          y: 0,
+          opacity: 90,
+          strokeColor: "#FF0000",
+          strokeStyle: "solid",
+          strokeWidth: 10,
+          roughness: 2,
+          backgroundColor: "#00FF00",
+          fillStyle: "solid",
+          strokeSharpness: "sharp",
+        }),
+        API.createElement({
+          type: "arrow",
+          id: "B",
+          x: 0,
+          y: 0,
+          startArrowhead: "bar",
+          endArrowhead: "bar",
+        }),
+        API.createElement({
+          type: "text",
+          id: "C",
+          x: 0,
+          y: 0,
+          fontFamily: 3,
+          fontSize: 200,
+          textAlign: "center",
+        }),
+        API.createElement({
+          type: "rectangle",
+          id: "D",
+          x: 200,
+          y: 200,
+        }),
+        API.createElement({
+          type: "arrow",
+          id: "E",
+          x: 200,
+          y: 200,
+        }),
+        API.createElement({
+          type: "text",
+          id: "F",
+          x: 200,
+          y: 200,
+        }),
+      ],
+    });
 
-    // Change some styles of second rectangle
-    clickLabeledElement("Stroke");
-    clickLabeledElement("#c92a2a");
-    clickLabeledElement("Background");
-    clickLabeledElement("#e64980");
-    // Fill style
-    fireEvent.click(screen.getByTitle("Cross-hatch"));
-    // Stroke width
-    fireEvent.click(screen.getByTitle("Bold"));
-    // Stroke style
-    fireEvent.click(screen.getByTitle("Dotted"));
-    // Roughness
-    fireEvent.click(screen.getByTitle("Cartoonist"));
-    // Opacity
-    fireEvent.change(screen.getByLabelText("Opacity"), {
-      target: { value: "60" },
+    h.app.setState({
+      selectedElementIds: { A: true, B: true, C: true },
     });
 
-    mouse.reset();
-    // Copy styles of second rectangle
     fireEvent.contextMenu(GlobalTestState.canvas, {
       button: 2,
-      clientX: 40,
-      clientY: 40,
+      clientX: 1,
+      clientY: 1,
     });
-    let contextMenu = document.querySelector(".context-menu");
-    fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
-    const secondRect = JSON.parse(copiedStyles);
-    expect(secondRect.id).toBe(h.elements[1].id);
+    fireEvent.click(
+      queryByText(
+        document.querySelector(".context-menu") as HTMLElement,
+        "Copy styles",
+      )!,
+    );
 
-    mouse.reset();
-    // Paste styles to first rectangle
+    h.app.setState({
+      selectedElementIds: { D: true, E: true, F: true },
+    });
     fireEvent.contextMenu(GlobalTestState.canvas, {
       button: 2,
-      clientX: 10,
-      clientY: 10,
+      clientX: 201,
+      clientY: 201,
     });
-    contextMenu = document.querySelector(".context-menu");
-    fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
-
-    const firstRect = API.getSelectedElement();
-    expect(firstRect.id).toBe(h.elements[0].id);
-    expect(firstRect.strokeColor).toBe("#c92a2a");
-    expect(firstRect.backgroundColor).toBe("#e64980");
-    expect(firstRect.fillStyle).toBe("cross-hatch");
-    expect(firstRect.strokeWidth).toBe(2); // Bold: 2
-    expect(firstRect.strokeStyle).toBe("dotted");
-    expect(firstRect.roughness).toBe(2); // Cartoonist: 2
-    expect(firstRect.opacity).toBe(60);
+    fireEvent.click(
+      queryByText(
+        document.querySelector(".context-menu") as HTMLElement,
+        "Paste styles",
+      )!,
+    );
+
+    const defaultAppState = getDefaultAppState();
+
+    expect(h.elements.find((element) => element.id === "D")).toEqual(
+      expect.objectContaining({
+        opacity: 90,
+        strokeColor: "#FF0000",
+        strokeStyle: "solid",
+        strokeWidth: 10,
+        roughness: 2,
+        backgroundColor: "#00FF00",
+        fillStyle: "solid",
+        strokeSharpness: "sharp",
+      }),
+    );
+    expect(h.elements.find((element) => element.id === "E")).toEqual(
+      expect.objectContaining({
+        opacity: defaultAppState.currentItemOpacity,
+        strokeColor: defaultAppState.currentItemStrokeColor,
+        strokeStyle: defaultAppState.currentItemStrokeStyle,
+        strokeWidth: defaultAppState.currentItemStrokeWidth,
+        roughness: defaultAppState.currentItemRoughness,
+        backgroundColor: "#00FF00",
+        fillStyle: "solid",
+        strokeSharpness: "sharp",
+        startArrowhead: "bar",
+        endArrowhead: "bar",
+      }),
+    );
+    expect(h.elements.find((element) => element.id === "F")).toEqual(
+      expect.objectContaining({
+        opacity: defaultAppState.currentItemOpacity,
+        strokeColor: defaultAppState.currentItemStrokeColor,
+        strokeStyle: defaultAppState.currentItemStrokeStyle,
+        strokeWidth: 10,
+        roughness: 2,
+        backgroundColor: "#00FF00",
+        fillStyle: "solid",
+        strokeSharpness: "sharp",
+        fontFamily: 3,
+        fontSize: 200,
+        textAlign: "center",
+      }),
+    );
   });
 
   it("selecting 'Delete' in context menu deletes element", () => {

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.