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/imageExportUtils";

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

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> => {
// this function was mostly copied from the tldraw package
davwas marked this conversation as resolved.
Show resolved Hide resolved
// as we need to extend its functionality to support exporting assets stored in s3

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

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');`;
davwas marked this conversation as resolved.
Show resolved Hide resolved
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