Browse Source

fix: image-mirroring in export preview and in exported svg (#5700)

Co-authored-by: dwelle <luzar.david@gmail.com>
Ryan Di 2 năm trước cách đây
mục cha
commit
3a776f8795

+ 6 - 3
src/element/newElement.ts

@@ -308,6 +308,9 @@ export const newLinearElement = (
 export const newImageElement = (
 export const newImageElement = (
   opts: {
   opts: {
     type: ExcalidrawImageElement["type"];
     type: ExcalidrawImageElement["type"];
+    status?: ExcalidrawImageElement["status"];
+    fileId?: ExcalidrawImageElement["fileId"];
+    scale?: ExcalidrawImageElement["scale"];
   } & ElementConstructorOpts,
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawImageElement> => {
 ): NonDeleted<ExcalidrawImageElement> => {
   return {
   return {
@@ -315,9 +318,9 @@ export const newImageElement = (
     // in the future we'll support changing stroke color for some SVG elements,
     // in the future we'll support changing stroke color for some SVG elements,
     // and `transparent` will likely mean "use original colors of the image"
     // and `transparent` will likely mean "use original colors of the image"
     strokeColor: "transparent",
     strokeColor: "transparent",
-    status: "pending",
-    fileId: null,
-    scale: [1, 1],
+    status: opts.status ?? "pending",
+    fileId: opts.fileId ?? null,
+    scale: opts.scale ?? [1, 1],
   };
   };
 };
 };
 
 

+ 26 - 4
src/renderer/renderElement.ts

@@ -790,6 +790,9 @@ export const renderElement = (
         context.save();
         context.save();
         context.translate(cx, cy);
         context.translate(cx, cy);
         context.rotate(element.angle);
         context.rotate(element.angle);
+        if (element.type === "image") {
+          context.scale(element.scale[0], element.scale[1]);
+        }
         context.translate(-shiftX, -shiftY);
         context.translate(-shiftX, -shiftY);
 
 
         if (shouldResetImageFilter(element, renderConfig)) {
         if (shouldResetImageFilter(element, renderConfig)) {
@@ -950,6 +953,8 @@ export const renderElementToSvg = (
       break;
       break;
     }
     }
     case "image": {
     case "image": {
+      const width = Math.round(element.width);
+      const height = Math.round(element.height);
       const fileData =
       const fileData =
         isInitializedImageElement(element) && files[element.fileId];
         isInitializedImageElement(element) && files[element.fileId];
       if (fileData) {
       if (fileData) {
@@ -978,17 +983,34 @@ export const renderElementToSvg = (
           use.setAttribute("filter", IMAGE_INVERT_FILTER);
           use.setAttribute("filter", IMAGE_INVERT_FILTER);
         }
         }
 
 
-        use.setAttribute("width", `${Math.round(element.width)}`);
-        use.setAttribute("height", `${Math.round(element.height)}`);
+        use.setAttribute("width", `${width}`);
+        use.setAttribute("height", `${height}`);
+
+        // We first apply `scale` transforms (horizontal/vertical mirroring)
+        // on the <use> element, then apply translation and rotation
+        // on the <g> element which wraps the <use>.
+        // Doing this separately is a quick hack to to work around compositing
+        // the transformations correctly (the transform-origin was not being
+        // applied correctly).
+        if (element.scale[0] !== 1 || element.scale[1] !== 1) {
+          const translateX = element.scale[0] !== 1 ? -width : 0;
+          const translateY = element.scale[1] !== 1 ? -height : 0;
+          use.setAttribute(
+            "transform",
+            `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`,
+          );
+        }
 
 
-        use.setAttribute(
+        const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
+        g.appendChild(use);
+        g.setAttribute(
           "transform",
           "transform",
           `translate(${offsetX || 0} ${
           `translate(${offsetX || 0} ${
             offsetY || 0
             offsetY || 0
           }) rotate(${degree} ${cx} ${cy})`,
           }) rotate(${degree} ${cx} ${cy})`,
         );
         );
 
 
-        root.appendChild(use);
+        root.appendChild(g);
       }
       }
       break;
       break;
     }
     }

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 3 - 0
src/tests/__snapshots__/export.test.tsx.snap


+ 73 - 0
src/tests/export.test.tsx

@@ -7,6 +7,10 @@ import {
   decodeSvgMetadata,
   decodeSvgMetadata,
 } from "../data/image";
 } from "../data/image";
 import { serializeAsJSON } from "../data/json";
 import { serializeAsJSON } from "../data/json";
+import { exportToSvg } from "../scene/export";
+import { FileId } from "../element/types";
+import { getDataURL } from "../data/blob";
+import { getDefaultAppState } from "../appState";
 
 
 const { h } = window;
 const { h } = window;
 
 
@@ -101,4 +105,73 @@ describe("export", () => {
       ]);
       ]);
     });
     });
   });
   });
