@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
356 lines (355 loc) • 12 kB
JavaScript
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