UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

356 lines (355 loc) • 12 kB
import { jsx, jsxs } from "react/jsx-runtime"; import { useAtom, useValue } from "@tldraw/state-react"; import { getDefaultColorTheme } from "@tldraw/tlschema"; import { hasOwnProperty, promiseWithResolve, uniqueId } from "@tldraw/utils"; import { Fragment, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { flushSync } from "react-dom"; import { ErrorBoundary } from "../components/ErrorBoundary.mjs"; import { InnerShape, InnerShapeBackground } from "../components/Shape.mjs"; import { SvgExportContextProvider } from "../editor/types/SvgExportContext.mjs"; import { useEditor } from "../hooks/useEditor.mjs"; import { useEvent } from "../hooks/useEvent.mjs"; import { suffixSafeId, useUniqueSafeId } from "../hooks/useSafeId.mjs"; import { Mat } from "../primitives/Mat.mjs"; import { ExportDelay } from "./ExportDelay.mjs"; function getSvgJsx(editor, ids, opts = {}) { if (!window.document) throw Error("No document"); const { scale = 1, // should we include the background in the export? or is it transparent? background = editor.getInstanceState().exportBackground, padding = editor.options.defaultSvgPadding, preserveAspectRatio } = opts; const isDarkMode = opts.darkMode ?? editor.user.getIsDarkMode(); const shapeIdsToInclude = editor.getShapeAndDescendantIds(ids); const renderingShapes = editor.getUnorderedRenderingShapes(false).filter(({ id }) => shapeIdsToInclude.has(id)); let bbox = null; if (opts.bounds) { bbox = opts.bounds; } else { for (const { id } of renderingShapes) { const maskedPageBounds = editor.getShapeMaskedPageBounds(id); if (!maskedPageBounds) continue; if (bbox) { bbox.union(maskedPageBounds); } else { bbox = maskedPageBounds.clone(); } } } if (!bbox) return; const singleFrameShapeId = ids.length === 1 && editor.isShapeOfType(editor.getShape(ids[0]), "frame") ? ids[0] : null; if (!singleFrameShapeId) { bbox.expandBy(padding); } const w = bbox.width * scale; const h = bbox.height * scale; try { document.body.focus?.(); } catch { } const exportDelay = new ExportDelay(editor.options.maxExportDelayMs); const initialEffectPromise = promiseWithResolve(); exportDelay.waitUntil(initialEffectPromise); const svg = /* @__PURE__ */ jsx( SvgExport, { editor, preserveAspectRatio, scale, pixelRatio: opts.pixelRatio ?? null, bbox, background, singleFrameShapeId, isDarkMode, renderingShapes, onMount: initialEffectPromise.resolve, waitUntil: exportDelay.waitUntil } ); return { jsx: svg, width: w, height: h, exportDelay }; } function SvgExport({ editor, preserveAspectRatio, scale, pixelRatio, bbox, background, singleFrameShapeId, isDarkMode, renderingShapes, onMount, waitUntil }) { const masksId = useUniqueSafeId(); const theme = getDefaultColorTheme({ isDarkMode }); const stateAtom = useAtom("export state", { defsById: {}, shapeElements: null }); const { defsById, shapeElements } = useValue(stateAtom); const addExportDef = useEvent((def) => { stateAtom.update((state) => { if (hasOwnProperty(state.defsById, def.key)) return state; const promise = Promise.resolve(def.getElement()); waitUntil( promise.then((result) => { stateAtom.update((state2) => ({ ...state2, defsById: { ...state2.defsById, [def.key]: { pending: false, element: result } } })); }) ); return { ...state, defsById: { ...state.defsById, [def.key]: { pending: true, element: promise } } }; }); }); const exportContext = useMemo( () => ({ isDarkMode, waitUntil, addExportDef, scale, pixelRatio, async resolveAssetUrl(assetId, width) { const asset = editor.getAsset(assetId); if (!asset || asset.type !== "image" && asset.type !== "video") return null; return await editor.resolveAssetUrl(assetId, { screenScale: scale * (width / asset.props.w), shouldResolveToOriginal: pixelRatio === null, dpr: pixelRatio ?? void 0 }); } }), [isDarkMode, waitUntil, addExportDef, scale, pixelRatio, editor] ); const didRenderRef = useRef(false); useLayoutEffect(() => { if (didRenderRef.current) { throw new Error("SvgExport should only render once - do not use with react strict mode"); } didRenderRef.current = true; (async () => { const shapeDefs = {}; const unorderedShapeElementPromises = renderingShapes.map( async ({ id, opacity, index, backgroundIndex }) => { if (id === singleFrameShapeId) return []; const shape = editor.getShape(id); if (editor.isShapeOfType(shape, "group")) return []; const elements = []; const util = editor.getShapeUtil(shape); if (util.toSvg || util.toBackgroundSvg) { const [toSvgResult, toBackgroundSvgResult] = await Promise.all([ util.toSvg?.(shape, exportContext), util.toBackgroundSvg?.(shape, exportContext) ]); const pageTransform = editor.getShapePageTransform(shape); let pageTransformString = pageTransform.toCssString(); let scale2 = 1; if ("scale" in shape.props) { if (shape.props.scale !== 1) { scale2 = shape.props.scale; pageTransformString = `${pageTransformString} scale(${shape.props.scale}, ${shape.props.scale})`; } } const pageMask = editor.getShapeMask(shape.id); const shapeMask = pageMask ? Mat.From(Mat.Inverse(pageTransform)).applyToPoints(pageMask) : null; const shapeMaskId = suffixSafeId(masksId, shape.id); if (shapeMask) { shapeDefs[shapeMaskId] = { pending: false, element: /* @__PURE__ */ jsx("clipPath", { id: shapeMaskId, children: /* @__PURE__ */ jsx( "path", { d: `M${shapeMask.map(({ x, y }) => `${x / scale2},${y / scale2}`).join("L")}Z` } ) }) }; } if (toSvgResult) { elements.push({ zIndex: index, element: /* @__PURE__ */ jsx( "g", { transform: pageTransformString, opacity, clipPath: pageMask ? `url(#${shapeMaskId})` : void 0, children: toSvgResult }, `fg_${shape.id}` ) }); } if (toBackgroundSvgResult) { elements.push({ zIndex: backgroundIndex, element: /* @__PURE__ */ jsx( "g", { transform: pageTransformString, opacity, clipPath: pageMask ? `url(#${shapeMaskId})` : void 0, children: toBackgroundSvgResult }, `bg_${shape.id}` ) }); } } else { elements.push({ zIndex: index, element: /* @__PURE__ */ jsx( ForeignObjectShape, { shape, util, component: InnerShape, className: "tl-shape", bbox, opacity }, `fg_${shape.id}` ) }); if (util.backgroundComponent) { elements.push({ zIndex: backgroundIndex, element: /* @__PURE__ */ jsx( ForeignObjectShape, { shape, util, component: InnerShapeBackground, className: "tl-shape tl-shape-background", bbox, opacity }, `bg_${shape.id}` ) }); } } return elements; } ); const unorderedShapeElements = (await Promise.all(unorderedShapeElementPromises)).flat(); flushSync(() => { stateAtom.update((state) => ({ ...state, shapeElements: unorderedShapeElements.sort((a, b) => a.zIndex - b.zIndex).map(({ element }) => element), defsById: { ...state.defsById, ...shapeDefs } })); }); })(); }, [bbox, editor, exportContext, masksId, renderingShapes, singleFrameShapeId, stateAtom]); useEffect(() => { const fontsInUse = /* @__PURE__ */ new Set(); for (const { id } of renderingShapes) { for (const font of editor.fonts.getShapeFontFaces(id)) { fontsInUse.add(font); } } for (const font of fontsInUse) { addExportDef({ key: uniqueId(), getElement: async () => { const declaration = await editor.fonts.toEmbeddedCssDeclaration(font); return /* @__PURE__ */ jsx("style", { nonce: editor.options.nonce, children: declaration }); } }); } }, [editor, renderingShapes, addExportDef]); useEffect(() => { if (shapeElements === null) return; onMount(); }, [onMount, shapeElements]); let backgroundColor = background ? theme.background : "transparent"; if (singleFrameShapeId && background) { const frameShapeUtil = editor.getShapeUtil("frame"); if (frameShapeUtil?.options.showColors) { const shape = editor.getShape(singleFrameShapeId); const color = theme[shape.props.color]; backgroundColor = color.frame.fill; } else { backgroundColor = theme.solid; } } return /* @__PURE__ */ jsx(SvgExportContextProvider, { editor, context: exportContext, children: /* @__PURE__ */ jsxs( "svg", { preserveAspectRatio, direction: "ltr", width: bbox.width * scale, height: bbox.height * scale, viewBox: `${bbox.minX} ${bbox.minY} ${bbox.width} ${bbox.height}`, strokeLinecap: "round", strokeLinejoin: "round", style: { backgroundColor }, "data-color-mode": isDarkMode ? "dark" : "light", className: `tl-container tl-theme__force-sRGB ${isDarkMode ? "tl-theme__dark" : "tl-theme__light"}`, children: [ /* @__PURE__ */ jsx("defs", { children: Object.entries(defsById).map( ([key, def]) => def.pending ? null : /* @__PURE__ */ jsx(Fragment, { children: def.element }, key) ) }), shapeElements ] } ) }); } function ForeignObjectShape({ shape, util, className, component: Component, bbox, opacity }) { const editor = useEditor(); const transform = Mat.Translate(-bbox.minX, -bbox.minY).multiply( editor.getShapePageTransform(shape.id) ); const bounds = editor.getShapeGeometry(shape.id).bounds; const width = Math.max(bounds.width, 1); const height = Math.max(bounds.height, 1); return /* @__PURE__ */ jsx(ErrorBoundary, { fallback: () => null, children: /* @__PURE__ */ jsx( "foreignObject", { x: bbox.minX, y: bbox.minY, width: bbox.w, height: bbox.h, className: "tl-shape-foreign-object tl-export-embed-styles", children: /* @__PURE__ */ jsx( "div", { className, "data-shape-type": shape.type, style: { clipPath: editor.getShapeClipPath(shape.id), transform: transform.toCssString(), width, height, opacity }, children: /* @__PURE__ */ jsx(Component, { shape, util }) } ) } ) }); } export { getSvgJsx }; //# sourceMappingURL=getSvgJsx.mjs.map