From a18525ea7894aa49ca4408eec8c1bb37d2cda6f7 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 11 Apr 2024 15:02:05 +0100 Subject: [PATCH] Fix SVG exports in Next.js (#3446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js bans the use of react-dom/server APIs on the client. React's docs recommend against using these too: https://react.dev/reference/react-dom/server/renderToString#removing-rendertostring-from-the-client-code In this diff, we switch from using `ReactDOMServer.renderToStaticMarkup` to `ReactDOMClient.createRoot`, fixing SVG exports in next.js apps. `getSvg` remains deprecated, but we've introduced a new `getSvgElement` method with a similar API to `getSvgString` - it returns an `{svg, width, height}` object. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `bugfix` — Bug fix --- packages/editor/api-report.md | 5 + packages/editor/api/api.json | 108 +++++++++++++++++- packages/editor/src/lib/editor/Editor.ts | 53 ++++++--- packages/editor/src/lib/editor/getSvgJsx.tsx | 1 - .../__snapshots__/getSvgString.test.ts.snap | 2 +- 5 files changed, 153 insertions(+), 16 deletions(-) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index f78dd11c7..e2ca21d9d 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -767,6 +767,11 @@ export class Editor extends EventEmitter { getStyleForNextShape(style: StyleProp): T; // @deprecated (undocumented) getSvg(shapes: TLShape[] | TLShapeId[], opts?: Partial): Promise; + getSvgElement(shapes: TLShape[] | TLShapeId[], opts?: Partial): Promise<{ + svg: SVGSVGElement; + width: number; + height: number; + } | undefined>; getSvgString(shapes: TLShape[] | TLShapeId[], opts?: Partial): Promise<{ svg: string; width: number; diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index e0d8317a7..d6c01d768 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -13888,7 +13888,7 @@ { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getSvg:member(1)", - "docComment": "/**\n * @deprecated\n *\n * Use {@link Editor.getSvgString} instead\n */\n", + "docComment": "/**\n * @deprecated\n *\n * Use {@link Editor.getSvgString} or {@link Editor.getSvgElement} instead.\n */\n", "excerptTokens": [ { "kind": "Content", @@ -13991,6 +13991,112 @@ "isAbstract": false, "name": "getSvg" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#getSvgElement:member(1)", + "docComment": "/**\n * Get an exported SVG element of the given shapes.\n *\n * @param ids - The shapes (or shape ids) to export.\n *\n * @param opts - Options for the export.\n *\n * @returns The SVG element.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getSvgElement(shapes: " + }, + { + "kind": "Reference", + "text": "TLShape", + "canonicalReference": "@tldraw/tlschema!TLShape:type" + }, + { + "kind": "Content", + "text": "[] | " + }, + { + "kind": "Reference", + "text": "TLShapeId", + "canonicalReference": "@tldraw/tlschema!TLShapeId:type" + }, + { + "kind": "Content", + "text": "[]" + }, + { + "kind": "Content", + "text": ", opts?: " + }, + { + "kind": "Reference", + "text": "Partial", + "canonicalReference": "!Partial:type" + }, + { + "kind": "Content", + "text": "<" + }, + { + "kind": "Reference", + "text": "TLSvgOptions", + "canonicalReference": "@tldraw/editor!TLSvgOptions:type" + }, + { + "kind": "Content", + "text": ">" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "<{\n svg: " + }, + { + "kind": "Reference", + "text": "SVGSVGElement", + "canonicalReference": "!SVGSVGElement:interface" + }, + { + "kind": "Content", + "text": ";\n width: number;\n height: number;\n } | undefined>" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 11, + "endIndex": 15 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "shapes", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 5 + }, + "isOptional": false + }, + { + "parameterName": "opts", + "parameterTypeTokenRange": { + "startIndex": 6, + "endIndex": 10 + }, + "isOptional": true + } + ], + "isOptional": false, + "isAbstract": false, + "name": "getSvgElement" + }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getSvgString:member(1)", diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 33a1de566..79ed3ac0f 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -61,7 +61,6 @@ import { import { EventEmitter } from 'eventemitter3' import { flushSync } from 'react-dom' import { createRoot } from 'react-dom/client' -import { renderToStaticMarkup } from 'react-dom/server' import { TLUser, createTLUser } from '../config/createTLUser' import { checkShapesAndAddCore } from '../config/defaultShapes' import { @@ -8070,6 +8069,33 @@ export class Editor extends EventEmitter { return this } + /** + * Get an exported SVG element of the given shapes. + * + * @param ids - The shapes (or shape ids) to export. + * @param opts - Options for the export. + * + * @returns The SVG element. + * + * @public + */ + async getSvgElement(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { + const result = await getSvgJsx(this, shapes, opts) + if (!result) return undefined + + const fragment = document.createDocumentFragment() + const root = createRoot(fragment) + flushSync(() => { + root.render(result.jsx) + }) + + const svg = fragment.firstElementChild + assert(svg instanceof SVGSVGElement, 'Expected an SVG element') + + root.unmount() + return { svg, width: result.width, height: result.height } + } + /** * Get an exported SVG string of the given shapes. * @@ -8081,21 +8107,22 @@ export class Editor extends EventEmitter { * @public */ async getSvgString(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { - const svg = await getSvgJsx(this, shapes, opts) - if (!svg) return undefined - return { svg: renderToStaticMarkup(svg.jsx), width: svg.width, height: svg.height } + const result = await this.getSvgElement(shapes, opts) + if (!result) return undefined + + const serializer = new XMLSerializer() + return { + svg: serializer.serializeToString(result.svg), + width: result.width, + height: result.height, + } } - /** @deprecated Use {@link Editor.getSvgString} instead */ + /** @deprecated Use {@link Editor.getSvgString} or {@link Editor.getSvgElement} instead. */ async getSvg(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { - const svg = await getSvgJsx(this, shapes, opts) - if (!svg) return undefined - const fragment = new DocumentFragment() - const root = createRoot(fragment) - flushSync(() => root.render(svg.jsx)) - const rendered = fragment.firstElementChild - root.unmount() - return rendered as SVGSVGElement + const result = await this.getSvgElement(shapes, opts) + if (!result) return undefined + return result.svg } /* --------------------- Events --------------------- */ diff --git a/packages/editor/src/lib/editor/getSvgJsx.tsx b/packages/editor/src/lib/editor/getSvgJsx.tsx index 1f16b0ce1..f86bef475 100644 --- a/packages/editor/src/lib/editor/getSvgJsx.tsx +++ b/packages/editor/src/lib/editor/getSvgJsx.tsx @@ -184,7 +184,6 @@ export async function getSvgJsx( const svg = (