diff --git a/apps/dotcom/setupTests.js b/apps/dotcom/setupTests.js index 88d3a65a1..b3eb934fc 100644 --- a/apps/dotcom/setupTests.js +++ b/apps/dotcom/setupTests.js @@ -2,3 +2,6 @@ global.crypto ??= new (require('@peculiar/webcrypto').Crypto)() process.env.MULTIPLAYER_SERVER = 'https://localhost:8787' process.env.ASSET_UPLOAD = 'https://localhost:8788' + +global.TextEncoder = require('util').TextEncoder +global.TextDecoder = require('util').TextDecoder diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-none-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-none-in-dark-mode-1-Mobile-Chrome-linux.png index 05ffa6129..d575b197c 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-none-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-none-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-none-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-none-in-dark-mode-1-chromium-linux.png index 05ffa6129..d575b197c 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-none-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-none-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-pattern-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-pattern-in-dark-mode-1-Mobile-Chrome-linux.png index 4d2d387f4..af3cb0850 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-pattern-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-pattern-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-pattern-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-pattern-in-dark-mode-1-chromium-linux.png index 4d2d387f4..af3cb0850 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-pattern-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-pattern-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-semi-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-semi-in-dark-mode-1-Mobile-Chrome-linux.png index ccd75a3c1..caad24714 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-semi-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-semi-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-semi-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-semi-in-dark-mode-1-chromium-linux.png index ccd75a3c1..caad24714 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-semi-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-semi-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-solid-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-solid-in-dark-mode-1-Mobile-Chrome-linux.png index 70bbfdf93..ed4edfdc6 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-solid-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-solid-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-solid-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-solid-in-dark-mode-1-chromium-linux.png index 70bbfdf93..ed4edfdc6 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-solid-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-fill-solid-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-draw-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-draw-in-dark-mode-1-Mobile-Chrome-linux.png index cdeeebb62..0e42a3e07 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-draw-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-draw-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-draw-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-draw-in-dark-mode-1-chromium-linux.png index cdeeebb62..0e42a3e07 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-draw-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-draw-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-mono-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-mono-in-dark-mode-1-Mobile-Chrome-linux.png index 7cac88624..d08230507 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-mono-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-mono-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-mono-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-mono-in-dark-mode-1-chromium-linux.png index 7cac88624..d08230507 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-mono-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-mono-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-sans-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-sans-in-dark-mode-1-Mobile-Chrome-linux.png index 70136c34e..3e32c148b 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-sans-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-sans-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-sans-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-sans-in-dark-mode-1-chromium-linux.png index 70136c34e..3e32c148b 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-sans-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-sans-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-serif-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-serif-in-dark-mode-1-Mobile-Chrome-linux.png index fa6a669e3..146d612ef 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-serif-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-serif-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-serif-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-serif-in-dark-mode-1-chromium-linux.png index fa6a669e3..146d612ef 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-serif-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-arrow-font-serif-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-draw-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-draw-in-dark-mode-1-Mobile-Chrome-linux.png index 5ae95a9d7..fbe1b9850 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-draw-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-draw-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-draw-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-draw-in-dark-mode-1-chromium-linux.png index 5ae95a9d7..fbe1b9850 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-draw-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-draw-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-mono-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-mono-in-dark-mode-1-Mobile-Chrome-linux.png index 73adcd5ad..90151978f 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-mono-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-mono-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-mono-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-mono-in-dark-mode-1-chromium-linux.png index 73adcd5ad..90151978f 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-mono-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-mono-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-sans-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-sans-in-dark-mode-1-Mobile-Chrome-linux.png index 16167c55e..2a3d87cfe 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-sans-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-sans-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-sans-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-sans-in-dark-mode-1-chromium-linux.png index 16167c55e..2a3d87cfe 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-sans-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-sans-in-dark-mode-1-chromium-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-serif-in-dark-mode-1-Mobile-Chrome-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-serif-in-dark-mode-1-Mobile-Chrome-linux.png index 7783f703d..37cdf598c 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-serif-in-dark-mode-1-Mobile-Chrome-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-serif-in-dark-mode-1-Mobile-Chrome-linux.png differ diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-serif-in-dark-mode-1-chromium-linux.png b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-serif-in-dark-mode-1-chromium-linux.png index 7783f703d..37cdf598c 100644 Binary files a/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-serif-in-dark-mode-1-chromium-linux.png and b/apps/examples/e2e/tests/export-snapshots.spec.ts-snapshots/Export-snapshots-Exports-with-note-font-serif-in-dark-mode-1-chromium-linux.png differ diff --git a/config/setupJest.ts b/config/setupJest.ts index 81ee9c0fe..bc6cecb22 100644 --- a/config/setupJest.ts +++ b/config/setupJest.ts @@ -1,5 +1,4 @@ import { equals, getObjectSubset, iterableEquality, subsetEquality } from '@jest/expect-utils' - import { matcherHint, printDiffOrStringify, @@ -7,6 +6,10 @@ import { printReceived, stringify, } from 'jest-matcher-utils' +import { TextDecoder, TextEncoder } from 'util' + +global.TextEncoder = TextEncoder +global.TextDecoder = TextDecoder function convertNumbersInObject(obj: any, roundToNearest: number) { if (!obj) return obj diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 4317f6d42..61108ed60 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -26,6 +26,7 @@ import { PointerEventHandler } from 'react'; import { react } from '@tldraw/state'; import { default as React_2 } from 'react'; import * as React_3 from 'react'; +import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { SerializedSchema } from '@tldraw/store'; import { SerializedStore } from '@tldraw/store'; @@ -766,7 +767,24 @@ export class Editor extends EventEmitter { getSortedChildIdsForParent(parent: TLPage | TLParentId | TLShape): TLShapeId[]; getStateDescendant(path: string): T | undefined; getStyleForNextShape(style: StyleProp): T; + // @deprecated (undocumented) getSvg(shapes: TLShape[] | TLShapeId[], opts?: Partial): Promise; + getSvgString(shapes: TLShape[] | TLShapeId[], opts?: Partial): Promise<{ + svg: string; + width: number; + height: number; + } | undefined>; + // @internal (undocumented) + getUnorderedRenderingShapes(useEditorState: boolean): { + id: TLShapeId; + shape: TLShape; + util: ShapeUtil; + index: number; + backgroundIndex: number; + opacity: number; + isCulled: boolean; + maskedPageBounds: Box | undefined; + }[]; getViewportPageBounds(): Box; getViewportPageCenter(): Vec; getViewportScreenBounds(): Box; @@ -1654,8 +1672,8 @@ export abstract class ShapeUtil { static props?: ShapeProps; // @internal providesBackgroundForChildren(shape: Shape): boolean; - toBackgroundSvg?(shape: Shape, ctx: SvgExportContext): null | Promise | SVGElement; - toSvg?(shape: Shape, ctx: SvgExportContext): Promise | SVGElement; + toBackgroundSvg?(shape: Shape, ctx: SvgExportContext): null | Promise | ReactElement; + toSvg?(shape: Shape, ctx: SvgExportContext): null | Promise | ReactElement; static type: string; } @@ -1826,7 +1844,7 @@ export interface SvgExportContext { // @public (undocumented) export interface SvgExportDef { // (undocumented) - getElement: () => null | Promise | SVGElement | SVGElement[]; + getElement: () => null | Promise | ReactElement; // (undocumented) key: string; } @@ -2727,6 +2745,11 @@ export function useShallowArrayIdentity(arr: readonly T[]): readonly T[]; // @internal (undocumented) export function useShallowObjectIdentity>(arr: T): T; +// @public +export function useSvgExportContext(): { + isDarkMode: boolean; +} | null; + // @public (undocumented) export function useTLStore(opts: TLStoreOptions & { snapshot?: StoreSnapshot; diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index bda751816..67f116846 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -13884,7 +13884,7 @@ { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getSvg:member(1)", - "docComment": "/**\n * Get an exported SVG 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", + "docComment": "/**\n * @deprecated\n *\n * Use {@link Editor.getSvgString} instead\n */\n", "excerptTokens": [ { "kind": "Content", @@ -13987,6 +13987,103 @@ "isAbstract": false, "name": "getSvg" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#getSvgString:member(1)", + "docComment": "/**\n * Get an exported SVG string 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": "getSvgString(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: string;\n width: number;\n height: number;\n } | undefined>" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 11, + "endIndex": 13 + }, + "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": "getSvgString" + }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getViewportPageBounds:member(1)", @@ -24629,7 +24726,7 @@ { "kind": "Function", "canonicalReference": "@tldraw/editor!isSafeFloat:function(1)", - "docComment": "/**\n * Check if a float is safe to use. ie: Not too big or small.\n *\n * @public\n */\n", + "docComment": "/**\n * ] Check if a float is safe to use. ie: Not too big or small.\n *\n * @public\n */\n", "excerptTokens": [ { "kind": "Content", @@ -32096,12 +32193,12 @@ }, { "kind": "Content", - "text": "<" + "text": " | " }, { "kind": "Reference", - "text": "SVGElement", - "canonicalReference": "!SVGElement:interface" - }, - { - "kind": "Content", - "text": "[]> | " - }, - { - "kind": "Reference", - "text": "SVGElement", - "canonicalReference": "!SVGElement:interface" - }, - { - "kind": "Content", - "text": " | " - }, - { - "kind": "Reference", - "text": "SVGElement", - "canonicalReference": "!SVGElement:interface" - }, - { - "kind": "Content", - "text": "[]" + "text": "ReactElement", + "canonicalReference": "@types/react!React.ReactElement:interface" }, { "kind": "Content", @@ -34782,7 +34861,7 @@ "name": "getElement", "propertyTypeTokenRange": { "startIndex": 1, - "endIndex": 12 + "endIndex": 7 } }, { @@ -43050,6 +43129,34 @@ ], "name": "useSelectionEvents" }, + { + "kind": "Function", + "canonicalReference": "@tldraw/editor!useSvgExportContext:function(1)", + "docComment": "/**\n * Returns the read-only parts of {@link SvgExportContext}.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export declare function useSvgExportContext(): " + }, + { + "kind": "Content", + "text": "{\n isDarkMode: boolean;\n} | null" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "packages/editor/src/lib/editor/types/SvgExportContext.tsx", + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [], + "name": "useSvgExportContext" + }, { "kind": "Function", "canonicalReference": "@tldraw/editor!useTLStore:function(1)", diff --git a/packages/editor/setupTests.js b/packages/editor/setupTests.js index 50569abdc..9e66dcb03 100644 --- a/packages/editor/setupTests.js +++ b/packages/editor/setupTests.js @@ -13,3 +13,6 @@ document.fonts = { forEach: () => {}, [Symbol.iterator]: () => [][Symbol.iterator](), } + +global.TextEncoder = require('util').TextEncoder +global.TextDecoder = require('util').TextDecoder diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 8f7f42c9f..a8acb06ce 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -192,7 +192,11 @@ export { getArrowTerminalsInArrowSpace } from './lib/editor/shapes/shared/arrow/ export { resizeBox, type ResizeBoxOptions } from './lib/editor/shapes/shared/resizeBox' export { BaseBoxShapeTool } from './lib/editor/tools/BaseBoxShapeTool/BaseBoxShapeTool' export { StateNode, type TLStateNodeConstructor } from './lib/editor/tools/StateNode' -export { type SvgExportContext, type SvgExportDef } from './lib/editor/types/SvgExportContext' +export { + useSvgExportContext, + type SvgExportContext, + type SvgExportDef, +} from './lib/editor/types/SvgExportContext' export { type TLContent } from './lib/editor/types/clipboard-types' export { type TLEventMap, type TLEventMapHandler } from './lib/editor/types/emit-types' export { diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index 0fe9d9c79..807fccef1 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -481,7 +481,7 @@ function CollaboratorHintDef() { function DebugSvgCopy({ id }: { id: TLShapeId }) { const editor = useEditor() - const [html, setHtml] = useState('') + const [src, setSrc] = useState(null) const isInRoot = useValue( 'is in root', @@ -499,18 +499,15 @@ function DebugSvgCopy({ id }: { id: TLShapeId }) { const unsubscribe = react('shape to svg', async () => { const renderId = Math.random() latest = renderId - const bb = editor.getShapePageBounds(id) - const el = await editor.getSvg([id], { + const result = await editor.getSvgString([id], { padding: 0, background: editor.getInstanceState().exportBackground, }) - if (el && bb && latest === renderId) { - el.style.setProperty('overflow', 'visible') - el.setAttribute('preserveAspectRatio', 'xMidYMin slice') - el.style.setProperty('transform', `translate(${bb.x}px, ${bb.y + bb.h + 12}px)`) - el.style.setProperty('border', '1px solid black') - setHtml(el?.outerHTML) - } + + if (latest !== renderId || !result) return + + const svgDataUrl = `data:image/svg+xml;utf8,${encodeURIComponent(result.svg)}` + setSrc(svgDataUrl) }) return () => { @@ -518,13 +515,24 @@ function DebugSvgCopy({ id }: { id: TLShapeId }) { unsubscribe() } }, [editor, id, isInRoot]) + const bb = editor.getShapePageBounds(id) - if (!isInRoot) return null + if (!isInRoot || !src || !bb) return null return ( -
-
-
+ ) } diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 6b2be38b6..9d0a9edfb 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -34,7 +34,6 @@ import { TLUnknownShape, TLVideoAsset, createShapeId, - getDefaultColorTheme, getShapePropKeysByStyle, isPageId, isShape, @@ -60,6 +59,9 @@ import { structuredClone, } from '@tldraw/utils' 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 { @@ -82,7 +84,6 @@ import { MAX_SHAPES_PER_PAGE, MAX_ZOOM, MIN_ZOOM, - SVG_PADDING, ZOOMS, } from '../constants' import { Box } from '../primitives/Box' @@ -103,6 +104,7 @@ import { uniqueId } from '../utils/uniqueId' import { arrowBindingsIndex } from './derivations/arrowBindingsIndex' import { parentsToChildren } from './derivations/parentsToChildren' import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage' +import { getSvgJsx } from './getSvgJsx' import { ClickManager } from './managers/ClickManager' import { EnvironmentManager } from './managers/EnvironmentManager' import { HistoryManager } from './managers/HistoryManager' @@ -119,7 +121,6 @@ import { getArrowTerminalsInArrowSpace, getIsArrowStraight } from './shapes/shar import { getStraightArrowInfo } from './shapes/shared/arrow/straight-arrow' import { RootState } from './tools/RootState' import { StateNode, TLStateNodeConstructor } from './tools/StateNode' -import { SvgExportContext, SvgExportDef } from './types/SvgExportContext' import { TLContent } from './types/clipboard-types' import { TLEventMap } from './types/emit-types' import { @@ -3091,7 +3092,8 @@ export class Editor extends EventEmitter { } } - private getUnorderedRenderingShapes( + /** @internal */ + getUnorderedRenderingShapes( // The rendering state. We use this method both for rendering, which // is based on other state, and for computing order for SVG export, // which should work even when things are for example off-screen. @@ -8080,7 +8082,7 @@ export class Editor extends EventEmitter { } /** - * Get an exported SVG of the given shapes. + * Get an exported SVG string of the given shapes. * * @param ids - The shapes (or shape ids) to export. * @param opts - Options for the export. @@ -8089,220 +8091,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 } + } + + /** @deprecated Use {@link Editor.getSvgString} instead */ async getSvg(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { - const ids = - typeof shapes[0] === 'string' - ? (shapes as TLShapeId[]) - : (shapes as TLShape[]).map((s) => s.id) - - if (ids.length === 0) return - if (!window.document) throw Error('No document') - - const { - scale = 1, - background = false, - padding = SVG_PADDING, - preserveAspectRatio = false, - } = opts - - const isDarkMode = opts.darkMode ?? this.user.getIsDarkMode() - const theme = getDefaultColorTheme({ isDarkMode }) - - // ---Figure out which shapes we need to include - const shapeIdsToInclude = this.getShapeAndDescendantIds(ids) - const renderingShapes = this.getUnorderedRenderingShapes(false).filter(({ id }) => - shapeIdsToInclude.has(id) - ) - - // --- Common bounding box of all shapes - let bbox = null - if (opts.bounds) { - bbox = opts.bounds - } else { - for (const { maskedPageBounds } of renderingShapes) { - if (!maskedPageBounds) continue - if (bbox) { - bbox.union(maskedPageBounds) - } else { - bbox = maskedPageBounds.clone() - } - } - } - - // no unmasked shapes to export - if (!bbox) return - - const singleFrameShapeId = - ids.length === 1 && this.isShapeOfType(this.getShape(ids[0])!, 'frame') - ? ids[0] - : null - if (!singleFrameShapeId) { - // Expand by an extra 32 pixels - bbox.expandBy(padding) - } - - // We want the svg image to be BIGGER THAN USUAL to account for image quality - const w = bbox.width * scale - const h = bbox.height * scale - - // --- Create the SVG - - // Embed our custom fonts - const svg = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg') - - if (preserveAspectRatio) { - svg.setAttribute('preserveAspectRatio', preserveAspectRatio) - } - - svg.setAttribute('direction', 'ltr') - svg.setAttribute('width', w + '') - svg.setAttribute('height', h + '') - svg.setAttribute('viewBox', `${bbox.minX} ${bbox.minY} ${bbox.width} ${bbox.height}`) - svg.setAttribute('stroke-linecap', 'round') - svg.setAttribute('stroke-linejoin', 'round') - // Add current background color, or else background will be transparent - - if (background) { - if (singleFrameShapeId) { - svg.style.setProperty('background', theme.solid) - } else { - svg.style.setProperty('background-color', theme.background) - } - } else { - svg.style.setProperty('background-color', 'transparent') - } - - try { - document.body.focus?.() // weird but necessary - } catch (e) { - // not implemented - } - - // Add the defs to the svg - const defs = window.document.createElementNS('http://www.w3.org/2000/svg', 'defs') - svg.append(defs) - - const exportDefPromisesById = new Map>() - const exportContext: SvgExportContext = { - isDarkMode, - addExportDef: (def: SvgExportDef) => { - if (exportDefPromisesById.has(def.key)) return - const promise = (async () => { - const elements = await def.getElement() - if (!elements) return - - const comment = document.createComment(`def: ${def.key}`) - defs.appendChild(comment) - - for (const element of Array.isArray(elements) ? elements : [elements]) { - defs.appendChild(element) - } - })() - exportDefPromisesById.set(def.key, promise) - }, - } - - const unorderedShapeElements = ( - await Promise.all( - renderingShapes.map(async ({ id, opacity, index, backgroundIndex }) => { - // Don't render the frame if we're only exporting a single frame - if (id === singleFrameShapeId) return [] - - const shape = this.getShape(id)! - - if (this.isShapeOfType(shape, 'group')) return [] - - const util = this.getShapeUtil(shape) - - let shapeSvgElement = await util.toSvg?.(shape, exportContext) - let backgroundSvgElement = await util.toBackgroundSvg?.(shape, exportContext) - - // wrap the shapes in groups so we can apply properties without overwriting ones from the shape util - if (shapeSvgElement) { - const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') - outerElement.appendChild(shapeSvgElement) - shapeSvgElement = outerElement - } - - if (backgroundSvgElement) { - const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') - outerElement.appendChild(backgroundSvgElement) - backgroundSvgElement = outerElement - } - - if (!shapeSvgElement && !backgroundSvgElement) { - const bounds = this.getShapePageBounds(shape)! - const elm = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect') - elm.setAttribute('width', bounds.width + '') - elm.setAttribute('height', bounds.height + '') - elm.setAttribute('fill', theme.solid) - elm.setAttribute('stroke', theme.grey.pattern) - elm.setAttribute('stroke-width', '1') - shapeSvgElement = elm - } - - let pageTransform = this.getShapePageTransform(shape)!.toCssString() - if ('scale' in shape.props) { - if (shape.props.scale !== 1) { - pageTransform = `${pageTransform} scale(${shape.props.scale}, ${shape.props.scale})` - } - } - - shapeSvgElement?.setAttribute('transform', pageTransform) - backgroundSvgElement?.setAttribute('transform', pageTransform) - shapeSvgElement?.setAttribute('opacity', opacity + '') - backgroundSvgElement?.setAttribute('opacity', opacity + '') - - // Create svg mask if shape has a frame as parent - const pageMask = this.getShapeMask(shape.id) - if (pageMask) { - // Create a clip path and add it to defs - const clipPathEl = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath') - defs.appendChild(clipPathEl) - const id = uniqueId() - clipPathEl.id = id - - // Create a polyline mask that does the clipping - const mask = document.createElementNS('http://www.w3.org/2000/svg', 'path') - mask.setAttribute('d', `M${pageMask.map(({ x, y }) => `${x},${y}`).join('L')}Z`) - clipPathEl.appendChild(mask) - - // Create group that uses the clip path and wraps the shape elements - if (shapeSvgElement) { - const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') - outerElement.setAttribute('clip-path', `url(#${id})`) - outerElement.appendChild(shapeSvgElement) - shapeSvgElement = outerElement - } - - if (backgroundSvgElement) { - const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') - outerElement.setAttribute('clip-path', `url(#${id})`) - outerElement.appendChild(backgroundSvgElement) - backgroundSvgElement = outerElement - } - } - - const elements = [] - if (shapeSvgElement) { - elements.push({ zIndex: index, element: shapeSvgElement }) - } - if (backgroundSvgElement) { - elements.push({ zIndex: backgroundIndex, element: backgroundSvgElement }) - } - - return elements - }) - ) - ).flat() - - await Promise.all(exportDefPromisesById.values()) - - for (const { element } of unorderedShapeElements.sort((a, b) => a.zIndex - b.zIndex)) { - svg.appendChild(element) - } - - return svg + 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 } /* --------------------- Events --------------------- */ diff --git a/packages/editor/src/lib/editor/getSvgJsx.tsx b/packages/editor/src/lib/editor/getSvgJsx.tsx new file mode 100644 index 000000000..34965135c --- /dev/null +++ b/packages/editor/src/lib/editor/getSvgJsx.tsx @@ -0,0 +1,209 @@ +import { + TLFrameShape, + TLGroupShape, + TLShape, + TLShapeId, + getDefaultColorTheme, +} from '@tldraw/tlschema' +import { Fragment, ReactElement } from 'react' +import { SVG_PADDING } from '../constants' +import { Editor } from './Editor' +import { SvgExportContext, SvgExportContextProvider, SvgExportDef } from './types/SvgExportContext' +import { TLSvgOptions } from './types/misc-types' + +export async function getSvgJsx( + editor: Editor, + shapes: TLShapeId[] | TLShape[], + opts = {} as Partial +) { + const ids = + typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) + + if (ids.length === 0) return + if (!window.document) throw Error('No document') + + const { scale = 1, background = false, padding = SVG_PADDING, preserveAspectRatio = false } = opts + + const isDarkMode = opts.darkMode ?? editor.user.getIsDarkMode() + const theme = getDefaultColorTheme({ isDarkMode }) + + // ---Figure out which shapes we need to include + const shapeIdsToInclude = editor.getShapeAndDescendantIds(ids) + const renderingShapes = editor + .getUnorderedRenderingShapes(false) + .filter(({ id }) => shapeIdsToInclude.has(id)) + + // --- Common bounding box of all shapes + let bbox = null + if (opts.bounds) { + bbox = opts.bounds + } else { + for (const { maskedPageBounds } of renderingShapes) { + if (!maskedPageBounds) continue + if (bbox) { + bbox.union(maskedPageBounds) + } else { + bbox = maskedPageBounds.clone() + } + } + } + + // no unmasked shapes to export + if (!bbox) return + + const singleFrameShapeId = + ids.length === 1 && editor.isShapeOfType(editor.getShape(ids[0])!, 'frame') + ? ids[0] + : null + if (!singleFrameShapeId) { + // Expand by an extra 32 pixels + bbox.expandBy(padding) + } + + // We want the svg image to be BIGGER THAN USUAL to account for image quality + const w = bbox.width * scale + const h = bbox.height * scale + + try { + document.body.focus?.() // weird but necessary + } catch (e) { + // not implemented + } + + const defChildren: ReactElement[] = [] + + const exportDefPromisesById = new Map>() + const exportContext: SvgExportContext = { + isDarkMode, + addExportDef: (def: SvgExportDef) => { + if (exportDefPromisesById.has(def.key)) return + const promise = (async () => { + const element = await def.getElement() + if (!element) return + + defChildren.push({element}) + })() + exportDefPromisesById.set(def.key, promise) + }, + } + + const unorderedShapeElements = ( + await Promise.all( + renderingShapes.map(async ({ id, opacity, index, backgroundIndex }) => { + // Don't render the frame if we're only exporting a single frame + if (id === singleFrameShapeId) return [] + + const shape = editor.getShape(id)! + + if (editor.isShapeOfType(shape, 'group')) return [] + + const util = editor.getShapeUtil(shape) + + let toSvgResult = await util.toSvg?.(shape, exportContext) + let toBackgroundSvgResult = await util.toBackgroundSvg?.(shape, exportContext) + + if (!toSvgResult && !toBackgroundSvgResult) { + const bounds = editor.getShapePageBounds(shape)! + toSvgResult = ( + + ) + } + + let pageTransform = editor.getShapePageTransform(shape)!.toCssString() + if ('scale' in shape.props) { + if (shape.props.scale !== 1) { + pageTransform = `${pageTransform} scale(${shape.props.scale}, ${shape.props.scale})` + } + } + + if (toSvgResult) { + toSvgResult = ( + + {toSvgResult} + + ) + } + if (toBackgroundSvgResult) { + toBackgroundSvgResult = ( + + {toBackgroundSvgResult} + + ) + } + + // Create svg mask if shape has a frame as parent + const pageMask = editor.getShapeMask(shape.id) + if (pageMask) { + // Create a clip path and add it to defs + const pageMaskId = `mask_${shape.id.replace(':', '_')}` + defChildren.push( + + {/* Create a polyline mask that does the clipping */} + `${x},${y}`).join('L')}Z`} /> + + ) + + if (toSvgResult) { + toSvgResult = ( + + {toSvgResult} + + ) + } + if (toBackgroundSvgResult) { + toBackgroundSvgResult = ( + + {toBackgroundSvgResult} + + ) + } + } + + const elements = [] + if (toSvgResult) { + elements.push({ zIndex: index, element: toSvgResult }) + } + if (toBackgroundSvgResult) { + elements.push({ zIndex: backgroundIndex, element: toBackgroundSvgResult }) + } + + return elements + }) + ) + ).flat() + + await Promise.all(exportDefPromisesById.values()) + + const svg = ( + + + {defChildren} + {unorderedShapeElements.sort((a, b) => a.zIndex - b.zIndex).map(({ element }) => element)} + + + ) + + return { jsx: svg, width: w, height: h } +} diff --git a/packages/editor/src/lib/editor/shapes/ShapeUtil.ts b/packages/editor/src/lib/editor/shapes/ShapeUtil.ts index 1e4d13f4f..d2c3c9553 100644 --- a/packages/editor/src/lib/editor/shapes/ShapeUtil.ts +++ b/packages/editor/src/lib/editor/shapes/ShapeUtil.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { Migrations } from '@tldraw/store' import { ShapeProps, TLHandle, TLShape, TLShapePartial, TLUnknownShape } from '@tldraw/tlschema' +import { ReactElement } from 'react' import { Box } from '../../primitives/Box' import { Vec } from '../../primitives/Vec' import { Geometry2d } from '../../primitives/geometry/Geometry2d' @@ -230,7 +231,7 @@ export abstract class ShapeUtil { * @returns An SVG element. * @public */ - toSvg?(shape: Shape, ctx: SvgExportContext): SVGElement | Promise + toSvg?(shape: Shape, ctx: SvgExportContext): ReactElement | null | Promise /** * Get the shape's background layer as an SVG object. @@ -240,7 +241,10 @@ export abstract class ShapeUtil { * @returns An SVG element. * @public */ - toBackgroundSvg?(shape: Shape, ctx: SvgExportContext): SVGElement | Promise | null + toBackgroundSvg?( + shape: Shape, + ctx: SvgExportContext + ): ReactElement | null | Promise /** @internal */ expandSelectionOutlinePx(shape: Shape): number { diff --git a/packages/editor/src/lib/editor/shapes/shared/arrow/shared.ts b/packages/editor/src/lib/editor/shapes/shared/arrow/shared.ts index a27aeb04f..a42559ddb 100644 --- a/packages/editor/src/lib/editor/shapes/shared/arrow/shared.ts +++ b/packages/editor/src/lib/editor/shapes/shared/arrow/shared.ts @@ -119,8 +119,6 @@ export const MIN_ARROW_LENGTH = 10 /** @internal */ export const BOUND_ARROW_OFFSET = 10 /** @internal */ -export const LABEL_TO_ARROW_PADDING = 20 -/** @internal */ export const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10 /** @public */ diff --git a/packages/editor/src/lib/editor/types/SvgExportContext.tsx b/packages/editor/src/lib/editor/types/SvgExportContext.tsx index 5a958d784..17f338c66 100644 --- a/packages/editor/src/lib/editor/types/SvgExportContext.tsx +++ b/packages/editor/src/lib/editor/types/SvgExportContext.tsx @@ -1,7 +1,11 @@ +import { ReactElement, ReactNode, createContext, useContext } from 'react' +import { EditorContext } from '../../hooks/useEditor' +import { Editor } from '../Editor' + /** @public */ export interface SvgExportDef { key: string - getElement: () => Promise | SVGElement | SVGElement[] | null + getElement: () => Promise | ReactElement | null } /** @public */ @@ -17,3 +21,30 @@ export interface SvgExportContext { */ readonly isDarkMode: boolean } + +const Context = createContext(null) +export function SvgExportContextProvider({ + context, + editor, + children, +}: { + context: SvgExportContext + editor: Editor + children: ReactNode +}) { + return ( + + {children} + + ) +} + +/** + * Returns the read-only parts of {@link SvgExportContext}. + * @public + */ +export function useSvgExportContext() { + const ctx = useContext(Context) + if (!ctx) return null + return { isDarkMode: ctx.isDarkMode } +} diff --git a/packages/editor/src/lib/hooks/useIsDarkMode.ts b/packages/editor/src/lib/hooks/useIsDarkMode.ts index a03d987f3..5fef12bf8 100644 --- a/packages/editor/src/lib/hooks/useIsDarkMode.ts +++ b/packages/editor/src/lib/hooks/useIsDarkMode.ts @@ -1,8 +1,13 @@ import { useValue } from '@tldraw/state' +import { useSvgExportContext } from '../editor/types/SvgExportContext' import { useEditor } from './useEditor' /** @public */ export function useIsDarkMode() { const editor = useEditor() - return useValue('isDarkMode', () => editor.user.getIsDarkMode(), [editor]) + const exportContext = useSvgExportContext() + return useValue('isDarkMode', () => exportContext?.isDarkMode ?? editor.user.getIsDarkMode(), [ + exportContext, + editor, + ]) } diff --git a/packages/editor/src/lib/primitives/utils.ts b/packages/editor/src/lib/primitives/utils.ts index a4f163719..317f52239 100644 --- a/packages/editor/src/lib/primitives/utils.ts +++ b/packages/editor/src/lib/primitives/utils.ts @@ -339,17 +339,17 @@ export function pointInPolygon(A: VecLike, points: VecLike[]): boolean { * @public */ export function toDomPrecision(v: number) { - return +v.toFixed(4) + return Math.round(v * 1e4) / 1e4 } /** * @public */ export function toFixed(v: number) { - return +v.toFixed(2) + return Math.round(v * 1e2) / 1e2 } -/** +/**] * Check if a float is safe to use. ie: Not too big or small. * @public */ diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index 766ef4434..6f06c3328 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -240,7 +240,7 @@ export class ArrowShapeUtil extends ShapeUtil { labelPosition: Validator; }; // (undocumented) - toSvg(shape: TLArrowShape, ctx: SvgExportContext): SVGGElement; + toSvg(shape: TLArrowShape, ctx: SvgExportContext): JSX_2.Element; // (undocumented) static type: "arrow"; } @@ -499,7 +499,7 @@ export class DrawShapeUtil extends ShapeUtil { isPen: Validator; }; // (undocumented) - toSvg(shape: TLDrawShape, ctx: SvgExportContext): SVGGElement; + toSvg(shape: TLDrawShape, ctx: SvgExportContext): JSX_2.Element; // (undocumented) static type: "draw"; } @@ -664,7 +664,7 @@ export class FrameShapeUtil extends BaseBoxShapeUtil { // (undocumented) providesBackgroundForChildren(): boolean; // (undocumented) - toSvg(shape: TLFrameShape, ctx: SvgExportContext): Promise | SVGElement; + toSvg(shape: TLFrameShape, ctx: SvgExportContext): JSX_2.Element; // (undocumented) static type: "frame"; } @@ -816,7 +816,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { text: Validator; }; // (undocumented) - toSvg(shape: TLGeoShape, ctx: SvgExportContext): SVGElement; + toSvg(shape: TLGeoShape, ctx: SvgExportContext): JSX_2.Element; // (undocumented) static type: "geo"; } @@ -830,15 +830,14 @@ export function GeoStylePickerSet({ styles }: { export function getEmbedInfo(inputUrl: string): TLEmbedResult; // @public (undocumented) -export function getSvgAsImage(svg: SVGElement, isSafari: boolean, options: { +export function getSvgAsImage(svgString: string, isSafari: boolean, options: { type: 'jpeg' | 'png' | 'webp'; quality: number; scale: number; + width: number; + height: number; }): Promise; -// @public (undocumented) -export function getSvgAsString(svg: SVGElement): Promise; - // @public (undocumented) export function GroupMenuItem(): JSX_2.Element | null; @@ -915,9 +914,9 @@ export class HighlightShapeUtil extends ShapeUtil { isPen: Validator; }; // (undocumented) - toBackgroundSvg(shape: TLHighlightShape): SVGPathElement; + toBackgroundSvg(shape: TLHighlightShape): JSX_2.Element; // (undocumented) - toSvg(shape: TLHighlightShape, ctx: SvgExportContext): SVGPathElement; + toSvg(shape: TLHighlightShape): JSX_2.Element; // (undocumented) static type: "highlight"; } @@ -956,7 +955,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { } | null>; }; // (undocumented) - toSvg(shape: TLImageShape): Promise; + toSvg(shape: TLImageShape): Promise; // (undocumented) static type: "image"; } @@ -1016,7 +1015,7 @@ export class LineShapeTool extends StateNode { // @public (undocumented) export class LineShapeUtil extends ShapeUtil { // (undocumented) - component(shape: TLLineShape): JSX_2.Element | undefined; + component(shape: TLLineShape): JSX_2.Element; // (undocumented) getDefaultProps(): TLLineShape['props']; // (undocumented) @@ -1055,7 +1054,7 @@ export class LineShapeUtil extends ShapeUtil { }>; }; // (undocumented) - toSvg(shape: TLLineShape, ctx: SvgExportContext): SVGGElement; + toSvg(shape: TLLineShape): JSX_2.Element; // (undocumented) static type: "line"; } @@ -1163,7 +1162,7 @@ export class NoteShapeUtil extends ShapeUtil { text: Validator; }; // (undocumented) - toSvg(shape: TLNoteShape, ctx: SvgExportContext): SVGGElement; + toSvg(shape: TLNoteShape, ctx: SvgExportContext): JSX_2.Element; // (undocumented) static type: "note"; } @@ -1393,7 +1392,7 @@ export class TextShapeUtil extends ShapeUtil { autoSize: Validator; }; // (undocumented) - toSvg(shape: TLTextShape, ctx: SvgExportContext): SVGGElement; + toSvg(shape: TLTextShape, ctx: SvgExportContext): JSX_2.Element; // (undocumented) static type: "text"; } @@ -2563,7 +2562,7 @@ export class VideoShapeUtil extends BaseBoxShapeUtil { assetId: Validator; }; // (undocumented) - toSvg(shape: TLVideoShape): SVGGElement; + toSvg(shape: TLVideoShape): JSX_2.Element; // (undocumented) static type: "video"; } diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index 60d6d6c27..9f1417e53 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -1737,10 +1737,14 @@ "kind": "Content", "text": "): " }, + { + "kind": "Content", + "text": "import(\"react/jsx-runtime\")." + }, { "kind": "Reference", - "text": "SVGGElement", - "canonicalReference": "!SVGGElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -1750,7 +1754,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 5, - "endIndex": 6 + "endIndex": 7 }, "releaseTag": "Public", "isProtected": false, @@ -5270,10 +5274,14 @@ "kind": "Content", "text": "): " }, + { + "kind": "Content", + "text": "import(\"react/jsx-runtime\")." + }, { "kind": "Reference", - "text": "SVGGElement", - "canonicalReference": "!SVGGElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -5283,7 +5291,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 5, - "endIndex": 6 + "endIndex": 7 }, "releaseTag": "Public", "isProtected": false, @@ -7753,28 +7761,14 @@ "kind": "Content", "text": "): " }, - { - "kind": "Reference", - "text": "Promise", - "canonicalReference": "!Promise:interface" - }, { "kind": "Content", - "text": "<" + "text": "import(\"react/jsx-runtime\")." }, { "kind": "Reference", - "text": "SVGElement", - "canonicalReference": "!SVGElement:interface" - }, - { - "kind": "Content", - "text": "> | " - }, - { - "kind": "Reference", - "text": "SVGElement", - "canonicalReference": "!SVGElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -7784,7 +7778,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 5, - "endIndex": 10 + "endIndex": 7 }, "releaseTag": "Public", "isProtected": false, @@ -8975,10 +8969,14 @@ "kind": "Content", "text": "): " }, + { + "kind": "Content", + "text": "import(\"react/jsx-runtime\")." + }, { "kind": "Reference", - "text": "SVGElement", - "canonicalReference": "!SVGElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -8988,7 +8986,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 5, - "endIndex": 6 + "endIndex": 7 }, "releaseTag": "Public", "isProtected": false, @@ -9168,12 +9166,11 @@ "excerptTokens": [ { "kind": "Content", - "text": "export declare function getSvgAsImage(svg: " + "text": "export declare function getSvgAsImage(svgString: " }, { - "kind": "Reference", - "text": "SVGElement", - "canonicalReference": "!SVGElement:interface" + "kind": "Content", + "text": "string" }, { "kind": "Content", @@ -9189,7 +9186,7 @@ }, { "kind": "Content", - "text": "{\n type: 'jpeg' | 'png' | 'webp';\n quality: number;\n scale: number;\n}" + "text": "{\n type: 'jpeg' | 'png' | 'webp';\n quality: number;\n scale: number;\n width: number;\n height: number;\n}" }, { "kind": "Content", @@ -9227,7 +9224,7 @@ "overloadIndex": 1, "parameters": [ { - "parameterName": "svg", + "parameterName": "svgString", "parameterTypeTokenRange": { "startIndex": 1, "endIndex": 2 @@ -9253,57 +9250,6 @@ ], "name": "getSvgAsImage" }, - { - "kind": "Function", - "canonicalReference": "tldraw!getSvgAsString:function(1)", - "docComment": "/**\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "export declare function getSvgAsString(svg: " - }, - { - "kind": "Reference", - "text": "SVGElement", - "canonicalReference": "!SVGElement:interface" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Reference", - "text": "Promise", - "canonicalReference": "!Promise:interface" - }, - { - "kind": "Content", - "text": "" - }, - { - "kind": "Content", - "text": ";" - } - ], - "fileUrlPath": "packages/tldraw/src/lib/utils/export/export.ts", - "returnTypeTokenRange": { - "startIndex": 3, - "endIndex": 5 - }, - "releaseTag": "Public", - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "svg", - "parameterTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "isOptional": false - } - ], - "name": "getSvgAsString" - }, { "kind": "Function", "canonicalReference": "tldraw!GroupMenuItem:function(1)", @@ -10475,10 +10421,14 @@ "kind": "Content", "text": "): " }, + { + "kind": "Content", + "text": "import(\"react/jsx-runtime\")." + }, { "kind": "Reference", - "text": "SVGPathElement", - "canonicalReference": "!SVGPathElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -10488,7 +10438,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 3, - "endIndex": 4 + "endIndex": 5 }, "releaseTag": "Public", "isProtected": false, @@ -10521,23 +10471,18 @@ "text": "TLHighlightShape", "canonicalReference": "@tldraw/tlschema!TLHighlightShape:type" }, - { - "kind": "Content", - "text": ", ctx: " - }, - { - "kind": "Reference", - "text": "SvgExportContext", - "canonicalReference": "@tldraw/editor!SvgExportContext:interface" - }, { "kind": "Content", "text": "): " }, + { + "kind": "Content", + "text": "import(\"react/jsx-runtime\")." + }, { "kind": "Reference", - "text": "SVGPathElement", - "canonicalReference": "!SVGPathElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -10546,8 +10491,8 @@ ], "isStatic": false, "returnTypeTokenRange": { - "startIndex": 5, - "endIndex": 6 + "startIndex": 3, + "endIndex": 5 }, "releaseTag": "Public", "isProtected": false, @@ -10560,14 +10505,6 @@ "endIndex": 2 }, "isOptional": false - }, - { - "parameterName": "ctx", - "parameterTypeTokenRange": { - "startIndex": 3, - "endIndex": 4 - }, - "isOptional": false } ], "isOptional": false, @@ -11144,16 +11081,16 @@ }, { "kind": "Content", - "text": "<" + "text": "" + "text": " | null>" }, { "kind": "Content", @@ -11778,10 +11715,6 @@ "text": "JSX.Element", "canonicalReference": "@types/react!JSX.Element:interface" }, - { - "kind": "Content", - "text": " | undefined" - }, { "kind": "Content", "text": ";" @@ -11790,7 +11723,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 3, - "endIndex": 6 + "endIndex": 5 }, "releaseTag": "Public", "isProtected": false, @@ -12403,23 +12336,18 @@ "text": "TLLineShape", "canonicalReference": "@tldraw/tlschema!TLLineShape:type" }, - { - "kind": "Content", - "text": ", ctx: " - }, - { - "kind": "Reference", - "text": "SvgExportContext", - "canonicalReference": "@tldraw/editor!SvgExportContext:interface" - }, { "kind": "Content", "text": "): " }, + { + "kind": "Content", + "text": "import(\"react/jsx-runtime\")." + }, { "kind": "Reference", - "text": "SVGGElement", - "canonicalReference": "!SVGGElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -12428,8 +12356,8 @@ ], "isStatic": false, "returnTypeTokenRange": { - "startIndex": 5, - "endIndex": 6 + "startIndex": 3, + "endIndex": 5 }, "releaseTag": "Public", "isProtected": false, @@ -12442,14 +12370,6 @@ "endIndex": 2 }, "isOptional": false - }, - { - "parameterName": "ctx", - "parameterTypeTokenRange": { - "startIndex": 3, - "endIndex": 4 - }, - "isOptional": false } ], "isOptional": false, @@ -13504,10 +13424,14 @@ "kind": "Content", "text": "): " }, + { + "kind": "Content", + "text": "import(\"react/jsx-runtime\")." + }, { "kind": "Reference", - "text": "SVGGElement", - "canonicalReference": "!SVGGElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -13517,7 +13441,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 5, - "endIndex": 6 + "endIndex": 7 }, "releaseTag": "Public", "isProtected": false, @@ -16070,10 +15994,14 @@ "kind": "Content", "text": "): " }, + { + "kind": "Content", + "text": "import(\"react/jsx-runtime\")." + }, { "kind": "Reference", - "text": "SVGGElement", - "canonicalReference": "!SVGGElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -16083,7 +16011,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 5, - "endIndex": 6 + "endIndex": 7 }, "releaseTag": "Public", "isProtected": false, @@ -28609,10 +28537,14 @@ "kind": "Content", "text": "): " }, + { + "kind": "Content", + "text": "import(\"react/jsx-runtime\")." + }, { "kind": "Reference", - "text": "SVGGElement", - "canonicalReference": "!SVGGElement:interface" + "text": "JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" }, { "kind": "Content", @@ -28622,7 +28554,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 3, - "endIndex": 4 + "endIndex": 5 }, "releaseTag": "Public", "isProtected": false, diff --git a/packages/tldraw/setupTests.js b/packages/tldraw/setupTests.js index ce41eafc1..68f880da8 100644 --- a/packages/tldraw/setupTests.js +++ b/packages/tldraw/setupTests.js @@ -67,3 +67,6 @@ window.DOMRect = class DOMRect { this.height = height } } + +global.TextEncoder = require('util').TextEncoder +global.TextDecoder = require('util').TextDecoder diff --git a/packages/tldraw/src/index.ts b/packages/tldraw/src/index.ts index 4b838d967..4154a07f7 100644 --- a/packages/tldraw/src/index.ts +++ b/packages/tldraw/src/index.ts @@ -109,7 +109,7 @@ export { } from './lib/utils/assets/assets' export { getEmbedInfo } from './lib/utils/embeds/embeds' export { copyAs } from './lib/utils/export/copyAs' -export { exportToBlob, getSvgAsImage, getSvgAsString } from './lib/utils/export/export' +export { exportToBlob, getSvgAsImage } from './lib/utils/export/export' export { exportAs } from './lib/utils/export/exportAs' export { fitFrameToContent, removeFrame } from './lib/utils/frames/frames' export { setDefaultEditorAssetUrls } from './lib/utils/static-assets/assetUrls' diff --git a/packages/tldraw/src/lib/TldrawImage.tsx b/packages/tldraw/src/lib/TldrawImage.tsx index 80514f276..f3ce3cd95 100644 --- a/packages/tldraw/src/lib/TldrawImage.tsx +++ b/packages/tldraw/src/lib/TldrawImage.tsx @@ -14,7 +14,7 @@ import { import { memo, useLayoutEffect, useMemo, useState } from 'react' import { defaultShapeUtils } from './defaultShapeUtils' import { usePreloadAssets } from './ui/hooks/usePreloadAssets' -import { getSvgAsImage, getSvgAsString } from './utils/export/export' +import { getSvgAsImage } from './utils/export/export' import { useDefaultEditorAssetsWithOverrides } from './utils/static-assets/assetUrls' /** @@ -108,7 +108,7 @@ export const TldrawImage = memo(function TldrawImage(props: TldrawImageProps) { const shapeIds = editor.getCurrentPageShapeIds() async function setSvg() { - const svg = await editor.getSvg([...shapeIds], { + const svgResult = await editor.getSvgString([...shapeIds], { bounds, scale, background, @@ -117,19 +117,20 @@ export const TldrawImage = memo(function TldrawImage(props: TldrawImageProps) { preserveAspectRatio, }) - if (svg && !isCancelled) { + if (svgResult && !isCancelled) { if (format === 'svg') { - const string = await getSvgAsString(svg) if (!isCancelled) { - const blob = new Blob([string], { type: 'image/svg+xml' }) + const blob = new Blob([svgResult.svg], { type: 'image/svg+xml' }) const url = URL.createObjectURL(blob) setUrl(url) } } else if (format === 'png') { - const blob = await getSvgAsImage(svg, editor.environment.isSafari, { + const blob = await getSvgAsImage(svgResult.svg, editor.environment.isSafari, { type: format, quality: 1, scale: 2, + width: svgResult.width, + height: svgResult.height, }) if (blob && !isCancelled) { const url = URL.createObjectURL(blob) diff --git a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx index 8b4acb359..cbe043bad 100644 --- a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx @@ -1,8 +1,8 @@ import { Arc2d, Box, - DefaultFontFamilies, Edge2d, + Editor, Geometry2d, Group2d, Rectangle2d, @@ -10,11 +10,7 @@ import { ShapeUtil, SvgExportContext, TLArrowShape, - TLArrowShapeArrowheadStyle, TLArrowShapeProps, - TLDefaultColorStyle, - TLDefaultColorTheme, - TLDefaultFillStyle, TLHandle, TLOnEditEndHandler, TLOnHandleDragHandler, @@ -28,17 +24,18 @@ import { arrowShapeMigrations, arrowShapeProps, getArrowTerminalsInArrowSpace, - getDefaultColorTheme, mapObjectMapValues, objectMapEntries, structuredClone, toDomPrecision, + track, + useEditor, useIsEditing, } from '@tldraw/editor' import React from 'react' -import { ShapeFill, getShapeFillSvg, useDefaultColorTheme } from '../shared/ShapeFill' -import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans' -import { ARROW_LABEL_FONT_SIZES, STROKE_SIZES, TEXT_PROPS } from '../shared/default-shape-constants' +import { ShapeFill, useDefaultColorTheme } from '../shared/ShapeFill' +import { SvgTextLabel } from '../shared/SvgTextLabel' +import { ARROW_LABEL_FONT_SIZES, STROKE_SIZES } from '../shared/default-shape-constants' import { getFillDefForCanvas, getFillDefForExport, @@ -133,14 +130,6 @@ export class ArrowShapeUtil extends ShapeUtil { }) } - private getLength(shape: TLArrowShape): number { - const info = this.editor.getArrowInfo(shape)! - - return info.isStraight - ? Vec.Dist(info.start.handle, info.end.handle) - : Math.abs(info.handleArc.length) - } - override getHandles(shape: TLArrowShape): TLHandle[] { const info = this.editor.getArrowInfo(shape)! @@ -531,7 +520,6 @@ export class ArrowShapeUtil extends ShapeUtil { // eslint-disable-next-line react-hooks/rules-of-hooks const theme = useDefaultColorTheme() const onlySelectedShape = this.editor.getOnlySelectedShape() - const shouldDisplayHandles = this.editor.isInAny( 'select.idle', @@ -542,156 +530,17 @@ export class ArrowShapeUtil extends ShapeUtil { ) && !this.editor.getInstanceState().isReadonly const info = this.editor.getArrowInfo(shape) - const bounds = Box.ZeroFix(this.editor.getShapeGeometry(shape).bounds) - - // eslint-disable-next-line react-hooks/rules-of-hooks - const changeIndex = React.useMemo(() => { - return this.editor.environment.isSafari ? (globalRenderIndex += 1) : 0 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shape]) - if (!info?.isValid) return null - const strokeWidth = STROKE_SIZES[shape.props.size] - - const as = info.start.arrowhead && getArrowheadPathForType(info, 'start', strokeWidth) - const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth) - - const path = info.isStraight ? getSolidStraightArrowPath(info) : getSolidCurvedArrowPath(info) - - let handlePath: null | React.JSX.Element = null - - if (onlySelectedShape === shape && shouldDisplayHandles) { - const sw = 2 - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(this.getLength(shape), sw, { - end: 'skip', - start: 'skip', - lengthRatio: 2.5, - }) - - handlePath = - shape.props.start.type === 'binding' || shape.props.end.type === 'binding' ? ( - - ) : null - } - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - info.isStraight ? info.length : Math.abs(info.bodyArc.length), - strokeWidth, - { - style: shape.props.dash, - } - ) - const labelPosition = getArrowLabelPosition(this.editor, shape) - const maskStartArrowhead = !( - info.start.arrowhead === 'none' || info.start.arrowhead === 'arrow' - ) - const maskEndArrowhead = !(info.end.arrowhead === 'none' || info.end.arrowhead === 'arrow') - - // NOTE: I know right setting `changeIndex` hacky-as right! But we need this because otherwise safari loses - // the mask, see - const maskId = (shape.id + '_clip_' + changeIndex).replace(':', '_') - return ( <> - {/* Yep */} - - - - {shape.props.text.trim() && ( - - )} - {as && maskStartArrowhead && ( - - )} - {ae && maskEndArrowhead && ( - - )} - - - - {handlePath} - {/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */} - - - - - {as && maskStartArrowhead && shape.props.fill !== 'none' && ( - - )} - {ae && maskEndArrowhead && shape.props.fill !== 'none' && ( - - )} - {as && } - {ae && } - + { } override toSvg(shape: TLArrowShape, ctx: SvgExportContext) { - const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }) - ctx.addExportDef(getFillDefForExport(shape.props.fill, theme)) + ctx.addExportDef(getFillDefForExport(shape.props.fill)) + if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font)) - const color = theme[shape.props.color].solid - - const info = this.editor.getArrowInfo(shape) - - const strokeWidth = STROKE_SIZES[shape.props.size] - - // Group for arrow - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - if (!info) return g - - // Arrowhead start path - const as = info.start.arrowhead && getArrowheadPathForType(info, 'start', strokeWidth) - // Arrowhead end path - const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth) - - const geometry = this.editor.getShapeGeometry(shape) - const bounds = geometry.bounds - - const labelGeometry = shape.props.text.trim() ? (geometry.children[1] as Rectangle2d) : null - - const maskId = (shape.id + '_clip').replace(':', '_') - - // If we have any arrowheads, then mask the arrowheads - if (as || ae || !!labelGeometry) { - // Create mask for arrowheads - - // Create defs - const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs') - - // Create mask - const mask = document.createElementNS('http://www.w3.org/2000/svg', 'mask') - mask.id = maskId - - // Create large white shape for mask - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect') - rect.setAttribute('x', bounds.minX - 100 + '') - rect.setAttribute('y', bounds.minY - 100 + '') - rect.setAttribute('width', bounds.width + 200 + '') - rect.setAttribute('height', bounds.height + 200 + '') - rect.setAttribute('fill', 'white') - mask.appendChild(rect) - - // add arrowhead start mask - if (as) mask.appendChild(getArrowheadSvgMask(as, info.start.arrowhead)) - - // add arrowhead end mask - if (ae) mask.appendChild(getArrowheadSvgMask(ae, info.end.arrowhead)) - - // Mask out text label if text is present - if (labelGeometry) { - const labelMask = document.createElementNS('http://www.w3.org/2000/svg', 'rect') - labelMask.setAttribute('x', labelGeometry.x + '') - labelMask.setAttribute('y', labelGeometry.y + '') - labelMask.setAttribute('width', labelGeometry.w + '') - labelMask.setAttribute('height', labelGeometry.h + '') - labelMask.setAttribute('fill', 'black') - - mask.appendChild(labelMask) - } - - defs.appendChild(mask) - g.appendChild(defs) - } - - const g2 = document.createElementNS('http://www.w3.org/2000/svg', 'g') - g2.setAttribute('mask', `url(#${maskId})`) - g.appendChild(g2) - - // Dumb mask fix thing - const rect2 = document.createElementNS('http://www.w3.org/2000/svg', 'rect') - rect2.setAttribute('x', '-100') - rect2.setAttribute('y', '-100') - rect2.setAttribute('width', bounds.width + 200 + '') - rect2.setAttribute('height', bounds.height + 200 + '') - rect2.setAttribute('fill', 'transparent') - rect2.setAttribute('stroke', 'none') - g2.appendChild(rect2) - - // Arrowhead body path - const path = getArrowSvgPath( - info.isStraight ? getSolidStraightArrowPath(info) : getSolidCurvedArrowPath(info), - color, - strokeWidth + return ( + <> + + + ) - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - info.isStraight ? info.length : Math.abs(info.bodyArc.length), - strokeWidth, - { - style: shape.props.dash, - } - ) - - path.setAttribute('stroke-dasharray', strokeDasharray) - path.setAttribute('stroke-dashoffset', strokeDashoffset) - - g2.appendChild(path) - - // Arrowhead start path - if (as) { - g.appendChild( - getArrowheadSvgPath( - as, - shape.props.color, - strokeWidth, - shape.props.arrowheadStart === 'arrow' ? 'none' : shape.props.fill, - theme - ) - ) - } - // Arrowhead end path - if (ae) { - g.appendChild( - getArrowheadSvgPath( - ae, - shape.props.color, - strokeWidth, - shape.props.arrowheadEnd === 'arrow' ? 'none' : shape.props.fill, - theme - ) - ) - } - - // Text Label - if (labelGeometry) { - ctx.addExportDef(getFontDefForExport(shape.props.font)) - - const opts = { - fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size], - lineHeight: TEXT_PROPS.lineHeight, - fontFamily: DefaultFontFamilies[shape.props.font], - padding: 0, - textAlign: 'middle' as const, - width: labelGeometry.w - 8, - verticalTextAlign: 'middle' as const, - height: labelGeometry.h, - fontStyle: 'normal', - fontWeight: 'normal', - overflow: 'wrap' as const, - } - - const textElm = createTextSvgElementFromSpans( - this.editor, - this.editor.textMeasure.measureTextSpans(shape.props.text, opts), - opts - ) - textElm.setAttribute('fill', theme[shape.props.labelColor].solid) - - const children = Array.from(textElm.children) as unknown as SVGTSpanElement[] - - children.forEach((child) => { - const x = parseFloat(child.getAttribute('x') || '0') - const y = parseFloat(child.getAttribute('y') || '0') - - child.setAttribute('x', x + 4 + labelGeometry.x + 'px') - child.setAttribute('y', y + labelGeometry.y + 'px') - }) - - const textBgEl = textElm.cloneNode(true) as SVGTextElement - textBgEl.setAttribute('stroke-width', '2') - textBgEl.setAttribute('fill', theme.background) - textBgEl.setAttribute('stroke', theme.background) - - g.appendChild(textBgEl) - g.appendChild(textElm) - } - - return g } override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] { @@ -1028,55 +724,165 @@ export class ArrowShapeUtil extends ShapeUtil { } } -function getArrowheadSvgMask(d: string, arrowhead: TLArrowShapeArrowheadStyle) { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path.setAttribute('d', d) - path.setAttribute('fill', arrowhead === 'arrow' ? 'none' : 'black') - path.setAttribute('stroke', 'none') - return path +function getLength(editor: Editor, shape: TLArrowShape): number { + const info = editor.getArrowInfo(shape)! + + return info.isStraight + ? Vec.Dist(info.start.handle, info.end.handle) + : Math.abs(info.handleArc.length) } -function getArrowSvgPath(d: string, color: string, strokeWidth: number) { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path.setAttribute('d', d) - path.setAttribute('fill', 'none') - path.setAttribute('stroke', color) - path.setAttribute('stroke-width', strokeWidth + '') - return path -} +const ArrowSvg = track(function ArrowSvg({ + shape, + shouldDisplayHandles, +}: { + shape: TLArrowShape + shouldDisplayHandles: boolean +}) { + const editor = useEditor() + const theme = useDefaultColorTheme() + const info = editor.getArrowInfo(shape) + const bounds = Box.ZeroFix(editor.getShapeGeometry(shape).bounds) -function getArrowheadSvgPath( - d: string, - color: TLDefaultColorStyle, - strokeWidth: number, - fill: TLDefaultFillStyle, - theme: TLDefaultColorTheme -) { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path.setAttribute('d', d) - path.setAttribute('fill', 'none') - path.setAttribute('stroke', theme[color].solid) - path.setAttribute('stroke-width', strokeWidth + '') + const changeIndex = React.useMemo(() => { + return editor.environment.isSafari ? (globalRenderIndex += 1) : 0 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shape]) - // Get the fill element, if any - const shapeFill = getShapeFillSvg({ - d, - fill, - color, - theme, - }) + if (!info?.isValid) return null - if (shapeFill) { - // If there is a fill element, return a group containing the fill and the path - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - g.appendChild(shapeFill) - g.appendChild(path) - return g - } else { - // Otherwise, just return the path - return path + const strokeWidth = STROKE_SIZES[shape.props.size] + + const as = info.start.arrowhead && getArrowheadPathForType(info, 'start', strokeWidth) + const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth) + + const path = info.isStraight ? getSolidStraightArrowPath(info) : getSolidCurvedArrowPath(info) + + let handlePath: null | React.JSX.Element = null + + if (shouldDisplayHandles) { + const sw = 2 + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + getLength(editor, shape), + sw, + { + end: 'skip', + start: 'skip', + lengthRatio: 2.5, + } + ) + + handlePath = + shape.props.start.type === 'binding' || shape.props.end.type === 'binding' ? ( + + ) : null } -} + + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + info.isStraight ? info.length : Math.abs(info.bodyArc.length), + strokeWidth, + { + style: shape.props.dash, + } + ) + + const labelPosition = getArrowLabelPosition(editor, shape) + + const maskStartArrowhead = !(info.start.arrowhead === 'none' || info.start.arrowhead === 'arrow') + const maskEndArrowhead = !(info.end.arrowhead === 'none' || info.end.arrowhead === 'arrow') + + // NOTE: I know right setting `changeIndex` hacky-as right! But we need this because otherwise safari loses + // the mask, see + const maskId = (shape.id + '_clip_' + changeIndex).replace(':', '_') + + return ( + <> + {/* Yep */} + + + + {shape.props.text.trim() && ( + + )} + {as && maskStartArrowhead && ( + + )} + {ae && maskEndArrowhead && ( + + )} + + + + {handlePath} + {/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */} + + + + + {as && maskStartArrowhead && shape.props.fill !== 'none' && ( + + )} + {ae && maskEndArrowhead && shape.props.fill !== 'none' && ( + + )} + {as && } + {ae && } + + + ) +}) const shapeAtTranslationStart = new WeakMap< TLArrowShape, diff --git a/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts b/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts index bd496a1d9..e2e871bb4 100644 --- a/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts +++ b/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts @@ -27,7 +27,7 @@ import { const labelSizeCache = new WeakMap() -export function getArrowLabelSize(editor: Editor, shape: TLArrowShape) { +function getArrowLabelSize(editor: Editor, shape: TLArrowShape) { const cachedSize = labelSizeCache.get(shape) if (cachedSize) return cachedSize diff --git a/packages/tldraw/src/lib/shapes/draw/DrawShapeUtil.tsx b/packages/tldraw/src/lib/shapes/draw/DrawShapeUtil.tsx index 4ba0fb24a..4da1f721d 100644 --- a/packages/tldraw/src/lib/shapes/draw/DrawShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/draw/DrawShapeUtil.tsx @@ -14,18 +14,14 @@ import { VecLike, drawShapeMigrations, drawShapeProps, - getDefaultColorTheme, - getSvgPathFromPoints, last, rng, toFixed, } from '@tldraw/editor' -import { ShapeFill, getShapeFillSvg, useDefaultColorTheme } from '../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../shared/ShapeFill' import { STROKE_SIZES } from '../shared/default-shape-constants' import { getFillDefForCanvas, getFillDefForExport } from '../shared/defaultStyleDefs' -import { getStrokeOutlinePoints } from '../shared/freehand/getStrokeOutlinePoints' import { getStrokePoints } from '../shared/freehand/getStrokePoints' -import { setStrokePointRadii } from '../shared/freehand/setStrokePointRadii' import { getSvgPathFromStrokePoints } from '../shared/freehand/svg' import { svgInk } from '../shared/freehand/svgInk' import { useForceSolid } from '../shared/useForceSolid' @@ -91,71 +87,9 @@ export class DrawShapeUtil extends ShapeUtil { } component(shape: TLDrawShape) { - const theme = useDefaultColorTheme() - const forceSolid = useForceSolid() - const strokeWidth = STROKE_SIZES[shape.props.size] - const allPointsFromSegments = getPointsFromSegments(shape.props.segments) - - const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight' - - let sw = strokeWidth - if ( - !forceSolid && - !shape.props.isPen && - shape.props.dash === 'draw' && - allPointsFromSegments.length === 1 - ) { - sw += rng(shape.id)() * (strokeWidth / 6) - } - - const options = getFreehandOptions(shape.props, sw, showAsComplete, forceSolid) - - if (!forceSolid && shape.props.dash === 'draw') { - return ( - - {shape.props.isClosed && shape.props.fill && allPointsFromSegments.length > 1 ? ( - - ) : null} - - - ) - } - - const strokePoints = getStrokePoints(allPointsFromSegments, options) - const isDot = strokePoints.length < 2 - const solidStrokePath = isDot - ? getDot(allPointsFromSegments[0], 0) - : getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed) - return ( - - + ) } @@ -187,68 +121,8 @@ export class DrawShapeUtil extends ShapeUtil { } override toSvg(shape: TLDrawShape, ctx: SvgExportContext) { - const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }) - ctx.addExportDef(getFillDefForExport(shape.props.fill, theme)) - - const { color } = shape.props - - const strokeWidth = STROKE_SIZES[shape.props.size] - const allPointsFromSegments = getPointsFromSegments(shape.props.segments) - - const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight' - - let sw = strokeWidth - if (!shape.props.isPen && shape.props.dash === 'draw' && allPointsFromSegments.length === 1) { - sw += rng(shape.id)() * (strokeWidth / 6) - } - - const options = getFreehandOptions(shape.props, sw, showAsComplete, false) - const strokePoints = getStrokePoints(allPointsFromSegments, options) - const solidStrokePath = - strokePoints.length > 1 - ? getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed) - : getDot(allPointsFromSegments[0], sw) - - let foregroundPath: SVGPathElement | undefined - - if (shape.props.dash === 'draw' || strokePoints.length < 2) { - setStrokePointRadii(strokePoints, options) - const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options) - - const p = document.createElementNS('http://www.w3.org/2000/svg', 'path') - p.setAttribute('d', getSvgPathFromPoints(strokeOutlinePoints, true)) - p.setAttribute('fill', theme[color].solid) - p.setAttribute('stroke-linecap', 'round') - - foregroundPath = p - } else { - const p = document.createElementNS('http://www.w3.org/2000/svg', 'path') - p.setAttribute('d', solidStrokePath) - p.setAttribute('stroke', theme[color].solid) - p.setAttribute('fill', 'none') - p.setAttribute('stroke-linecap', 'round') - p.setAttribute('stroke-width', strokeWidth.toString()) - p.setAttribute('stroke-dasharray', getDrawShapeStrokeDashArray(shape, strokeWidth)) - p.setAttribute('stroke-dashoffset', '0') - - foregroundPath = p - } - - const fillPath = getShapeFillSvg({ - fill: shape.props.isClosed ? shape.props.fill : 'none', - d: solidStrokePath, - color: shape.props.color, - theme, - }) - - if (fillPath) { - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - g.appendChild(fillPath) - g.appendChild(foregroundPath) - return g - } - - return foregroundPath + ctx.addExportDef(getFillDefForExport(shape.props.fill)) + return } override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] { @@ -296,3 +170,72 @@ function getDot(point: VecLike, sw: number) { function getIsDot(shape: TLDrawShape) { return shape.props.segments.length === 1 && shape.props.segments[0].points.length < 2 } + +function DrawShapSvg({ shape, forceSolid }: { shape: TLDrawShape; forceSolid: boolean }) { + const theme = useDefaultColorTheme() + const strokeWidth = STROKE_SIZES[shape.props.size] + const allPointsFromSegments = getPointsFromSegments(shape.props.segments) + + const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight' + + let sw = strokeWidth + if ( + !forceSolid && + !shape.props.isPen && + shape.props.dash === 'draw' && + allPointsFromSegments.length === 1 + ) { + sw += rng(shape.id)() * (strokeWidth / 6) + } + + const options = getFreehandOptions(shape.props, sw, showAsComplete, forceSolid) + + if (!forceSolid && shape.props.dash === 'draw') { + return ( + <> + {shape.props.isClosed && shape.props.fill && allPointsFromSegments.length > 1 ? ( + + ) : null} + + + ) + } + + const strokePoints = getStrokePoints(allPointsFromSegments, options) + const isDot = strokePoints.length < 2 + const solidStrokePath = isDot + ? getDot(allPointsFromSegments[0], 0) + : getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed) + + return ( + <> + + + + ) +} diff --git a/packages/tldraw/src/lib/shapes/frame/FrameShapeUtil.tsx b/packages/tldraw/src/lib/shapes/frame/FrameShapeUtil.tsx index 8d540c14b..8356d8ab1 100644 --- a/packages/tldraw/src/lib/shapes/frame/FrameShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/frame/FrameShapeUtil.tsx @@ -12,6 +12,7 @@ import { TLShape, TLShapeId, canonicalizeRotation, + exhaustiveSwitchError, frameShapeMigrations, frameShapeProps, getDefaultColorTheme, @@ -22,7 +23,7 @@ import { } from '@tldraw/editor' import classNames from 'classnames' import { useDefaultColorTheme } from '../shared/ShapeFill' -import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans' +import { createTextJsxFromSpans } from '../shared/createTextJsxFromSpans' import { FrameHeading } from './components/FrameHeading' export function defaultEmptyAs(str: string, dflt: string) { @@ -97,19 +98,8 @@ export class FrameShapeUtil extends BaseBoxShapeUtil { ) } - override toSvg(shape: TLFrameShape, ctx: SvgExportContext): SVGElement | Promise { + override toSvg(shape: TLFrameShape, ctx: SvgExportContext) { const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }) - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect') - rect.setAttribute('width', shape.props.w.toString()) - rect.setAttribute('height', shape.props.h.toString()) - rect.setAttribute('fill', theme.solid) - rect.setAttribute('stroke', theme.black.solid) - rect.setAttribute('stroke-width', '1') - rect.setAttribute('rx', '1') - rect.setAttribute('ry', '1') - g.appendChild(rect) // Text label const pageRotation = canonicalizeRotation( @@ -128,18 +118,18 @@ export class FrameShapeUtil extends BaseBoxShapeUtil { labelTranslate = `` break case 'right': - labelTranslate = `translate(${toDomPrecision(shape.props.w)}px, 0px) rotate(90deg)` + labelTranslate = `translate(${toDomPrecision(shape.props.w)}, 0) rotate(90)` break case 'bottom': - labelTranslate = `translate(${toDomPrecision(shape.props.w)}px, ${toDomPrecision( + labelTranslate = `translate(${toDomPrecision(shape.props.w)}, ${toDomPrecision( shape.props.h - )}px) rotate(180deg)` + )}) rotate(180)` break case 'left': - labelTranslate = `translate(0px, ${toDomPrecision(shape.props.h)}px) rotate(270deg)` + labelTranslate = `translate(0, ${toDomPrecision(shape.props.h)}) rotate(270)` break default: - labelTranslate = `` + exhaustiveSwitchError(labelSide) } // Truncate with ellipsis @@ -165,25 +155,36 @@ export class FrameShapeUtil extends BaseBoxShapeUtil { const firstSpan = spans[0] const lastSpan = last(spans)! const labelTextWidth = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x - const text = createTextSvgElementFromSpans(this.editor, spans, { + const text = createTextJsxFromSpans(this.editor, spans, { offsetY: -opts.height - 2, ...opts, }) - text.style.setProperty('transform', labelTranslate) - const textBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect') - textBg.setAttribute('x', '-8px') - textBg.setAttribute('y', -opts.height - 4 + 'px') - textBg.setAttribute('width', labelTextWidth + 16 + 'px') - textBg.setAttribute('height', `${opts.height}px`) - textBg.setAttribute('rx', 4 + 'px') - textBg.setAttribute('ry', 4 + 'px') - textBg.setAttribute('fill', theme.background) - - g.appendChild(textBg) - g.appendChild(text) - - return g + return ( + <> + + + + {text} + + + ) } indicator(shape: TLFrameShape) { diff --git a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx index 398c47f50..29c5c11be 100644 --- a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx @@ -1,6 +1,5 @@ import { BaseBoxShapeUtil, - DefaultFontFamilies, Editor, Ellipse2d, Geometry2d, @@ -15,21 +14,19 @@ import { SVGContainer, Stadium2d, SvgExportContext, - TLDefaultDashStyle, TLGeoShape, TLOnEditEndHandler, TLOnResizeHandler, TLShapeUtilCanvasSvgDef, Vec, - VecLike, exhaustiveSwitchError, geoShapeMigrations, geoShapeProps, - getDefaultColorTheme, getPolygonVertices, } from '@tldraw/editor' import { HyperlinkButton } from '../shared/HyperlinkButton' +import { SvgTextLabel } from '../shared/SvgTextLabel' import { TextLabel } from '../shared/TextLabel' import { FONT_FAMILIES, @@ -42,24 +39,12 @@ import { getFillDefForExport, getFontDefForExport, } from '../shared/defaultStyleDefs' -import { getTextLabelSvgElement } from '../shared/getTextLabelSvgElement' import { getRoundedInkyPolygonPath, getRoundedPolygonPoints } from '../shared/polygon-helpers' import { cloudOutline, cloudSvgPath } from './cloudOutline' -import { DashStyleCloud, DashStyleCloudSvg } from './components/DashStyleCloud' -import { DashStyleEllipse, DashStyleEllipseSvg } from './components/DashStyleEllipse' -import { DashStyleOval, DashStyleOvalSvg } from './components/DashStyleOval' -import { DashStylePolygon, DashStylePolygonSvg } from './components/DashStylePolygon' -import { DrawStyleCloud, DrawStyleCloudSvg } from './components/DrawStyleCloud' -import { DrawStyleEllipseSvg, getEllipseIndicatorPath } from './components/DrawStyleEllipse' -import { DrawStylePolygon, DrawStylePolygonSvg } from './components/DrawStylePolygon' -import { SolidStyleCloud, SolidStyleCloudSvg } from './components/SolidStyleCloud' -import { SolidStyleEllipse, SolidStyleEllipseSvg } from './components/SolidStyleEllipse' -import { - SolidStyleOval, - SolidStyleOvalSvg, - getOvalIndicatorPath, -} from './components/SolidStyleOval' -import { SolidStylePolygon, SolidStylePolygonSvg } from './components/SolidStylePolygon' +import { getEllipseIndicatorPath } from './components/DrawStyleEllipse' +import { GeoShapeBody } from './components/GeoShapeBody' +import { getOvalIndicatorPath } from './components/SolidStyleOval' +import { getLines } from './getLines' const LABEL_PADDING = 16 const MIN_SIZE_WITH_LABEL = 17 * 3 @@ -396,152 +381,13 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { component(shape: TLGeoShape) { const { id, type, props } = shape - - const strokeWidth = STROKE_SIZES[props.size] - - const { w, color, labelColor, fill, dash, growY, font, align, verticalAlign, size, text } = - props - - const getShape = () => { - const h = props.h + growY - - switch (props.geo) { - case 'cloud': { - if (dash === 'solid') { - return ( - - ) - } else if (dash === 'dashed' || dash === 'dotted') { - return ( - - ) - } else if (dash === 'draw') { - return ( - - ) - } - - break - } - case 'ellipse': { - if (dash === 'solid') { - return ( - - ) - } else if (dash === 'dashed' || dash === 'dotted') { - return ( - - ) - } else if (dash === 'draw') { - return ( - - ) - } - break - } - case 'oval': { - if (dash === 'solid') { - return ( - - ) - } else if (dash === 'dashed' || dash === 'dotted') { - return ( - - ) - } else if (dash === 'draw') { - return ( - - ) - } - break - } - default: { - const geometry = this.editor.getShapeGeometry(shape) - const outline = - geometry instanceof Group2d ? geometry.children[0].vertices : geometry.vertices - const lines = getLines(shape.props, strokeWidth) - - if (dash === 'solid') { - return ( - - ) - } else if (dash === 'dashed' || dash === 'dotted') { - return ( - - ) - } else if (dash === 'draw') { - return ( - - ) - } - } - } - } + const { labelColor, fill, font, align, verticalAlign, size, text } = props return ( <> - {getShape()} + + + { } override toSvg(shape: TLGeoShape, ctx: SvgExportContext) { - const { id, props } = shape - const strokeWidth = STROKE_SIZES[props.size] - const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }) - ctx.addExportDef(getFillDefForExport(shape.props.fill, theme)) - - let svgElm: SVGElement - - switch (props.geo) { - case 'ellipse': { - switch (props.dash) { - case 'draw': - svgElm = DrawStyleEllipseSvg({ - id, - w: props.w, - h: props.h, - color: props.color, - fill: props.fill, - strokeWidth, - theme, - }) - break - - case 'solid': - svgElm = SolidStyleEllipseSvg({ - strokeWidth, - w: props.w, - h: props.h, - color: props.color, - fill: props.fill, - theme, - }) - break - - default: - svgElm = DashStyleEllipseSvg({ - id, - strokeWidth, - w: props.w, - h: props.h, - dash: props.dash, - color: props.color, - fill: props.fill, - theme, - }) - break - } - break - } - - case 'oval': { - switch (props.dash) { - case 'draw': - svgElm = DashStyleOvalSvg({ - id, - strokeWidth, - w: props.w, - h: props.h, - dash: props.dash, - color: props.color, - fill: props.fill, - theme, - }) - break - - case 'solid': - svgElm = SolidStyleOvalSvg({ - strokeWidth, - w: props.w, - h: props.h, - color: props.color, - fill: props.fill, - theme, - }) - break - - default: - svgElm = DashStyleOvalSvg({ - id, - strokeWidth, - w: props.w, - h: props.h, - dash: props.dash, - color: props.color, - fill: props.fill, - theme, - }) - } - break - } - - case 'cloud': { - switch (props.dash) { - case 'draw': - svgElm = DrawStyleCloudSvg({ - id, - strokeWidth, - w: props.w, - h: props.h, - color: props.color, - fill: props.fill, - size: props.size, - theme, - }) - break - - case 'solid': - svgElm = SolidStyleCloudSvg({ - strokeWidth, - w: props.w, - h: props.h, - color: props.color, - fill: props.fill, - size: props.size, - id, - theme, - }) - break - - default: - svgElm = DashStyleCloudSvg({ - id, - strokeWidth, - w: props.w, - h: props.h, - dash: props.dash, - color: props.color, - fill: props.fill, - theme, - size: props.size, - }) - } - break - } - default: { - const geometry = this.editor.getShapeGeometry(shape) - const outline = - geometry instanceof Group2d ? geometry.children[0].vertices : geometry.vertices - const lines = getLines(shape.props, strokeWidth) - - switch (props.dash) { - case 'draw': - svgElm = DrawStylePolygonSvg({ - id, - fill: props.fill, - color: props.color, - strokeWidth, - outline, - lines, - theme, - }) - break - - case 'solid': - svgElm = SolidStylePolygonSvg({ - fill: props.fill, - color: props.color, - strokeWidth, - outline, - lines, - theme, - }) - break - - default: - svgElm = DashStylePolygonSvg({ - dash: props.dash, - fill: props.fill, - color: props.color, - strokeWidth, - outline, - lines, - theme, - }) - break - } - break - } - } + const { props } = shape + ctx.addExportDef(getFillDefForExport(shape.props.fill)) + let textEl if (props.text) { - const bounds = this.editor.getShapeGeometry(shape).bounds - ctx.addExportDef(getFontDefForExport(shape.props.font)) - const rootTextElm = getTextLabelSvgElement({ - editor: this.editor, - shape, - font: DefaultFontFamilies[shape.props.font], - bounds, - }) - - const textElm = rootTextElm.cloneNode(true) as SVGTextElement - textElm.setAttribute('fill', theme[shape.props.labelColor].solid) - textElm.setAttribute('stroke', 'none') - - const textBgEl = rootTextElm.cloneNode(true) as SVGTextElement - textBgEl.setAttribute('stroke-width', '2') - textBgEl.setAttribute('fill', theme.background) - textBgEl.setAttribute('stroke', theme.background) - - const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g') - groupEl.append(textBgEl) - groupEl.append(textElm) - - if (svgElm.nodeName === 'g') { - svgElm.appendChild(groupEl) - return svgElm - } else { - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - g.appendChild(svgElm) - g.appendChild(groupEl) - return g - } + const bounds = this.editor.getShapeGeometry(shape).bounds + textEl = ( + + ) } - return svgElm + return ( + <> + + {textEl} + + ) } override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] { @@ -1110,80 +767,3 @@ function getLabelSize(editor: Editor, shape: TLGeoShape) { h: size.h + LABEL_PADDING * 2, } } - -function getLines(props: TLGeoShape['props'], sw: number) { - switch (props.geo) { - case 'x-box': { - return getXBoxLines(props.w, props.h, sw, props.dash) - } - case 'check-box': { - return getCheckBoxLines(props.w, props.h) - } - default: { - return undefined - } - } -} - -function getXBoxLines(w: number, h: number, sw: number, dash: TLDefaultDashStyle) { - const inset = dash === 'draw' ? 0.62 : 0 - - if (dash === 'dashed') { - return [ - [new Vec(0, 0), new Vec(w / 2, h / 2)], - [new Vec(w, h), new Vec(w / 2, h / 2)], - [new Vec(0, h), new Vec(w / 2, h / 2)], - [new Vec(w, 0), new Vec(w / 2, h / 2)], - ] - } - - const clampX = (x: number) => Math.max(0, Math.min(w, x)) - const clampY = (y: number) => Math.max(0, Math.min(h, y)) - - return [ - [ - new Vec(clampX(sw * inset), clampY(sw * inset)), - new Vec(clampX(w - sw * inset), clampY(h - sw * inset)), - ], - [ - new Vec(clampX(sw * inset), clampY(h - sw * inset)), - new Vec(clampX(w - sw * inset), clampY(sw * inset)), - ], - ] -} - -function getCheckBoxLines(w: number, h: number) { - const size = Math.min(w, h) * 0.82 - const ox = (w - size) / 2 - const oy = (h - size) / 2 - - const clampX = (x: number) => Math.max(0, Math.min(w, x)) - const clampY = (y: number) => Math.max(0, Math.min(h, y)) - - return [ - [ - new Vec(clampX(ox + size * 0.25), clampY(oy + size * 0.52)), - new Vec(clampX(ox + size * 0.45), clampY(oy + size * 0.82)), - ], - [ - new Vec(clampX(ox + size * 0.45), clampY(oy + size * 0.82)), - new Vec(clampX(ox + size * 0.82), clampY(oy + size * 0.22)), - ], - ] -} - -/** - * Get the centroid of a regular polygon. - * @param points - The points that make up the polygon. - * @internal - */ -export function getCentroidOfRegularPolygon(points: VecLike[]) { - const len = points.length - let x = 0 - let y = 0 - for (let i = 0; i < len; i++) { - x += points[i].x - y += points[i].y - } - return new Vec(x / len, y / len) -} diff --git a/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts b/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts index 0a98f6492..e1c9f039e 100644 --- a/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts +++ b/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts @@ -28,7 +28,7 @@ type PillSection = startAngle: number } -export function getPillPoints(width: number, height: number, numPoints: number) { +function getPillPoints(width: number, height: number, numPoints: number) { const radius = Math.min(width, height) / 2 const longSide = Math.max(width, height) - radius * 2 @@ -352,7 +352,7 @@ export function inkyCloudSvgPath( return pathA + pathB + ' Z' } -export function pointsOnArc( +function pointsOnArc( startPoint: VecModel, endPoint: VecModel, center: VecModel | null, diff --git a/packages/tldraw/src/lib/shapes/geo/components/DashStyleCloud.tsx b/packages/tldraw/src/lib/shapes/geo/components/DashStyleCloud.tsx index eed300830..d2d0817fa 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/DashStyleCloud.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/DashStyleCloud.tsx @@ -1,17 +1,6 @@ -import { - TLDefaultColorTheme, - TLGeoShape, - TLShapeId, - Vec, - canonicalizeRotation, -} from '@tldraw/editor' +import { TLGeoShape, TLShapeId, Vec, canonicalizeRotation } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' import { getPerfectDashProps } from '../../shared/getPerfectDashProps' import { cloudSvgPath, getCloudArcs } from '../cloudOutline' @@ -72,64 +61,3 @@ export const DashStyleCloud = React.memo(function DashStylePolygon({ ) }) - -export function DashStyleCloudSvg({ - dash, - fill, - color, - theme, - strokeWidth, - w, - h, - id, - size, -}: Pick & { - id: TLShapeId - strokeWidth: number - theme: TLDefaultColorTheme -}) { - const innerPath = cloudSvgPath(w, h, id, size) - const arcs = getCloudArcs(w, h, id, size) - - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') - strokeElement.setAttribute('stroke-width', strokeWidth.toString()) - strokeElement.setAttribute('stroke', theme[color].solid) - strokeElement.setAttribute('fill', 'none') - - for (const { leftPoint, rightPoint, center, radius } of arcs) { - const arcLength = center - ? radius * - canonicalizeRotation( - canonicalizeRotation(Vec.Angle(center, rightPoint)) - - canonicalizeRotation(Vec.Angle(center, leftPoint)) - ) - : Vec.Dist(leftPoint, rightPoint) - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(arcLength, strokeWidth, { - style: dash, - start: 'outset', - end: 'outset', - }) - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path.setAttribute( - 'd', - center - ? `M${leftPoint.x},${leftPoint.y}A${radius},${radius},0,0,1,${rightPoint.x},${rightPoint.y}` - : `M${leftPoint.x},${leftPoint.y}L${rightPoint.x},${rightPoint.y}` - ) - path.setAttribute('stroke-dasharray', strokeDasharray.toString()) - path.setAttribute('stroke-dashoffset', strokeDashoffset.toString()) - strokeElement.appendChild(path) - } - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d: innerPath, - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/DashStyleEllipse.tsx b/packages/tldraw/src/lib/shapes/geo/components/DashStyleEllipse.tsx index ff3666cb7..2e8c99ca5 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/DashStyleEllipse.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/DashStyleEllipse.tsx @@ -1,17 +1,6 @@ -import { - TLDefaultColorTheme, - TLGeoShape, - TLShapeId, - perimeterOfEllipse, - toDomPrecision, -} from '@tldraw/editor' +import { TLGeoShape, TLShapeId, perimeterOfEllipse, toDomPrecision } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' import { getPerfectDashProps } from '../../shared/getPerfectDashProps' export const DashStyleEllipse = React.memo(function DashStyleEllipse({ @@ -62,56 +51,3 @@ export const DashStyleEllipse = React.memo(function DashStyleEllipse({ ) }) - -export function DashStyleEllipseSvg({ - w, - h, - strokeWidth: sw, - dash, - color, - theme, - fill, -}: Pick & { - strokeWidth: number - id: TLShapeId - theme: TLDefaultColorTheme -}) { - const cx = w / 2 - const cy = h / 2 - const rx = Math.max(0, cx - sw / 2) - const ry = Math.max(0, cy - sw / 2) - - const perimeter = perimeterOfEllipse(rx, ry) - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - perimeter < 64 ? perimeter * 2 : perimeter, - sw, - { - style: dash, - snap: 4, - closed: true, - } - ) - - const d = `M${cx - rx},${cy}a${rx},${ry},0,1,1,${rx * 2},0a${rx},${ry},0,1,1,-${rx * 2},0` - - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path') - strokeElement.setAttribute('d', d) - strokeElement.setAttribute('stroke-width', sw.toString()) - strokeElement.setAttribute('width', w.toString()) - strokeElement.setAttribute('height', h.toString()) - strokeElement.setAttribute('fill', 'none') - strokeElement.setAttribute('stroke', theme[color].solid) - strokeElement.setAttribute('stroke-dasharray', strokeDasharray) - strokeElement.setAttribute('stroke-dashoffset', strokeDashoffset) - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d, - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/DashStyleOval.tsx b/packages/tldraw/src/lib/shapes/geo/components/DashStyleOval.tsx index 1226612d2..1c787fadb 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/DashStyleOval.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/DashStyleOval.tsx @@ -1,11 +1,6 @@ -import { TLDefaultColorTheme, TLGeoShape, TLShapeId, toDomPrecision } from '@tldraw/editor' +import { TLGeoShape, TLShapeId, toDomPrecision } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' import { getPerfectDashProps } from '../../shared/getPerfectDashProps' import { getOvalPerimeter, getOvalSolidPath } from '../helpers' @@ -53,50 +48,3 @@ export const DashStyleOval = React.memo(function DashStyleOval({ ) }) - -export function DashStyleOvalSvg({ - w, - h, - strokeWidth: sw, - dash, - color, - theme, - fill, -}: Pick & { - strokeWidth: number - id: TLShapeId - theme: TLDefaultColorTheme -}) { - const d = getOvalSolidPath(w, h) - const perimeter = getOvalPerimeter(w, h) - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - perimeter < 64 ? perimeter * 2 : perimeter, - sw, - { - style: dash, - snap: 4, - closed: true, - } - ) - - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path') - strokeElement.setAttribute('d', d) - strokeElement.setAttribute('stroke-width', sw.toString()) - strokeElement.setAttribute('width', w.toString()) - strokeElement.setAttribute('height', h.toString()) - strokeElement.setAttribute('fill', 'none') - strokeElement.setAttribute('stroke', theme[color].solid) - strokeElement.setAttribute('stroke-dasharray', strokeDasharray) - strokeElement.setAttribute('stroke-dashoffset', strokeDashoffset) - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d, - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/DashStylePolygon.tsx b/packages/tldraw/src/lib/shapes/geo/components/DashStylePolygon.tsx index 060650fe5..76f2c67a1 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/DashStylePolygon.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/DashStylePolygon.tsx @@ -1,11 +1,6 @@ -import { TLDefaultColorTheme, TLGeoShape, Vec, VecLike } from '@tldraw/editor' +import { TLGeoShape, Vec, VecLike } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' import { getPerfectDashProps } from '../../shared/getPerfectDashProps' export const DashStylePolygon = React.memo(function DashStylePolygon({ @@ -78,75 +73,3 @@ export const DashStylePolygon = React.memo(function DashStylePolygon({ ) }) - -export function DashStylePolygonSvg({ - dash, - fill, - color, - theme, - strokeWidth, - outline, - lines, -}: Pick & { - outline: VecLike[] - strokeWidth: number - theme: TLDefaultColorTheme - lines?: VecLike[][] -}) { - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') - strokeElement.setAttribute('stroke-width', strokeWidth.toString()) - strokeElement.setAttribute('stroke', theme[color].solid) - strokeElement.setAttribute('fill', 'none') - - Array.from(Array(outline.length)).forEach((_, i) => { - const A = outline[i] - const B = outline[(i + 1) % outline.length] - - const dist = Vec.Dist(A, B) - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(dist, strokeWidth, { - style: dash, - }) - - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line') - line.setAttribute('x1', A.x.toString()) - line.setAttribute('y1', A.y.toString()) - line.setAttribute('x2', B.x.toString()) - line.setAttribute('y2', B.y.toString()) - line.setAttribute('stroke-dasharray', strokeDasharray.toString()) - line.setAttribute('stroke-dashoffset', strokeDashoffset.toString()) - - strokeElement.appendChild(line) - }) - - if (lines) { - for (const [A, B] of lines) { - const dist = Vec.Dist(A, B) - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(dist, strokeWidth, { - style: dash, - start: 'skip', - end: 'skip', - snap: dash === 'dotted' ? 4 : 2, - }) - - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line') - line.setAttribute('x1', A.x.toString()) - line.setAttribute('y1', A.y.toString()) - line.setAttribute('x2', B.x.toString()) - line.setAttribute('y2', B.y.toString()) - line.setAttribute('stroke-dasharray', strokeDasharray.toString()) - line.setAttribute('stroke-dashoffset', strokeDashoffset.toString()) - - strokeElement.appendChild(line) - } - } - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d: 'M' + outline[0] + 'L' + outline.slice(1) + 'Z', - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/DrawStyleCloud.tsx b/packages/tldraw/src/lib/shapes/geo/components/DrawStyleCloud.tsx index 7dcf098e2..51464c7e1 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/DrawStyleCloud.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/DrawStyleCloud.tsx @@ -1,11 +1,6 @@ -import { TLDefaultColorTheme, TLGeoShape, TLShapeId } from '@tldraw/editor' +import { TLGeoShape, TLShapeId } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' import { inkyCloudSvgPath } from '../cloudOutline' export const DrawStyleCloud = React.memo(function StyleCloud({ @@ -30,36 +25,3 @@ export const DrawStyleCloud = React.memo(function StyleCloud({ ) }) - -export function DrawStyleCloudSvg({ - fill, - color, - strokeWidth, - theme, - w, - h, - id, - size, -}: Pick & { - strokeWidth: number - theme: TLDefaultColorTheme - id: TLShapeId -}) { - const pathData = inkyCloudSvgPath(w, h, id, size) - - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path') - strokeElement.setAttribute('d', pathData) - strokeElement.setAttribute('stroke-width', strokeWidth.toString()) - strokeElement.setAttribute('stroke', theme[color].solid) - strokeElement.setAttribute('fill', 'none') - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d: pathData, - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/DrawStyleEllipse.tsx b/packages/tldraw/src/lib/shapes/geo/components/DrawStyleEllipse.tsx index 1a6c6bb70..dcdad3e40 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/DrawStyleEllipse.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/DrawStyleEllipse.tsx @@ -1,80 +1,8 @@ -import { - EASINGS, - HALF_PI, - PI2, - TLDefaultColorTheme, - TLGeoShape, - TLShapeId, - Vec, - getSvgPathFromPoints, - perimeterOfEllipse, - rng, -} from '@tldraw/editor' - -import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' -import { getStrokeOutlinePoints } from '../../shared/freehand/getStrokeOutlinePoints' +import { EASINGS, HALF_PI, PI2, Vec, perimeterOfEllipse, rng } from '@tldraw/editor' import { getStrokePoints } from '../../shared/freehand/getStrokePoints' -import { setStrokePointRadii } from '../../shared/freehand/setStrokePointRadii' import { getSvgPathFromStrokePoints } from '../../shared/freehand/svg' -export const DrawStyleEllipse = React.memo(function DrawStyleEllipse({ - id, - w, - h, - strokeWidth: sw, - fill, - color, -}: Pick & { - strokeWidth: number - id: TLShapeId -}) { - const theme = useDefaultColorTheme() - const innerPath = getEllipseIndicatorPath(id, w, h, sw) - const outerPath = getEllipsePath(id, w, h, sw) - - return ( - <> - - - - ) -}) - -export function DrawStyleEllipseSvg({ - id, - w, - h, - strokeWidth: sw, - fill, - color, - theme, -}: Pick & { - strokeWidth: number - id: TLShapeId - theme: TLDefaultColorTheme -}) { - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path') - strokeElement.setAttribute('d', getEllipsePath(id, w, h, sw)) - strokeElement.setAttribute('fill', theme[color].solid) - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d: getEllipseIndicatorPath(id, w, h, sw), - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} - -export function getEllipseStrokeOptions(strokeWidth: number) { +function getEllipseStrokeOptions(strokeWidth: number) { return { size: 1 + strokeWidth, thinning: 0.25, @@ -86,12 +14,7 @@ export function getEllipseStrokeOptions(strokeWidth: number) { } } -export function getEllipseStrokePoints( - id: string, - width: number, - height: number, - strokeWidth: number -) { +function getEllipseStrokePoints(id: string, width: number, height: number, strokeWidth: number) { const getRandom = rng(id) const rx = width / 2 @@ -125,16 +48,6 @@ export function getEllipseStrokePoints( return getStrokePoints(points, getEllipseStrokeOptions(strokeWidth)) } -export function getEllipsePath(id: string, width: number, height: number, strokeWidth: number) { - const options = getEllipseStrokeOptions(strokeWidth) - return getSvgPathFromPoints( - getStrokeOutlinePoints( - setStrokePointRadii(getEllipseStrokePoints(id, width, height, strokeWidth), options), - options - ) - ) -} - export function getEllipseIndicatorPath( id: string, width: number, diff --git a/packages/tldraw/src/lib/shapes/geo/components/DrawStylePolygon.tsx b/packages/tldraw/src/lib/shapes/geo/components/DrawStylePolygon.tsx index 2249604e0..bd73caefc 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/DrawStylePolygon.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/DrawStylePolygon.tsx @@ -1,11 +1,6 @@ -import { TLDefaultColorTheme, TLGeoShape, VecLike } from '@tldraw/editor' +import { TLGeoShape, VecLike } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' import { getRoundedInkyPolygonPath, getRoundedPolygonPoints } from '../../shared/polygon-helpers' export const DrawStylePolygon = React.memo(function DrawStylePolygon({ @@ -41,223 +36,3 @@ export const DrawStylePolygon = React.memo(function DrawStylePolygon({ ) }) - -export function DrawStylePolygonSvg({ - id, - outline, - lines, - fill, - color, - theme, - strokeWidth, -}: Pick & { - id: TLGeoShape['id'] - outline: VecLike[] - lines?: VecLike[][] - strokeWidth: number - theme: TLDefaultColorTheme -}) { - const polygonPoints = getRoundedPolygonPoints(id, outline, strokeWidth / 3, strokeWidth * 2, 2) - - let strokePathData = getRoundedInkyPolygonPath(polygonPoints) - - if (lines) { - for (const [A, B] of lines) { - strokePathData += `M${A.x},${A.y}L${B.x},${B.y}` - } - } - - const innerPolygonPoints = getRoundedPolygonPoints(id, outline, 0, strokeWidth * 2, 1) - const innerPathData = getRoundedInkyPolygonPath(innerPolygonPoints) - - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path') - strokeElement.setAttribute('d', strokePathData) - strokeElement.setAttribute('fill', 'none') - strokeElement.setAttribute('stroke', theme[color].solid) - strokeElement.setAttribute('stroke-width', strokeWidth.toString()) - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d: innerPathData, - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} - -// function getPolygonDrawPoints(id: string, outline: VecLike[], strokeWidth: number) { -// const points: Vec[] = [] - -// const getRandom = rng(id) - -// const start = Math.round(Math.abs(getRandom()) * outline.length) - -// const corners = outline.map((p) => -// Vec.AddXY(p, (getRandom() * strokeWidth) / 4, (getRandom() * strokeWidth) / 4) -// ) - -// const len = corners.length - -// for (let i = 0, n = len + 1; i < n; i++) { -// const At = corners[(start + i) % len] -// const Bt = corners[(start + i + 1) % len] - -// const dist = Math.min(Vec.Dist(At, Bt) / 2, strokeWidth / 2) -// const A = Vec.Nudge(At, Bt, dist) - -// const D = Vec.Med(At, Bt) - -// if (i === 0) { -// Bt.z = 0.7 -// points.push(new Vec(D.x, D.y, 0.7), Bt) -// } else if (i === outline.length) { -// const lastSegPoints = Vec.PointsBetween(A, D, 4) -// lastSegPoints.forEach((p) => (p.z = 0.7)) -// points.push(...lastSegPoints) -// } else { -// points.push(...Vec.PointsBetween(A, Bt, 6)) -// } -// } - -// return points -// } - -// export function getPolygonIndicatorPath(id: string, outline: VecLike[], strokeWidth: number) { -// const points = getPolygonDrawPoints(id, outline, strokeWidth) -// const options = getPolygonStrokeOptions(strokeWidth) -// const strokePoints = getStrokePoints(points, options) - -// return getSvgPathFromStrokePoints(strokePoints, true) -// } - -// function getPolygonStrokeOptions(strokeWidth: number) { -// return { -// size: 1 + strokeWidth * 0.618, -// last: true, -// simulatePressure: false, -// streamline: 0.25, -// thinning: 0.9, -// } -// } - -// function getPolygonstrokePathData(id: string, outline: VecLike[], strokeWidth: number) { -// // draw a line between all of the points -// let d = `M${outline[0].x},${outline[0].y}` -// d += 'Z' - -// for (const { x, y } of outline) { -// d += `${x},${y}` -// } - -// return d -// } - -// function SimpleInkyPolygon(id: string, outline: VecLike[], offset: number) { -// const random = rng(id) -// let p = outline[0] - -// let ox = random() * offset -// let oy = random() * offset - -// let polylineA = `M${p.x - ox},${p.y - oy}L` -// let polylineB = `${p.x + ox},${p.y + oy} ` - -// for (let i = 1, n = outline.length; i < n; i++) { -// p = outline[i] -// ox = random() * offset -// oy = random() * offset - -// polylineA += `${p.x - ox},${p.y - oy} ` -// polylineB += `${p.x + ox},${p.y + oy} ` -// } - -// polylineB += 'Z' - -// polylineA += polylineB - -// return polylineA -// } - -// function CubicInkyPolygon(id: string, outline: VecLike[], offset: number) { -// const random = rng(id) -// let p0 = outline[0] -// let p1 = p0 - -// let ox: number -// let oy: number - -// let polylineA = `M${p0.x},${p0.y} L` -// let polylineB = `M${p0.x},${p0.y}` - -// for (let i = 0, n = outline.length; i < n; i++) { -// p0 = outline[i] -// p1 = outline[(i + 1) % n] - -// polylineA += `${p1.x},${p1.y} ` - -// ox = random() * offset -// oy = random() * offset -// const c1 = Vec.Lrp(p0, p1, 0.25) -// const c2 = Vec.Lrp(p0, p1, 0.75) - -// polylineB += `C${c1.x + ox},${c1.y + oy} ${c2.x - ox},${c2.y - oy} ${p1.x},${p1.y}` -// } - -// polylineB += 'Z' - -// polylineA += polylineB - -// return polylineA -// } - -// function QuadraticInkyPolygon(id: string, outline: VecLike[], offset: number) { -// const random = rng(id) -// let p0 = outline[0] -// let p1 = p0 - -// let polylineA = `M${p0.x},${p0.y} Q` - -// const len = outline.length - -// for (let i = 0, n = len * 2; i < n; i++) { -// p0 = outline[i % len] -// p1 = outline[(i + 1) % len] -// const dist = Vec.Dist(p0, p1) - -// const c1 = Vec.Lrp(p0, p1, 0.5 + random() / 2) -// polylineA += `${c1.x + random() * Math.min(dist / 10, offset)},${ -// c1.y + random() * Math.min(dist / 10, offset) -// } ${p1.x + (random() * offset) / 2},${p1.y + (random() * offset) / 2} ` -// } - -// polylineA += 'Z' - -// return polylineA -// } - -// function GlobyInkyPolygon(id: string, outline: VecLike[], offset: number) { -// const random = rng(id) -// let p0 = outline[0] -// let p1 = p0 - -// let polylineA = `M${p0.x},${p0.y} Q` - -// const len = outline.length - -// for (let i = 0, n = len * 2; i < n; i++) { -// p0 = outline[i % len] -// p1 = outline[(i + 1) % len] -// const dist = Vec.Dist(p0, p1) - -// const c1 = Vec.Lrp(p0, p1, 0.5 + random() / 2) -// polylineA += `${c1.x + random() * Math.min(dist / 10, offset)},${ -// c1.y + random() * Math.min(dist / 10, offset) -// } ${p1.x + (random() * offset) / 2},${p1.y + (random() * offset) / 2} ` -// } - -// polylineA += 'Z' - -// return polylineA -// } diff --git a/packages/tldraw/src/lib/shapes/geo/components/GeoShapeBody.tsx b/packages/tldraw/src/lib/shapes/geo/components/GeoShapeBody.tsx new file mode 100644 index 000000000..e5fe7e959 --- /dev/null +++ b/packages/tldraw/src/lib/shapes/geo/components/GeoShapeBody.tsx @@ -0,0 +1,146 @@ +import { Group2d, TLGeoShape, useEditor } from '@tldraw/editor' +import { STROKE_SIZES } from '../../shared/default-shape-constants' +import { getLines } from '../getLines' +import { DashStyleCloud } from './DashStyleCloud' +import { DashStyleEllipse } from './DashStyleEllipse' +import { DashStyleOval } from './DashStyleOval' +import { DashStylePolygon } from './DashStylePolygon' +import { DrawStyleCloud } from './DrawStyleCloud' +import { DrawStylePolygon } from './DrawStylePolygon' +import { SolidStyleCloud } from './SolidStyleCloud' +import { SolidStyleEllipse } from './SolidStyleEllipse' +import { SolidStyleOval } from './SolidStyleOval' +import { SolidStylePolygon } from './SolidStylePolygon' + +export function GeoShapeBody({ shape }: { shape: TLGeoShape }) { + const editor = useEditor() + const { id, props } = shape + const { w, color, fill, dash, growY, size } = props + const strokeWidth = STROKE_SIZES[size] + const h = props.h + growY + + switch (props.geo) { + case 'cloud': { + if (dash === 'solid') { + return ( + + ) + } else if (dash === 'dashed' || dash === 'dotted') { + return ( + + ) + } else if (dash === 'draw') { + return ( + + ) + } + + break + } + case 'ellipse': { + if (dash === 'solid') { + return + } else if (dash === 'dashed' || dash === 'dotted') { + return ( + + ) + } else if (dash === 'draw') { + return + } + break + } + case 'oval': { + if (dash === 'solid') { + return + } else if (dash === 'dashed' || dash === 'dotted') { + return ( + + ) + } else if (dash === 'draw') { + return + } + break + } + default: { + const geometry = editor.getShapeGeometry(shape) + const outline = + geometry instanceof Group2d ? geometry.children[0].vertices : geometry.vertices + const lines = getLines(shape.props, strokeWidth) + + if (dash === 'solid') { + return ( + + ) + } else if (dash === 'dashed' || dash === 'dotted') { + return ( + + ) + } else if (dash === 'draw') { + return ( + + ) + } + } + } +} diff --git a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleCloud.tsx b/packages/tldraw/src/lib/shapes/geo/components/SolidStyleCloud.tsx index f575e3569..3f36a4317 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleCloud.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/SolidStyleCloud.tsx @@ -1,11 +1,6 @@ -import { TLDefaultColorTheme, TLGeoShape, TLShapeId } from '@tldraw/editor' +import { TLGeoShape, TLShapeId } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' import { cloudSvgPath } from '../cloudOutline' export const SolidStyleCloud = React.memo(function SolidStyleCloud({ @@ -30,36 +25,3 @@ export const SolidStyleCloud = React.memo(function SolidStyleCloud({ ) }) - -export function SolidStyleCloudSvg({ - fill, - color, - strokeWidth, - theme, - w, - h, - id, - size, -}: Pick & { - strokeWidth: number - theme: TLDefaultColorTheme - id: TLShapeId -}) { - const pathData = cloudSvgPath(w, h, id, size) - - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path') - strokeElement.setAttribute('d', pathData) - strokeElement.setAttribute('stroke-width', strokeWidth.toString()) - strokeElement.setAttribute('stroke', theme[color].solid) - strokeElement.setAttribute('fill', 'none') - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d: pathData, - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleEllipse.tsx b/packages/tldraw/src/lib/shapes/geo/components/SolidStyleEllipse.tsx index 145c06b8a..e9f30b170 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleEllipse.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/SolidStyleEllipse.tsx @@ -1,11 +1,6 @@ -import { TLDefaultColorTheme, TLGeoShape } from '@tldraw/editor' +import { TLGeoShape } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' export const SolidStyleEllipse = React.memo(function SolidStyleEllipse({ w, @@ -29,40 +24,3 @@ export const SolidStyleEllipse = React.memo(function SolidStyleEllipse({ ) }) - -export function SolidStyleEllipseSvg({ - w, - h, - strokeWidth: sw, - fill, - color, - theme, -}: Pick & { - strokeWidth: number - theme: TLDefaultColorTheme -}) { - const cx = w / 2 - const cy = h / 2 - const rx = Math.max(0, cx) - const ry = Math.max(0, cy) - - const d = `M${cx - rx},${cy}a${rx},${ry},0,1,1,${rx * 2},0a${rx},${ry},0,1,1,-${rx * 2},0` - - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path') - strokeElement.setAttribute('d', d) - strokeElement.setAttribute('stroke-width', sw.toString()) - strokeElement.setAttribute('width', w.toString()) - strokeElement.setAttribute('height', h.toString()) - strokeElement.setAttribute('fill', 'none') - strokeElement.setAttribute('stroke', theme[color].solid) - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d, - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleOval.tsx b/packages/tldraw/src/lib/shapes/geo/components/SolidStyleOval.tsx index c8755aa82..9c7a1a20c 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleOval.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/SolidStyleOval.tsx @@ -1,11 +1,6 @@ -import { TLDefaultColorTheme, TLGeoShape } from '@tldraw/editor' +import { TLGeoShape } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' export const SolidStyleOval = React.memo(function SolidStyleOval({ w, @@ -26,37 +21,6 @@ export const SolidStyleOval = React.memo(function SolidStyleOval({ ) }) -export function SolidStyleOvalSvg({ - w, - h, - strokeWidth: sw, - fill, - color, - theme, -}: Pick & { - strokeWidth: number - theme: TLDefaultColorTheme -}) { - const d = getOvalIndicatorPath(w, h) - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path') - strokeElement.setAttribute('d', d) - strokeElement.setAttribute('stroke-width', sw.toString()) - strokeElement.setAttribute('width', w.toString()) - strokeElement.setAttribute('height', h.toString()) - strokeElement.setAttribute('fill', 'none') - strokeElement.setAttribute('stroke', theme[color].solid) - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d, - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} - export function getOvalIndicatorPath(w: number, h: number) { let d: string diff --git a/packages/tldraw/src/lib/shapes/geo/components/SolidStylePolygon.tsx b/packages/tldraw/src/lib/shapes/geo/components/SolidStylePolygon.tsx index 1d8c77b1b..0b156d032 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/SolidStylePolygon.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/SolidStylePolygon.tsx @@ -1,11 +1,6 @@ -import { TLDefaultColorTheme, TLGeoShape, VecLike } from '@tldraw/editor' +import { TLGeoShape, VecLike } from '@tldraw/editor' import * as React from 'react' -import { - ShapeFill, - getShapeFillSvg, - getSvgWithShapeFill, - useDefaultColorTheme, -} from '../../shared/ShapeFill' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' export const SolidStylePolygon = React.memo(function SolidStylePolygon({ outline, @@ -34,44 +29,3 @@ export const SolidStylePolygon = React.memo(function SolidStylePolygon({ ) }) - -export function SolidStylePolygonSvg({ - outline, - lines, - fill, - color, - strokeWidth, - theme, -}: Pick & { - outline: VecLike[] - strokeWidth: number - theme: TLDefaultColorTheme - lines?: VecLike[][] -}) { - const pathData = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z' - - const fillPathData = pathData - let strokePathData = pathData - - if (lines) { - for (const [A, B] of lines) { - strokePathData += `M${A.x},${A.y}L${B.x},${B.y}` - } - } - - const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path') - strokeElement.setAttribute('d', strokePathData) - strokeElement.setAttribute('stroke-width', strokeWidth.toString()) - strokeElement.setAttribute('stroke', theme[color].solid) - strokeElement.setAttribute('fill', 'none') - - // Get the fill element, if any - const fillElement = getShapeFillSvg({ - d: fillPathData, - fill, - color, - theme, - }) - - return getSvgWithShapeFill(strokeElement, fillElement) -} diff --git a/packages/tldraw/src/lib/shapes/geo/getLines.tsx b/packages/tldraw/src/lib/shapes/geo/getLines.tsx new file mode 100644 index 000000000..af97dc8e7 --- /dev/null +++ b/packages/tldraw/src/lib/shapes/geo/getLines.tsx @@ -0,0 +1,60 @@ +import { TLDefaultDashStyle, TLGeoShape, Vec } from '@tldraw/editor' + +export function getLines(props: TLGeoShape['props'], sw: number) { + switch (props.geo) { + case 'x-box': { + return getXBoxLines(props.w, props.h, sw, props.dash) + } + case 'check-box': { + return getCheckBoxLines(props.w, props.h) + } + default: { + return undefined + } + } +} +function getXBoxLines(w: number, h: number, sw: number, dash: TLDefaultDashStyle) { + const inset = dash === 'draw' ? 0.62 : 0 + + if (dash === 'dashed') { + return [ + [new Vec(0, 0), new Vec(w / 2, h / 2)], + [new Vec(w, h), new Vec(w / 2, h / 2)], + [new Vec(0, h), new Vec(w / 2, h / 2)], + [new Vec(w, 0), new Vec(w / 2, h / 2)], + ] + } + + const clampX = (x: number) => Math.max(0, Math.min(w, x)) + const clampY = (y: number) => Math.max(0, Math.min(h, y)) + + return [ + [ + new Vec(clampX(sw * inset), clampY(sw * inset)), + new Vec(clampX(w - sw * inset), clampY(h - sw * inset)), + ], + [ + new Vec(clampX(sw * inset), clampY(h - sw * inset)), + new Vec(clampX(w - sw * inset), clampY(sw * inset)), + ], + ] +} +function getCheckBoxLines(w: number, h: number) { + const size = Math.min(w, h) * 0.82 + const ox = (w - size) / 2 + const oy = (h - size) / 2 + + const clampX = (x: number) => Math.max(0, Math.min(w, x)) + const clampY = (y: number) => Math.max(0, Math.min(h, y)) + + return [ + [ + new Vec(clampX(ox + size * 0.25), clampY(oy + size * 0.52)), + new Vec(clampX(ox + size * 0.45), clampY(oy + size * 0.82)), + ], + [ + new Vec(clampX(ox + size * 0.45), clampY(oy + size * 0.82)), + new Vec(clampX(ox + size * 0.82), clampY(oy + size * 0.22)), + ], + ] +} diff --git a/packages/tldraw/src/lib/shapes/highlight/HighlightShapeUtil.tsx b/packages/tldraw/src/lib/shapes/highlight/HighlightShapeUtil.tsx index 5064dbeab..771400cc1 100644 --- a/packages/tldraw/src/lib/shapes/highlight/HighlightShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/highlight/HighlightShapeUtil.tsx @@ -4,13 +4,10 @@ import { Polygon2d, SVGContainer, ShapeUtil, - SvgExportContext, - TLDefaultColorTheme, TLDrawShapeSegment, TLHighlightShape, TLOnResizeHandler, VecLike, - getDefaultColorTheme, highlightShapeMigrations, highlightShapeProps, last, @@ -72,21 +69,17 @@ export class HighlightShapeUtil extends ShapeUtil { component(shape: TLHighlightShape) { return ( - + + + ) } override backgroundComponent(shape: TLHighlightShape) { return ( - + + + ) } @@ -117,14 +110,24 @@ export class HighlightShapeUtil extends ShapeUtil { return } - override toSvg(shape: TLHighlightShape, ctx: SvgExportContext) { - const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }) - return highlighterToSvg(getStrokeWidth(shape), shape, OVERLAY_OPACITY, theme) + override toSvg(shape: TLHighlightShape) { + return ( + + ) } override toBackgroundSvg(shape: TLHighlightShape) { - const theme = getDefaultColorTheme({ isDarkMode: this.editor.user.getIsDarkMode() }) - return highlighterToSvg(getStrokeWidth(shape), shape, UNDERLAY_OPACITY, theme) + return ( + + ) } override onResize: TLOnResizeHandler = (shape, info) => { @@ -216,37 +219,18 @@ function HighlightRenderer({ const color = theme[shape.props.color].highlight[colorSpace] return ( - - - + ) } -function highlighterToSvg( - strokeWidth: number, - shape: TLHighlightShape, - opacity: number, - theme: TLDefaultColorTheme -) { - const { solidStrokePath, sw } = getHighlightSvgPath(shape, strokeWidth, false) - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path.setAttribute('d', solidStrokePath) - path.setAttribute('fill', 'none') - path.setAttribute('stroke', theme[shape.props.color].highlight.srgb) - path.setAttribute('stroke-width', `${sw}`) - path.setAttribute('opacity', `${opacity}`) - - return path -} - function getStrokeWidth(shape: TLHighlightShape) { return FONT_SIZES[shape.props.size] * 1.12 } diff --git a/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx b/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx index 28dea94ea..8a7a05ddf 100644 --- a/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx @@ -171,10 +171,9 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { } override async toSvg(shape: TLImageShape) { - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : null - if (!asset) return g + if (!asset) return null let src = asset?.props.src || '' if (src.startsWith('http') || src.startsWith('/') || src.startsWith('./')) { @@ -182,8 +181,6 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { src = (await getDataURIFromURL(src)) || '' } - const image = document.createElementNS('http://www.w3.org/2000/svg', 'image') - image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', src) const containerStyle = getCroppedContainerStyle(shape) const crop = shape.props.crop if (containerStyle.transform && crop) { @@ -198,31 +195,22 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { new Vec(0, croppedHeight), ] - const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') - polygon.setAttribute('points', points.map((p) => `${p.x},${p.y}`).join(' ')) - - const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath') - clipPath.setAttribute('id', 'cropClipPath') - clipPath.appendChild(polygon) - - const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs') - defs.appendChild(clipPath) - g.appendChild(defs) - - const innerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') - innerElement.setAttribute('clip-path', 'url(#cropClipPath)') - image.setAttribute('width', width.toString()) - image.setAttribute('height', height.toString()) - image.style.transform = transform - innerElement.appendChild(image) - g.appendChild(innerElement) + const cropClipId = `cropClipPath_${shape.id.replace(':', '_')}` + return ( + <> + + + `${p.x},${p.y}`).join(' ')} /> + + + + + + + ) } else { - image.setAttribute('width', shape.props.w.toString()) - image.setAttribute('height', shape.props.h.toString()) - g.appendChild(image) + return } - - return g } override onDoubleClick = (shape: TLImageShape) => { diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx index 635c393f9..03158d50e 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/rules-of-hooks */ import { CubicSpline2d, Group2d, @@ -6,14 +5,12 @@ import { Polyline2d, SVGContainer, ShapeUtil, - SvgExportContext, TLHandle, TLLineShape, TLOnHandleDragHandler, TLOnResizeHandler, Vec, WeakMapCache, - getDefaultColorTheme, getIndexBetween, getIndices, lineShapeMigrations, @@ -29,7 +26,6 @@ import { getDrawLinePathData } from '../shared/polygon-helpers' import { getLineDrawPath, getLineIndicatorPath } from './components/getLinePath' import { getSvgPathForBezierCurve, - getSvgPathForCubicSpline, getSvgPathForEdge, getSvgPathForLineGeometry, } from './components/svg' @@ -130,139 +126,11 @@ export class LineShapeUtil extends ShapeUtil { } component(shape: TLLineShape) { - const theme = useDefaultColorTheme() - const spline = getGeometryForLineShape(shape) - const strokeWidth = STROKE_SIZES[shape.props.size] - - const { dash, color } = shape.props - - // Line style lines - if (shape.props.spline === 'line') { - if (dash === 'solid') { - const outline = spline.points - const pathData = 'M' + outline[0] + 'L' + outline.slice(1) - - return ( - - - - - ) - } - - if (dash === 'dashed' || dash === 'dotted') { - const outline = spline.points - const pathData = 'M' + outline[0] + 'L' + outline.slice(1) - - return ( - - - - {spline.segments.map((segment, i) => { - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - segment.length, - strokeWidth, - { - style: dash, - start: i > 0 ? 'outset' : 'none', - end: i < spline.segments.length - 1 ? 'outset' : 'none', - } - ) - - return ( - - ) - })} - - - ) - } - - if (dash === 'draw') { - const outline = spline.points - const [innerPathData, outerPathData] = getDrawLinePathData(shape.id, outline, strokeWidth) - - return ( - - - - - ) - } - } - // Cubic style spline - if (shape.props.spline === 'cubic') { - const splinePath = getSvgPathForLineGeometry(spline) - if (dash === 'solid') { - return ( - - - - - ) - } - - if (dash === 'dashed' || dash === 'dotted') { - return ( - - - - {spline.segments.map((segment, i) => { - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - segment.length, - strokeWidth, - { - style: dash, - start: i > 0 ? 'outset' : 'none', - end: i < spline.segments.length - 1 ? 'outset' : 'none', - } - ) - - return ( - - ) - })} - - - ) - } - - if (dash === 'draw') { - return ( - - - - - ) - } - } + return ( + + + + ) } indicator(shape: TLLineShape) { @@ -287,79 +155,8 @@ export class LineShapeUtil extends ShapeUtil { return } - override toSvg(shape: TLLineShape, ctx: SvgExportContext) { - const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }) - const color = theme[shape.props.color].solid - const spline = getGeometryForLineShape(shape) - const strokeWidth = STROKE_SIZES[shape.props.size] - - switch (shape.props.dash) { - case 'draw': { - let pathData: string - if (spline instanceof CubicSpline2d) { - pathData = getLineDrawPath(shape, spline, strokeWidth) - } else { - const [_, outerPathData] = getDrawLinePathData(shape.id, spline.points, strokeWidth) - pathData = outerPathData - } - - const p = document.createElementNS('http://www.w3.org/2000/svg', 'path') - p.setAttribute('stroke-width', strokeWidth + 'px') - p.setAttribute('stroke', color) - p.setAttribute('fill', 'none') - p.setAttribute('d', pathData) - - return p - } - case 'solid': { - let pathData: string - - if (spline instanceof CubicSpline2d) { - pathData = getSvgPathForCubicSpline(spline, false) - } else { - const outline = spline.points - pathData = 'M' + outline[0] + 'L' + outline.slice(1) - } - - const p = document.createElementNS('http://www.w3.org/2000/svg', 'path') - p.setAttribute('stroke-width', strokeWidth + 'px') - p.setAttribute('stroke', color) - p.setAttribute('fill', 'none') - p.setAttribute('d', pathData) - - return p - } - default: { - const { segments } = spline - - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - g.setAttribute('stroke', color) - g.setAttribute('stroke-width', strokeWidth.toString()) - - const fn = spline instanceof CubicSpline2d ? getSvgPathForBezierCurve : getSvgPathForEdge - - segments.forEach((segment, i) => { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - segment.length, - strokeWidth, - { - style: shape.props.dash, - start: i > 0 ? 'outset' : 'none', - end: i < segments.length - 1 ? 'outset' : 'none', - } - ) - - path.setAttribute('stroke-dasharray', strokeDasharray.toString()) - path.setAttribute('stroke-dashoffset', strokeDashoffset.toString()) - path.setAttribute('d', fn(segment as any, true)) - path.setAttribute('fill', 'none') - g.appendChild(path) - }) - - return g - } - } + override toSvg(shape: TLLineShape) { + return } override getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry { @@ -411,3 +208,134 @@ export function getGeometryForLineShape(shape: TLLineShape): CubicSpline2d | Pol } } } + +function LineShapeSvg({ shape }: { shape: TLLineShape }) { + const theme = useDefaultColorTheme() + const spline = getGeometryForLineShape(shape) + const strokeWidth = STROKE_SIZES[shape.props.size] + + const { dash, color } = shape.props + + // Line style lines + if (shape.props.spline === 'line') { + if (dash === 'solid') { + const outline = spline.points + const pathData = 'M' + outline[0] + 'L' + outline.slice(1) + + return ( + <> + + + + ) + } + + if (dash === 'dashed' || dash === 'dotted') { + const outline = spline.points + const pathData = 'M' + outline[0] + 'L' + outline.slice(1) + + return ( + <> + + + {spline.segments.map((segment, i) => { + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + segment.length, + strokeWidth, + { + style: dash, + start: i > 0 ? 'outset' : 'none', + end: i < spline.segments.length - 1 ? 'outset' : 'none', + } + ) + + return ( + + ) + })} + + + ) + } + + if (dash === 'draw') { + const outline = spline.points + const [innerPathData, outerPathData] = getDrawLinePathData(shape.id, outline, strokeWidth) + + return ( + <> + + + + ) + } + } + // Cubic style spline + if (shape.props.spline === 'cubic') { + const splinePath = getSvgPathForLineGeometry(spline) + if (dash === 'solid') { + return ( + <> + + + + ) + } + + if (dash === 'dashed' || dash === 'dotted') { + return ( + <> + + + {spline.segments.map((segment, i) => { + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + segment.length, + strokeWidth, + { + style: dash, + start: i > 0 ? 'outset' : 'none', + end: i < spline.segments.length - 1 ? 'outset' : 'none', + } + ) + + return ( + + ) + })} + + + ) + } + + if (dash === 'draw') { + return ( + <> + + + + ) + } + } +} diff --git a/packages/tldraw/src/lib/shapes/line/components/getLinePath.ts b/packages/tldraw/src/lib/shapes/line/components/getLinePath.ts index 078dc8fc4..54a31269f 100644 --- a/packages/tldraw/src/lib/shapes/line/components/getLinePath.ts +++ b/packages/tldraw/src/lib/shapes/line/components/getLinePath.ts @@ -5,7 +5,7 @@ import { setStrokePointRadii } from '../../shared/freehand/setStrokePointRadii' import { getSvgPathFromStrokePoints } from '../../shared/freehand/svg' import { getSvgPathForLineGeometry } from './svg' -export function getLineDrawFreehandOptions(strokeWidth: number) { +function getLineDrawFreehandOptions(strokeWidth: number) { return { size: strokeWidth, thinning: 0.4, @@ -16,18 +16,7 @@ export function getLineDrawFreehandOptions(strokeWidth: number) { } } -export function getLineSolidFreehandOptions(strokeWidth: number) { - return { - size: strokeWidth, - thinning: 0, - streamline: 0, - smoothing: 0.5, - simulatePressure: false, - last: true, - } -} - -export function getLineStrokePoints( +function getLineStrokePoints( shape: TLLineShape, spline: CubicSpline2d | Polyline2d, strokeWidth: number @@ -38,7 +27,7 @@ export function getLineStrokePoints( return getStrokePoints(points, options) } -export function getLineDrawStrokeOutlinePoints( +function getLineDrawStrokeOutlinePoints( shape: TLLineShape, spline: CubicSpline2d | Polyline2d, strokeWidth: number @@ -50,15 +39,6 @@ export function getLineDrawStrokeOutlinePoints( ) } -export function getLineSolidStrokeOutlinePoints( - shape: TLLineShape, - spline: CubicSpline2d | Polyline2d, - strokeWidth: number -) { - const options = getLineSolidFreehandOptions(strokeWidth) - return getStrokeOutlinePoints(getLineStrokePoints(shape, spline, strokeWidth), options) -} - export function getLineDrawPath( shape: TLLineShape, spline: CubicSpline2d | Polyline2d, @@ -68,15 +48,6 @@ export function getLineDrawPath( return getSvgPathFromPoints(stroke) } -export function getLineSolidPath( - shape: TLLineShape, - spline: CubicSpline2d | Polyline2d, - strokeWidth: number -) { - const outlinePoints = getLineSolidStrokeOutlinePoints(shape, spline, strokeWidth) - return getSvgPathFromPoints(outlinePoints) -} - export function getLineIndicatorPath( shape: TLLineShape, spline: CubicSpline2d | Polyline2d, diff --git a/packages/tldraw/src/lib/shapes/line/components/svg.ts b/packages/tldraw/src/lib/shapes/line/components/svg.ts index 9ff1b01f7..78183cb6f 100644 --- a/packages/tldraw/src/lib/shapes/line/components/svg.ts +++ b/packages/tldraw/src/lib/shapes/line/components/svg.ts @@ -29,7 +29,7 @@ export function getSvgPathForBezierCurve(curve: CubicBezier2d, first: boolean) { )},${toDomPrecision(d.y)}` } -export function getSvgPathForCubicSpline(spline: CubicSpline2d, isClosed: boolean) { +function getSvgPathForCubicSpline(spline: CubicSpline2d, isClosed: boolean) { let d = spline.segments.reduce((d, segment, i) => { return d + getSvgPathForBezierCurve(segment, i === 0) }, '') @@ -41,7 +41,7 @@ export function getSvgPathForCubicSpline(spline: CubicSpline2d, isClosed: boolea return d } -export function getSvgPathForPolylineSpline(spline: Polyline2d, isClosed: boolean) { +function getSvgPathForPolylineSpline(spline: Polyline2d, isClosed: boolean) { let d = spline.segments.reduce((d, segment, i) => { return d + getSvgPathForEdge(segment, i === 0) }, '') diff --git a/packages/tldraw/src/lib/shapes/note/NoteShapeUtil.tsx b/packages/tldraw/src/lib/shapes/note/NoteShapeUtil.tsx index d4446a94b..131a2f7d3 100644 --- a/packages/tldraw/src/lib/shapes/note/NoteShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/note/NoteShapeUtil.tsx @@ -1,5 +1,4 @@ import { - DefaultFontFamilies, Editor, Rectangle2d, ShapeUtil, @@ -13,10 +12,10 @@ import { } from '@tldraw/editor' import { HyperlinkButton } from '../shared/HyperlinkButton' import { useDefaultColorTheme } from '../shared/ShapeFill' +import { SvgTextLabel } from '../shared/SvgTextLabel' import { TextLabel } from '../shared/TextLabel' import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants' import { getFontDefForExport } from '../shared/defaultStyleDefs' -import { getTextLabelSvgElement } from '../shared/getTextLabelSvgElement' const NOTE_SIZE = 200 @@ -112,42 +111,34 @@ export class NoteShapeUtil extends ShapeUtil { override toSvg(shape: TLNoteShape, ctx: SvgExportContext) { ctx.addExportDef(getFontDefForExport(shape.props.font)) + if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font)) const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }) const bounds = this.editor.getShapeGeometry(shape).bounds - - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - const adjustedColor = shape.props.color === 'black' ? 'yellow' : shape.props.color - const rect1 = document.createElementNS('http://www.w3.org/2000/svg', 'rect') - rect1.setAttribute('rx', '10') - rect1.setAttribute('width', NOTE_SIZE.toString()) - rect1.setAttribute('height', bounds.height.toString()) - rect1.setAttribute('fill', theme[adjustedColor].solid) - rect1.setAttribute('stroke', theme[adjustedColor].solid) - rect1.setAttribute('stroke-width', '1') - g.appendChild(rect1) - - const rect2 = document.createElementNS('http://www.w3.org/2000/svg', 'rect') - rect2.setAttribute('rx', '10') - rect2.setAttribute('width', NOTE_SIZE.toString()) - rect2.setAttribute('height', bounds.height.toString()) - rect2.setAttribute('fill', theme.background) - rect2.setAttribute('opacity', '.28') - g.appendChild(rect2) - - const textElm = getTextLabelSvgElement({ - editor: this.editor, - shape, - font: DefaultFontFamilies[shape.props.font], - bounds, - }) - - textElm.setAttribute('fill', theme.text) - textElm.setAttribute('stroke', 'none') - g.appendChild(textElm) - - return g + return ( + <> + + + + + ) } override onBeforeCreate = (next: TLNoteShape) => { diff --git a/packages/tldraw/src/lib/shapes/shared/ShapeFill.tsx b/packages/tldraw/src/lib/shapes/shared/ShapeFill.tsx index 0c94fd8ad..abf831b0e 100644 --- a/packages/tldraw/src/lib/shapes/shared/ShapeFill.tsx +++ b/packages/tldraw/src/lib/shapes/shared/ShapeFill.tsx @@ -6,6 +6,7 @@ import { getDefaultColorTheme, useEditor, useIsDarkMode, + useSvgExportContext, useValue, } from '@tldraw/editor' import React from 'react' @@ -40,6 +41,7 @@ export const ShapeFill = React.memo(function ShapeFill({ theme, d, color, fill } const PatternFill = function PatternFill({ d, color, theme }: ShapeFillProps) { const editor = useEditor() + const svgExport = useSvgExportContext() const zoomLevel = useValue('zoomLevel', () => editor.getZoomLevel(), [editor]) const intZoom = Math.ceil(zoomLevel) @@ -50,64 +52,14 @@ const PatternFill = function PatternFill({ d, color, theme }: ShapeFillProps) { ) } - -export function getShapeFillSvg({ d, color, fill, theme }: ShapeFillProps) { - if (fill === 'none') { - return - } - - if (fill === 'pattern') { - const gEl = document.createElementNS('http://www.w3.org/2000/svg', 'g') - const path1El = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path1El.setAttribute('d', d) - path1El.setAttribute('fill', theme[color].pattern) - - const path2El = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path2El.setAttribute('d', d) - path2El.setAttribute('fill', `url(#hash_pattern)`) - - gEl.appendChild(path1El) - gEl.appendChild(path2El) - return gEl - } - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path.setAttribute('d', d) - - switch (fill) { - case 'semi': { - path.setAttribute('fill', theme.solid) - break - } - case 'solid': { - { - path.setAttribute('fill', theme[color].semi) - } - break - } - } - - return path -} - -export function getSvgWithShapeFill(foregroundPath: SVGElement, backgroundPath?: SVGElement) { - if (backgroundPath) { - // If there is a fill element, return a group containing the fill and the path - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - g.appendChild(backgroundPath) - g.appendChild(foregroundPath) - return g - } else { - // Otherwise, just return the path - return foregroundPath - } -} diff --git a/packages/tldraw/src/lib/shapes/shared/SvgTextLabel.tsx b/packages/tldraw/src/lib/shapes/shared/SvgTextLabel.tsx new file mode 100644 index 000000000..4b1838011 --- /dev/null +++ b/packages/tldraw/src/lib/shapes/shared/SvgTextLabel.tsx @@ -0,0 +1,85 @@ +import { + Box, + DefaultFontFamilies, + TLDefaultColorStyle, + TLDefaultFontStyle, + TLDefaultHorizontalAlignStyle, + TLDefaultVerticalAlignStyle, + useEditor, +} from '@tldraw/editor' +import { useDefaultColorTheme } from './ShapeFill' +import { createTextJsxFromSpans } from './createTextJsxFromSpans' +import { TEXT_PROPS } from './default-shape-constants' +import { getLegacyOffsetX } from './legacyProps' + +export function SvgTextLabel({ + fontSize, + font, + align, + verticalAlign, + text, + labelColor, + bounds, + padding = 16, + stroke = true, +}: { + fontSize: number + font: TLDefaultFontStyle + // fill?: TLDefaultFillStyle + align: TLDefaultHorizontalAlignStyle + verticalAlign: TLDefaultVerticalAlignStyle + wrap?: boolean + text: string + labelColor: TLDefaultColorStyle + bounds: Box + padding?: number + stroke?: boolean +}) { + const editor = useEditor() + const theme = useDefaultColorTheme() + + const opts = { + fontSize, + fontFamily: DefaultFontFamilies[font], + textAlign: align, + verticalTextAlign: verticalAlign, + width: Math.ceil(bounds.width), + height: Math.ceil(bounds.height), + padding, + lineHeight: TEXT_PROPS.lineHeight, + fontStyle: 'normal', + fontWeight: 'normal', + overflow: 'wrap' as const, + offsetX: 0, + offsetY: 0, + fill: theme[labelColor].solid, + stroke: undefined as string | undefined, + strokeWidth: undefined as number | undefined, + } + + const spans = editor.textMeasure.measureTextSpans(text, opts) + const offsetX = getLegacyOffsetX(align, padding, spans, bounds.width) + if (offsetX) { + opts.offsetX = offsetX + } + + opts.offsetX += bounds.x + opts.offsetY += bounds.y + + const mainSpans = createTextJsxFromSpans(editor, spans, opts) + + let outlineSpans = null + if (stroke) { + opts.fill = theme.background + opts.stroke = theme.background + opts.strokeWidth = 2 + outlineSpans = createTextJsxFromSpans(editor, spans, opts) + } + + return ( + <> + {outlineSpans} + {mainSpans} + + ) +} diff --git a/packages/tldraw/src/lib/shapes/shared/createTextJsxFromSpans.tsx b/packages/tldraw/src/lib/shapes/shared/createTextJsxFromSpans.tsx new file mode 100644 index 000000000..ad3528e03 --- /dev/null +++ b/packages/tldraw/src/lib/shapes/shared/createTextJsxFromSpans.tsx @@ -0,0 +1,101 @@ +import { + Box, + BoxModel, + Editor, + TLDefaultHorizontalAlignStyle, + TLDefaultVerticalAlignStyle, +} from '@tldraw/editor' + +function correctSpacesToNbsp(input: string) { + return input.replace(/\s/g, '\xa0') +} + +/** Get an SVG element for a text shape. */ +export function createTextJsxFromSpans( + editor: Editor, + spans: { text: string; box: BoxModel }[], + opts: { + fontSize: number + fontFamily: string + textAlign: TLDefaultHorizontalAlignStyle + verticalTextAlign: TLDefaultVerticalAlignStyle + fontWeight: string + fontStyle: string + width: number + height: number + stroke?: string + strokeWidth?: number + fill?: string + padding?: number + offsetX?: number + offsetY?: number + } +) { + const { padding = 0 } = opts + if (spans.length === 0) return null + + const bounds = Box.From(spans[0].box) + for (const { box } of spans) { + bounds.union(box) + } + + const offsetX = padding + (opts.offsetX ?? 0) + const offsetY = + (opts.offsetY ?? 0) + + opts.fontSize / 2 + + (opts.verticalTextAlign === 'start' + ? padding + : opts.verticalTextAlign === 'end' + ? opts.height - padding - bounds.height + : (Math.ceil(opts.height) - bounds.height) / 2) + + // Create text span elements for each word + let currentLineTop = null + const children = [] + for (const { text, box } of spans) { + // if we broke a line, add a line break span. This helps tools like + // figma import our exported svg correctly + const didBreakLine = currentLineTop !== null && box.y > currentLineTop + if (didBreakLine) { + children.push( + + {'\n'} + + ) + } + + children.push( + + {correctSpacesToNbsp(text)} + + ) + + currentLineTop = box.y + } + + return ( + + {children} + + ) +} diff --git a/packages/tldraw/src/lib/shapes/shared/createTextSvgElementFromSpans.ts b/packages/tldraw/src/lib/shapes/shared/createTextSvgElementFromSpans.ts deleted file mode 100644 index 2c85dcc0b..000000000 --- a/packages/tldraw/src/lib/shapes/shared/createTextSvgElementFromSpans.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - Box, - BoxModel, - Editor, - TLDefaultHorizontalAlignStyle, - TLDefaultVerticalAlignStyle, -} from '@tldraw/editor' - -function correctSpacesToNbsp(input: string) { - return input.replace(/\s/g, '\xa0') -} - -/** Get an SVG element for a text shape. */ -export function createTextSvgElementFromSpans( - editor: Editor, - spans: { text: string; box: BoxModel }[], - opts: { - fontSize: number - fontFamily: string - textAlign: TLDefaultHorizontalAlignStyle - verticalTextAlign: TLDefaultVerticalAlignStyle - fontWeight: string - fontStyle: string - lineHeight: number - width: number - height: number - stroke?: string - strokeWidth?: number - fill?: string - padding?: number - offsetX?: number - offsetY?: number - } -) { - const { padding = 0 } = opts - - // Create the text element - const textElm = document.createElementNS('http://www.w3.org/2000/svg', 'text') - textElm.setAttribute('font-size', opts.fontSize + 'px') - textElm.setAttribute('font-family', opts.fontFamily) - textElm.setAttribute('font-style', opts.fontStyle) - textElm.setAttribute('font-weight', opts.fontWeight) - textElm.setAttribute('line-height', opts.lineHeight * opts.fontSize + 'px') - textElm.setAttribute('dominant-baseline', 'mathematical') - textElm.setAttribute('alignment-baseline', 'mathematical') - - if (spans.length === 0) return textElm - - const bounds = Box.From(spans[0].box) - for (const { box } of spans) { - bounds.union(box) - } - - const offsetX = padding + (opts.offsetX ?? 0) - // const offsetY = (Math.ceil(opts.height) - bounds.height + opts.fontSize) / 2 + (opts.offsetY ?? 0) - const offsetY = - (opts.offsetY ?? 0) + - opts.fontSize / 2 + - (opts.verticalTextAlign === 'start' - ? padding - : opts.verticalTextAlign === 'end' - ? opts.height - padding - bounds.height - : (Math.ceil(opts.height) - bounds.height) / 2) - - // Create text span elements for each word - let currentLineTop = null - for (const { text, box } of spans) { - // if we broke a line, add a line break span. This helps tools like - // figma import our exported svg correctly - const didBreakLine = currentLineTop !== null && box.y > currentLineTop - if (didBreakLine) { - const lineBreakTspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan') - lineBreakTspan.setAttribute('alignment-baseline', 'mathematical') - lineBreakTspan.setAttribute('x', offsetX + 'px') - lineBreakTspan.setAttribute('y', box.y + offsetY + 'px') - lineBreakTspan.textContent = '\n' - textElm.appendChild(lineBreakTspan) - } - - const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan') - tspan.setAttribute('alignment-baseline', 'mathematical') - tspan.setAttribute('x', box.x + offsetX + 'px') - tspan.setAttribute('y', box.y + offsetY + 'px') - const cleanText = correctSpacesToNbsp(text) - tspan.textContent = cleanText - textElm.appendChild(tspan) - - currentLineTop = box.y - } - - if (opts.stroke && opts.strokeWidth) { - textElm.setAttribute('stroke', opts.stroke) - textElm.setAttribute('stroke-width', opts.strokeWidth + 'px') - } - - if (opts.fill) { - textElm.setAttribute('fill', opts.fill) - } - - return textElm -} diff --git a/packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts b/packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts index 6cd0b20e6..7fc24c6d2 100644 --- a/packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts +++ b/packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts @@ -49,13 +49,7 @@ export const FONT_FAMILIES: Record = { mono: 'var(--tl-font-mono)', } -/** @internal */ -export const MIN_ARROW_LENGTH = 48 -/** @internal */ -export const BOUND_ARROW_OFFSET = 10 /** @internal */ export const LABEL_TO_ARROW_PADDING = 20 /** @internal */ export const ARROW_LABEL_PADDING = 4.25 -/** @internal */ -export const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10 diff --git a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx index 3f8e2f831..8a6ec0787 100644 --- a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx +++ b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx @@ -6,7 +6,6 @@ import { HASH_PATTERN_ZOOM_NAMES, MAX_ZOOM, SvgExportDef, - TLDefaultColorTheme, TLDefaultFillStyle, TLDefaultFontStyle, TLShapeUtilCanvasSvgDef, @@ -14,6 +13,7 @@ import { useEditor, } from '@tldraw/editor' import { useEffect, useMemo, useRef, useState } from 'react' +import { useDefaultColorTheme } from './ShapeFill' /** @public */ export function getFontDefForExport(fontStyle: TLDefaultFontStyle): SvgExportDef { @@ -31,9 +31,7 @@ export function getFontDefForExport(fontStyle: TLDefaultFontStyle): SvgExportDef const base64FontFile = await FileHelpers.blobToDataUrl(fontFile) const newFontFaceRule = fontFaceRule.replace(url, base64FontFile) - const style = document.createElementNS('http://www.w3.org/2000/svg', 'style') - style.textContent = newFontFaceRule - return style + return }, } } @@ -49,47 +47,42 @@ function findFont(name: TLDefaultFontStyle): FontFace | null { } /** @public */ -export function getFillDefForExport( - fill: TLDefaultFillStyle, - theme: TLDefaultColorTheme -): SvgExportDef { +export function getFillDefForExport(fill: TLDefaultFillStyle): SvgExportDef { return { key: `${DefaultFontStyle.id}:${fill}`, getElement: async () => { if (fill !== 'pattern') return null - const t = 8 / 12 - const divEl = document.createElement('div') - divEl.innerHTML = ` - - - - - - - - - - - - - - - - ` - return Array.from(divEl.querySelectorAll('defs > *')) + return }, } } +function HashPatternForExport() { + const theme = useDefaultColorTheme() + const t = 8 / 12 + return ( + <> + + + + + + + + + + + + + ) +} + export function getFillDefForCanvas(): TLShapeUtilCanvasSvgDef { return { key: `${DefaultFontStyle.id}:pattern`, diff --git a/packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts b/packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts index d8ff4c84a..bb60d358b 100644 --- a/packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts +++ b/packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts @@ -151,7 +151,7 @@ function circlePath(cx: number, cy: number, r: number) { ) } -export function renderPartition(strokePoints: StrokePoint[], options: StrokeOptions = {}): string { +function renderPartition(strokePoints: StrokePoint[], options: StrokeOptions = {}): string { if (strokePoints.length === 0) return '' if (strokePoints.length === 1) { return circlePath(strokePoints[0].point.x, strokePoints[0].point.y, strokePoints[0].radius) diff --git a/packages/tldraw/src/lib/shapes/shared/getBrowserCanvasMaxSize.tsx b/packages/tldraw/src/lib/shapes/shared/getBrowserCanvasMaxSize.tsx index d2334e6c3..4bda344f2 100644 --- a/packages/tldraw/src/lib/shapes/shared/getBrowserCanvasMaxSize.tsx +++ b/packages/tldraw/src/lib/shapes/shared/getBrowserCanvasMaxSize.tsx @@ -8,7 +8,7 @@ export type CanvasMaxSize = { let maxSizePromise: Promise | null = null -export function getBrowserCanvasMaxSize() { +function getBrowserCanvasMaxSize() { if (!maxSizePromise) { maxSizePromise = calculateBrowserCanvasMaxSize() } @@ -28,8 +28,8 @@ async function calculateBrowserCanvasMaxSize(): Promise { } // https://github.com/jhildenbiddle/canvas-size?tab=readme-ov-file#test-results -export const MAX_SAFE_CANVAS_DIMENSION = 8192 -export const MAX_SAFE_CANVAS_AREA = 4096 * 4096 +const MAX_SAFE_CANVAS_DIMENSION = 8192 +const MAX_SAFE_CANVAS_AREA = 4096 * 4096 export async function clampToBrowserMaxCanvasSize(width: number, height: number) { if ( diff --git a/packages/tldraw/src/lib/shapes/shared/getTextLabelSvgElement.ts b/packages/tldraw/src/lib/shapes/shared/getTextLabelSvgElement.ts deleted file mode 100644 index 0f4201f1c..000000000 --- a/packages/tldraw/src/lib/shapes/shared/getTextLabelSvgElement.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Box, Editor, TLGeoShape, TLNoteShape } from '@tldraw/editor' -import { createTextSvgElementFromSpans } from './createTextSvgElementFromSpans' -import { LABEL_FONT_SIZES, TEXT_PROPS } from './default-shape-constants' -import { getLegacyOffsetX } from './legacyProps' - -export function getTextLabelSvgElement({ - bounds, - editor, - font, - shape, -}: { - bounds: Box - editor: Editor - font: string - shape: TLGeoShape | TLNoteShape -}) { - const padding = 16 - - const opts = { - fontSize: LABEL_FONT_SIZES[shape.props.size], - fontFamily: font, - textAlign: shape.props.align, - verticalTextAlign: shape.props.verticalAlign, - width: Math.ceil(bounds.width), - height: Math.ceil(bounds.height), - padding: 16, - lineHeight: TEXT_PROPS.lineHeight, - fontStyle: 'normal', - fontWeight: 'normal', - overflow: 'wrap' as const, - offsetX: 0, - } - - const spans = editor.textMeasure.measureTextSpans(shape.props.text, opts) - const offsetX = getLegacyOffsetX(shape.props.align, padding, spans, bounds.width) - if (offsetX) { - opts.offsetX = offsetX - } - - const textElm = createTextSvgElementFromSpans(editor, spans, opts) - return textElm -} diff --git a/packages/tldraw/src/lib/shapes/text/TextShapeUtil.tsx b/packages/tldraw/src/lib/shapes/text/TextShapeUtil.tsx index 07129bf0e..41983128f 100644 --- a/packages/tldraw/src/lib/shapes/text/TextShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/text/TextShapeUtil.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { - DefaultFontFamilies, + Box, Editor, HTMLContainer, Rectangle2d, @@ -19,7 +19,7 @@ import { toDomPrecision, useEditor, } from '@tldraw/editor' -import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans' +import { SvgTextLabel } from '../shared/SvgTextLabel' import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants' import { getFontDefForExport } from '../shared/defaultStyleDefs' import { resizeScaled } from '../shared/resizeScaled' @@ -149,51 +149,24 @@ export class TextShapeUtil extends ShapeUtil { override toSvg(shape: TLTextShape, ctx: SvgExportContext) { ctx.addExportDef(getFontDefForExport(shape.props.font)) + if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font)) - const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }) const bounds = this.editor.getShapeGeometry(shape).bounds - const text = shape.props.text - const width = bounds.width / (shape.props.scale ?? 1) const height = bounds.height / (shape.props.scale ?? 1) - const opts = { - fontSize: FONT_SIZES[shape.props.size], - fontFamily: DefaultFontFamilies[shape.props.font], - textAlign: shape.props.align, - verticalTextAlign: 'middle' as const, - width, - height, - padding: 0, // no padding? - lineHeight: TEXT_PROPS.lineHeight, - fontStyle: 'normal', - fontWeight: 'normal', - overflow: 'wrap' as const, - } - - const color = theme[shape.props.color].solid - const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g') - - const textBgEl = createTextSvgElementFromSpans( - this.editor, - this.editor.textMeasure.measureTextSpans(text, opts), - { - ...opts, - stroke: theme.background, - strokeWidth: 2, - fill: theme.background, - padding: 0, - } + return ( + ) - - const textElm = textBgEl.cloneNode(true) as SVGTextElement - textElm.setAttribute('fill', color) - textElm.setAttribute('stroke', 'none') - - groupEl.append(textBgEl) - groupEl.append(textElm) - - return groupEl } override onResize: TLOnResizeHandler = (shape, info) => { diff --git a/packages/tldraw/src/lib/shapes/video/VideoShapeUtil.tsx b/packages/tldraw/src/lib/shapes/video/VideoShapeUtil.tsx index 796bf84ff..ac88bb899 100644 --- a/packages/tldraw/src/lib/shapes/video/VideoShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/video/VideoShapeUtil.tsx @@ -198,14 +198,7 @@ export class VideoShapeUtil extends BaseBoxShapeUtil { } override toSvg(shape: TLVideoShape) { - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') - const image = document.createElementNS('http://www.w3.org/2000/svg', 'image') - image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', serializeVideo(shape.id)) - image.setAttribute('width', shape.props.w.toString()) - image.setAttribute('height', shape.props.h.toString()) - g.appendChild(image) - - return g + return } } diff --git a/packages/tldraw/src/lib/ui/hooks/usePrint.ts b/packages/tldraw/src/lib/ui/hooks/usePrint.ts index 08f7e8c68..9728f4e22 100644 --- a/packages/tldraw/src/lib/ui/hooks/usePrint.ts +++ b/packages/tldraw/src/lib/ui/hooks/usePrint.ts @@ -142,14 +142,14 @@ export function usePrint() { window.addEventListener('beforeprint', beforePrintHandler) window.addEventListener('afterprint', afterPrintHandler) - function addPageToPrint(title: string, footer: string | null, svg: SVGElement) { + function addPageToPrint(title: string, footer: string | null, svg: string) { try { el.innerHTML += `
${title.replace(//g, '>')}
- ${svg.outerHTML} + ${svg}