fix: image-mirroring in export preview and in exported svg (#5700)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
9929a2be6f
commit
3a776f8795
@ -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",
|
status: opts.status ?? "pending",
|
||||||
fileId: null,
|
fileId: opts.fileId ?? null,
|
||||||
scale: [1, 1],
|
scale: opts.scale ?? [1, 1],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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("width", `${width}`);
|
||||||
use.setAttribute("height", `${Math.round(element.height)}`);
|
use.setAttribute("height", `${height}`);
|
||||||
|
|
||||||
use.setAttribute(
|
// 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})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
20
src/tests/__snapshots__/export.test.tsx.snap
Normal file
20
src/tests/__snapshots__/export.test.tsx.snap
Normal file
File diff suppressed because one or more lines are too long
@ -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
vendored
Normal file
BIN
src/tests/fixtures/deer.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
16
src/tests/fixtures/svg-image-exporting-reference.svg
vendored
Normal file
16
src/tests/fixtures/svg-image-exporting-reference.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 18 KiB |
@ -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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user