Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BC-6431 - Fix tldraw export to image functionality #53

Merged
merged 13 commits into from
Feb 12, 2024
20 changes: 14 additions & 6 deletions src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,19 @@ function Editor({
focusModeHandler: (isFocusMode: boolean) => void;
}) {
const { onSaveProjectAs, onSaveProject, onOpenMedia } = useFileSystem();
const { onMount, onOpen, onAssetCreate, onAssetDelete, onPatch, ...events } =
useMultiplayerState({
roomId,
setIsDarkMode: darkModeHandler,
setIsFocusMode: focusModeHandler,
});
const {
onMount,
onOpen,
onAssetCreate,
onAssetDelete,
onPatch,
onExport,
...events
} = useMultiplayerState({
roomId,
setIsDarkMode: darkModeHandler,
setIsFocusMode: focusModeHandler,
});
const containerRef = useRef<HTMLDivElement | null>(null);
useTldrawUiSanitizer(containerRef);
const { isDarkMode } = useTldrawSettings();
Expand All @@ -42,6 +49,7 @@ function Editor({
disableAssets={false}
onMount={onMount}
onPatch={onPatch}
onExport={onExport}
darkMode={isDarkMode}
{...events}
onOpenProject={onOpen}
Expand Down
18 changes: 18 additions & 0 deletions src/hooks/useMultiplayerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
TDAsset,
TDBinding,
TDDocument,
TDExport,
TDFile,
TDShape,
TDUser,
Expand All @@ -26,6 +27,7 @@ import {
} from "../stores/setup";
import { STORAGE_SETTINGS_KEY } from "../utils/userSettings";
import { UserPresence } from "../types/UserPresence";
import { getImageBlob } from "../utils/tldrawImageExportUtils";

declare const window: Window & { app: TldrawApp };

Expand Down Expand Up @@ -194,6 +196,7 @@ export function useMultiplayerState({
const onMount = useCallback(
(app: TldrawApp) => {
app.loadRoom(roomId);
app.document.name = `room-${roomId}`;
CeEv marked this conversation as resolved.
Show resolved Hide resolved
// Turn off the app's own undo / redo stack
app.pause();
// Put the state into the window, for debugging
Expand Down Expand Up @@ -237,6 +240,20 @@ export function useMultiplayerState({
room.updatePresence({ tdUser });
}, []);

const onExport = useCallback(
async (app: TldrawApp, info: TDExport) => {
const blob = await getImageBlob(app, info.type);
if (!blob) return;

const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${roomId}_export.${info.type}`;
link.click();
},
[roomId],
);

// Document Changes --------

// Update app users whenever there is a change in the room users
Expand Down Expand Up @@ -327,6 +344,7 @@ export function useMultiplayerState({
onRedo,
onMount,
onOpen,
onExport,
onChangePage,
onChangePresence,
loading,
Expand Down
14 changes: 14 additions & 0 deletions src/utils/imageExportUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Custom function for exporting images stored in external storage correctly
**/

export const serializeImage = (id: string): string => {
const image = document.getElementById(id + "_image") as HTMLImageElement;
if (image) {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
canvas.getContext("2d")!.drawImage(image, 0, 0);
return canvas.toDataURL("image/png");
} else throw new Error("Image with id " + id + " not found");
};
287 changes: 287 additions & 0 deletions src/utils/tldrawImageExportUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
/**
* Functions in this file were copied from the tldraw package.
* We needed to change some functionality to support exporting assets stored in external storage.
* @see Source code https://github.com/tldraw/tldraw-v1/blob/main/packages/tldraw/src/state/TldrawApp.ts
* @see Function we replaced with our own: serializeImage
**/

import {
TDExportBackground,
TDExportType,
TDShape,
TDShapeType,
TLDR,
TldrawApp,
} from "@tldraw/tldraw";
import { TLBounds, Utils } from "@tldraw/core";
import { serializeImage } from "./imageExportUtils";

const SVG_EXPORT_PADDING = 16;

const getImageBlob = async (
app: TldrawApp,
type: string,
): Promise<Blob | undefined> => {
const format = type as TDExportType;
if (format === TDExportType.JSON) return;

const svg = await getSvg(app, format);
if (!svg) return;

if (format === TDExportType.SVG) {
const svgString = TLDR.getSvgString(svg, 1);
const blob = new Blob([svgString], { type: "image/svg+xml" });

return blob;
}

const imageBlob = await TLDR.getImageForSvg(svg, format, {
scale: 2,
quality: 1,
});

return imageBlob;
};

const getSvg = async (
app: TldrawApp,
format: TDExportType,
): Promise<SVGElement | undefined> => {
const ids = app.selectedIds.length
? app.selectedIds
: Object.keys(app.page.shapes);

if (ids.length === 0) return;

const includeLocalFonts = format !== TDExportType.SVG;
const svg = await createInitialSvg(includeLocalFonts);

// Get the shapes in order
const shapes = ids
.map((id) => app.getShape(id, app.currentPageId))
.sort((a, b) => a.childIndex - b.childIndex);

// Find their common bounding box. Shapes will be positioned relative to this box
const commonBounds = Utils.getCommonBounds(shapes.map(TLDR.getRotatedBounds));

// Assemble the final SVG by iterating through each shape and its children
shapes.forEach((shape) => addShapeToSvg(svg, shape, commonBounds, app));

resizeToBoundingBox(svg, commonBounds);

removeHiddenElements(svg, commonBounds);

setBackgroundColor(svg, app);

removeUnusedElements(svg);

return svg;
};

const addShapeToSvg = (
svg: SVGElement,
shape: TDShape,
commonBounds: TLBounds,
app: TldrawApp,
) => {
{
// The shape is a group! Just add the children.
if (shape.children?.length) {
// Create a group <g> elm for shape
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");

// Get the shape's children as elms and add them to the group
shape.children.forEach((childId) => {
const shape = app.getShape(childId, app.currentPageId);
const elm = getSvgElementForShape(shape, commonBounds, app);

if (elm) {
g.append(elm);
}
});

// Add the group elm to the SVG
svg.append(g);

return;
}

// Just add the shape's element to the svg
const elm = getSvgElementForShape(shape, commonBounds, app);

if (elm) {
svg.append(elm);
}
}
};

const createInitialSvg = async (includeLocalFonts: boolean) => {
// Embed our custom fonts

const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const style = document.createElementNS("http://www.w3.org/2000/svg", "style");

if (typeof window !== "undefined") {
window.focus(); // weird but necessary
}

await includeTldrawFonts(includeLocalFonts, style);

defs.append(style);
svg.append(defs);

return svg;
};

const resizeToBoundingBox = (svg: SVGElement, commonBounds: TLBounds) => {
// Resize the elm to the bounding box

svg.setAttribute(
"viewBox",
[
0,
0,
commonBounds.width + SVG_EXPORT_PADDING * 2,
commonBounds.height + SVG_EXPORT_PADDING * 2,
].join(" "),
);
};

const removeHiddenElements = (svg: SVGElement, commonBounds: TLBounds) => {
// Clean up the SVG by removing any hidden elements

svg.setAttribute(
"width",
(commonBounds.width + SVG_EXPORT_PADDING * 2).toString(),
);
svg.setAttribute(
"height",
(commonBounds.height + SVG_EXPORT_PADDING * 2).toString(),
);
};

const removeUnusedElements = (svg: SVGElement) => {
svg
.querySelectorAll(
".tl-fill-hitarea, .tl-stroke-hitarea, .tl-binding-indicator",
)
.forEach((elm) => elm.remove());
};

const setBackgroundColor = (svg: SVGElement, app: TldrawApp) => {
// Set export background

const exportBackground: TDExportBackground = app.settings.exportBackground;
const darkBackground = "#212529";
const lightBackground = "rgb(248, 249, 250)";

switch (exportBackground) {
case TDExportBackground.Auto: {
svg.style.setProperty(
"background-color",
app.settings.isDarkMode ? darkBackground : lightBackground,
);
break;
}
case TDExportBackground.Dark: {
svg.style.setProperty("background-color", darkBackground);
break;
}
case TDExportBackground.Light: {
svg.style.setProperty("background-color", lightBackground);
break;
}
case TDExportBackground.Transparent:
default: {
svg.style.setProperty("background-color", "transparent");
break;
}
}
};

const getSvgElementForShape = (
shape: TDShape,
commonBounds: TLBounds,
app: TldrawApp,
) => {
// A quick routine to get an SVG element for each shape

const util = TLDR.getShapeUtil(shape);
const bounds = util.getBounds(shape);
const elm = util.getSvgElement(shape, app.settings.isDarkMode);

if (!elm) return;

// If the element is an image, set the asset src as the xlinkhref
if (shape.type === TDShapeType.Image) {
elm.setAttribute("xlink:href", serializeImage(shape.id)); // function we had replace with our own
} else if (shape.type === TDShapeType.Video) {
elm.setAttribute("xlink:href", app.serializeVideo(shape.id));
}

// Put the element in the correct position relative to the common bounds
elm.setAttribute(
"transform",
`translate(${(
SVG_EXPORT_PADDING +
shape.point[0] -
commonBounds.minX
).toFixed(2)}, ${(
SVG_EXPORT_PADDING +
shape.point[1] -
commonBounds.minY
).toFixed(2)}) rotate(${(((shape.rotation || 0) * 180) / Math.PI).toFixed(
2,
)}, ${(bounds.width / 2).toFixed(2)}, ${(bounds.height / 2).toFixed(2)})`,
);

return elm;
};

const includeTldrawFonts = async (
includeLocalFonts: boolean,
style: SVGElement,
) => {
if (!includeLocalFonts) {
style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block');`;
return;
}

try {
const { fonts } = await fetch(TldrawApp.assetSrc, {
mode: "no-cors",
}).then((d) => d.json());

style.textContent = `
@font-face {
font-family: 'Caveat Brush';
src: url(data:application/x-font-woff;charset=utf-8;base64,${fonts.caveat}) format('woff');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Source Code Pro';
src: url(data:application/x-font-woff;charset=utf-8;base64,${fonts.source_code_pro}) format('woff');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Source Sans Pro';
src: url(data:application/x-font-woff;charset=utf-8;base64,${fonts.source_sans_pro}) format('woff');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Crimson Pro';
src: url(data:application/x-font-woff;charset=utf-8;base64,${fonts.crimson_pro}) format('woff');
font-weight: 500;
font-style: normal;
}
`;
} catch (e) {
TLDR.warn("Could not find tldraw-assets.json file.");
}
};

export { getImageBlob };
Loading