import { TLShapeId } from '@tldraw/tlschema' import { assert } from '@tldraw/utils' import { flushSync } from 'react-dom' import { createRoot } from 'react-dom/client' import type { Editor } from '../editor/Editor' import { TLSvgExportOptions } from '../editor/types/misc-types' import { SVG_EXPORT_CLASSNAME } from './FontEmbedder' import { StyleEmbedder } from './StyleEmbedder' import { embedMedia } from './embedMedia' import { getSvgJsx } from './getSvgJsx' let idCounter = 1 export async function exportToSvg( editor: Editor, shapeIds: TLShapeId[], opts: TLSvgExportOptions = {} ) { // when rendering to SVG, we start by creating a JSX representation of the SVG that we can // render with react. Hopefully elements will have a `toSvg` method that renders them to SVG, // but if they don't we'll render their normal HTML content into an svg element. const result = getSvgJsx(editor, shapeIds, opts) if (!result) return undefined // we need to render that SVG into a real DOM element that's actually laid out in the document. // without this CSS and layout aren't computed correctly, which we need to make sure any // elements have their styles and content inlined correctly. const container = editor.getContainer() const renderTarget = document.createElement('div') renderTarget.className = SVG_EXPORT_CLASSNAME // we hide the element visually, but we don't want it to be focusable or interactive in any way either renderTarget.inert = true renderTarget.tabIndex = -1 Object.assign(renderTarget.style, { position: 'absolute', top: '0px', left: '0px', width: result.width + 'px', height: result.height + 'px', pointerEvents: 'none', opacity: 0, }) // we have to add the element to the document as otherwise styles won't be computed correctly. container.appendChild(renderTarget) // create a react root... const root = createRoot(renderTarget, { identifierPrefix: `export_${idCounter++}_` }) try { // ...wait for a tick so we know we're not in e.g. a react lifecycle method... await Promise.resolve() // ...and render the SVG into it. flushSync(() => { root.render(result.jsx) }) // Some operations take a while - for example, waiting for an asset to load in. We give // shape authors a way to delay snap-shotting the export until they're ready. await result.exportDelay.resolve() // Extract the rendered SVG element from the react root const svg = renderTarget.firstElementChild assert(svg instanceof SVGSVGElement, 'Expected an SVG element') // And apply any changes to elements that we need to make. while we're in // the document, these elements work exactly as we'd expect from other dom elements - they // can load external resources, and any stylesheets in the document apply to them as we // would expect them to. But when we pull the SVG into its own file or draw it to a canvas // though, it has to be completely self-contained. We embed any external resources, and // apply any styles directly to the elements themselves. await applyChangesToForeignObjects(svg) return { svg, width: result.width, height: result.height } } finally { // eslint-disable-next-line no-restricted-globals setTimeout(() => { // we wait for a cycle of the event loop to allow the svg to be cloned etc. before // unmounting root.unmount() container.removeChild(renderTarget) }, 0) } } async function applyChangesToForeignObjects(svg: SVGSVGElement) { // If any shapes have their own elements, we don't want to mess with them. Our // ones that we need to embed will have a class of `tl-export-embed-styles`. const foreignObjectChildren = [ ...svg.querySelectorAll('foreignObject.tl-export-embed-styles > *'), ] if (!foreignObjectChildren.length) return // StyleEmbedder embeds any CSS - including resources like fonts and images. const styleEmbedder = new StyleEmbedder(svg) try { // begin traversing stylesheets to find @font-face declarations we might need to embed styleEmbedder.fonts.startFindingCurrentDocumentFontFaces() // embed any media elements in the foreignObject children. images will get converted to data // urls, and things like videos will be converted to images. await Promise.all(foreignObjectChildren.map((el) => embedMedia(el as HTMLElement))) // read the computed styles of every element (+ it's children & pseudo-elements) in the // document. we do this in a single pass before we start embedding any CSS stuff to avoid // constantly forcing the browser to recompute styles & layout. for (const el of foreignObjectChildren) { styleEmbedder.readRootElementStyles(el as HTMLElement) } // fetch any resources that we need to embed in the CSS, like background images. await styleEmbedder.fetchResources() const fontCss = await styleEmbedder.getFontFaceCss() // custom elements that make use of the shadow dom won't be serialized correctly by default: // the contents of the shadow dom will be ignored. once we've read the styles from the // document, we go through and replace any custom elements with plain `
`s. as we do so, // we traverse the shadow dom and clone it into the new plain div. any scoped stylesheets // are removed, as we've already read all the computed styles above. styleEmbedder.unwrapCustomElements() // apply the computed styles (with their embedded resources) directly to the elements with // their `style` attribute. Anything that can't be done this way (pseudo-elements) will be // returned as a string of CSS. const pseudoCss = styleEmbedder.embedStyles() // add the CSS to the SVG if (fontCss || pseudoCss) { const style = document.createElementNS('http://www.w3.org/2000/svg', 'style') style.textContent = `${fontCss}\n${pseudoCss}` svg.prepend(style) } } finally { styleEmbedder.dispose() } }