+
+  it("exporting svg containing transformed images", async () => {
+    const normalizeAngle = (angle: number) => (angle / 180) * Math.PI;
+
+    const elements = [
+      API.createElement({
+        type: "image",
+        fileId: "file_A",
+        x: 0,
+        y: 0,
+        scale: [1, 1],
+        width: 100,
+        height: 100,
+        angle: normalizeAngle(315),
+      }),
+      API.createElement({
+        type: "image",
+        fileId: "file_A",
+        x: 100,
+        y: 0,
+        scale: [-1, 1],
+        width: 50,
+        height: 50,
+        angle: normalizeAngle(45),
+      }),
+      API.createElement({
+        type: "image",
+        fileId: "file_A",
+        x: 0,
+        y: 100,
+        scale: [1, -1],
+        width: 100,
+        height: 100,
+        angle: normalizeAngle(45),
+      }),
+      API.createElement({
+        type: "image",
+        fileId: "file_A",
+        x: 100,
+        y: 100,
+        scale: [-1, -1],
+        width: 50,
+        height: 50,
+        angle: normalizeAngle(315),
+      }),
+    ];
+    const appState = { ...getDefaultAppState(), exportBackground: false };
+    const files = {
+      file_A: {
+        id: "file_A" as FileId,
+        dataURL: await getDataURL(await API.loadFile("./fixtures/deer.png")),
+        mimeType: "image/png",
+        created: Date.now(),
+      },
+    } as const;
+
+    const svg = await exportToSvg(elements, appState, files);
+
+    const svgText = svg.outerHTML;
+
+    // expect 1 <image> element (deduped)
+    expect(svgText.match(/<image/g)?.length).toBe(1);
+    // expect 4 <use> elements (one for each excalidraw image element)
+    expect(svgText.match(/<use/g)?.length).toBe(4);
+
+    // in case of regressions, save the SVG to a file and visually compare to:
+    // src/tests/fixtures/svg-image-exporting-reference.svg
+    expect(svgText).toMatchSnapshot(`svg export output`);
+  });
 });
 });

BIN
src/tests/fixtures/deer.png


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
src/tests/fixtures/svg-image-exporting-reference.svg


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

@@ -4,6 +4,8 @@ import {
   ExcalidrawTextElement,
   ExcalidrawTextElement,
   ExcalidrawLinearElement,
   ExcalidrawLinearElement,
   ExcalidrawFreeDrawElement,
   ExcalidrawFreeDrawElement,
+  ExcalidrawImageElement,
+  FileId,
 } from "../../element/types";
 } from "../../element/types";
 import { newElement, newTextElement, newLinearElement } from "../../element";
 import { newElement, newTextElement, newLinearElement } from "../../element";
 import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
 import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
@@ -13,7 +15,7 @@ import fs from "fs";
 import util from "util";
 import util from "util";
 import path from "path";
 import path from "path";
 import { getMimeType } from "../../data/blob";
 import { getMimeType } from "../../data/blob";
-import { newFreeDrawElement } from "../../element/newElement";
+import { newFreeDrawElement, newImageElement } from "../../element/newElement";
 import { Point } from "../../types";
 import { Point } from "../../types";
 import { getSelectedElements } from "../../scene/selection";
 import { getSelectedElements } from "../../scene/selection";
 
 
@@ -77,6 +79,7 @@ export class API {
     y?: number;
     y?: number;
     height?: number;
     height?: number;
     width?: number;
     width?: number;
+    angle?: number;
     id?: string;
     id?: string;
     isDeleted?: boolean;
     isDeleted?: boolean;
     groupIds?: string[];
     groupIds?: string[];
@@ -103,12 +106,17 @@ export class API {
       : never;
       : never;
     points?: T extends "arrow" | "line" ? readonly Point[] : never;
     points?: T extends "arrow" | "line" ? readonly Point[] : never;
     locked?: boolean;
     locked?: boolean;
+    fileId?: T extends "image" ? string : never;
+    scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
+    status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
   }): T extends "arrow" | "line"
   }): T extends "arrow" | "line"
     ? ExcalidrawLinearElement
     ? ExcalidrawLinearElement
     : T extends "freedraw"
     : T extends "freedraw"
     ? ExcalidrawFreeDrawElement
     ? ExcalidrawFreeDrawElement
     : T extends "text"
     : T extends "text"
     ? ExcalidrawTextElement
     ? ExcalidrawTextElement
+    : T extends "image"
+    ? ExcalidrawImageElement
     : ExcalidrawGenericElement => {
     : ExcalidrawGenericElement => {
     let element: Mutable<ExcalidrawElement> = null!;
     let element: Mutable<ExcalidrawElement> = null!;
 
 
@@ -117,6 +125,7 @@ export class API {
     const base = {
     const base = {
       x,
       x,
       y,
       y,
+      angle: rest.angle ?? 0,
       strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
       strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
       backgroundColor:
       backgroundColor:
         rest.backgroundColor ?? appState.currentItemBackgroundColor,
         rest.backgroundColor ?? appState.currentItemBackgroundColor,
@@ -167,12 +176,23 @@ export class API {
           ...base,
           ...base,
           width,
           width,
           height,
           height,
-          type: type as "arrow" | "line",
+          type,
           startArrowhead: null,
           startArrowhead: null,
           endArrowhead: null,
           endArrowhead: null,
           points: rest.points ?? [],
           points: rest.points ?? [],
         });
         });
         break;
         break;
+      case "image":
+        element = newImageElement({
+          ...base,
+          width,
+          height,
+          type,
+          fileId: (rest.fileId as string as FileId) ?? null,
+          status: rest.status || "saved",
+          scale: rest.scale || [1, 1],
+        });
+        break;
     }
     }
     if (id) {
     if (id) {
       element.id = id;
       element.id = id;

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác