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 { getBlob } from "../utils/exportUtils";

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 getBlob(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
240 changes: 240 additions & 0 deletions src/utils/exportUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import {
TDExportBackground,
TDExportType,
TDShape,
TDShapeType,
TLDR,
TldrawApp,
} from "@tldraw/tldraw";
import { Utils } from "@tldraw/core";

export const getBlob = 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 (
davwas marked this conversation as resolved.
Show resolved Hide resolved
app: TldrawApp,
format: TDExportType,
): Promise<SVGElement | undefined> => {
const SVG_EXPORT_PADDING = 16;

const ids = app.selectedIds.length
? app.selectedIds
: Object.keys(app.page.shapes);
const includeFonts = format !== TDExportType.SVG;

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

// 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
}

if (includeFonts) {
davwas marked this conversation as resolved.
Show resolved Hide resolved
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.");
}
} else {
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');`;
}

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

// 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));

// A quick routine to get an SVG element for each shape
const getSvgElementForShape = (shape: TDShape) => {
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));
} 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;
};

// Assemble the final SVG by iterating through each shape and its children
shapes.forEach((shape) => {
// 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);

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

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

return;
}

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

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

// 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(" "),
);

// 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(),
);

// 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;
}
}

svg
.querySelectorAll(
".tl-fill-hitarea, .tl-stroke-hitarea, .tl-binding-indicator",
)
.forEach((elm) => elm.remove());

return svg;
};

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");
};
Loading