@tldraw/editor 5.4.1 → 5.5.0-canary.003bf5951274

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/dist-cjs/index.d.ts +37 -14
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +16 -12
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
  11. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
  12. package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
  13. package/dist-cjs/lib/constants.js +2 -0
  14. package/dist-cjs/lib/constants.js.map +2 -2
  15. package/dist-cjs/lib/editor/Editor.js +147 -157
  16. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  17. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  18. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +10 -6
  20. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  22. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
  24. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  26. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  27. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  28. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  29. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  30. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  31. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  32. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  33. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  34. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  35. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  36. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  37. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  38. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  39. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  40. package/dist-cjs/lib/exports/parseCss.js +1 -1
  41. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  42. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  43. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  44. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  45. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  46. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  47. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  48. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  49. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  50. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  51. package/dist-cjs/lib/primitives/geometry/Arc2d.js +1 -1
  52. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  53. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
  54. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  55. package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
  56. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  57. package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
  58. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  59. package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
  60. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  61. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  62. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  63. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  64. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  65. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +22 -24
  66. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  67. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
  68. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  69. package/dist-cjs/version.js +3 -3
  70. package/dist-cjs/version.js.map +1 -1
  71. package/dist-esm/index.d.mts +37 -14
  72. package/dist-esm/index.mjs +4 -2
  73. package/dist-esm/index.mjs.map +2 -2
  74. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  75. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  76. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  77. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  78. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  79. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  80. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  81. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  82. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  83. package/dist-esm/lib/constants.mjs +2 -0
  84. package/dist-esm/lib/constants.mjs.map +2 -2
  85. package/dist-esm/lib/editor/Editor.mjs +148 -157
  86. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  87. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  88. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  89. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
  90. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  91. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  92. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
  94. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +2 -2
  95. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  96. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  97. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  98. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  99. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  100. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  101. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  102. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  103. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  104. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  105. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  106. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  107. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  108. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  109. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  110. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  111. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  112. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  113. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  114. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  115. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  116. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  117. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  118. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  119. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  120. package/dist-esm/lib/primitives/geometry/Arc2d.mjs +1 -1
  121. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  122. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
  123. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  124. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
  125. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  126. package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
  127. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  128. package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
  129. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  130. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  131. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  132. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  133. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  134. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +22 -24
  135. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  136. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
  137. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  138. package/dist-esm/version.mjs +3 -3
  139. package/dist-esm/version.mjs.map +1 -1
  140. package/editor.css +9 -1
  141. package/package.json +8 -11
  142. package/src/index.ts +5 -1
  143. package/src/lib/TldrawEditor.tsx +35 -21
  144. package/src/lib/components/MenuClickCapture.tsx +55 -4
  145. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  146. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  147. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  148. package/src/lib/constants.ts +8 -0
  149. package/src/lib/editor/Editor.test.ts +20 -0
  150. package/src/lib/editor/Editor.ts +228 -262
  151. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  152. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
  153. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
  154. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  155. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  156. package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
  157. package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
  158. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  159. package/src/lib/editor/types/external-content.ts +5 -0
  160. package/src/lib/exports/ExportDelay.tsx +16 -4
  161. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  162. package/src/lib/exports/FontEmbedder.ts +32 -11
  163. package/src/lib/exports/StyleEmbedder.ts +13 -4
  164. package/src/lib/exports/fetchCache.test.ts +22 -0
  165. package/src/lib/exports/fetchCache.ts +4 -0
  166. package/src/lib/exports/getSvgAsImage.ts +13 -5
  167. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  168. package/src/lib/exports/getSvgJsx.tsx +28 -16
  169. package/src/lib/exports/parseCss.test.ts +4 -0
  170. package/src/lib/exports/parseCss.ts +2 -2
  171. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  172. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  173. package/src/lib/hooks/useIdentity.tsx +12 -1
  174. package/src/lib/license/LicenseManager.ts +13 -1
  175. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  176. package/src/lib/license/LicenseProvider.tsx +19 -5
  177. package/src/lib/primitives/geometry/Arc2d.test.ts +9 -0
  178. package/src/lib/primitives/geometry/Arc2d.ts +1 -1
  179. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
  180. package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
  181. package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
  182. package/src/lib/primitives/geometry/Edge2d.ts +1 -1
  183. package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
  184. package/src/lib/primitives/geometry/Group2d.ts +18 -7
  185. package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
  186. package/src/lib/primitives/geometry/Point2d.ts +1 -1
  187. package/src/lib/test/preserveFocus.test.tsx +81 -0
  188. package/src/lib/utils/debug-flags.ts +0 -6
  189. package/src/lib/utils/deepLinks.test.ts +15 -0
  190. package/src/lib/utils/deepLinks.ts +8 -3
  191. package/src/lib/utils/sync/LocalIndexedDb.test.ts +95 -0
  192. package/src/lib/utils/sync/LocalIndexedDb.ts +31 -27
  193. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
  194. package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
  195. package/src/version.ts +3 -3
  196. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  197. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  198. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  199. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  200. package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
@@ -145,7 +145,13 @@ function measureContentBounds(
145
145
  // (extraPx * 2 >= w means declaredRight <= declaredLeft, producing zero/negative crop)
146
146
  if (extraPx <= 0 || extraPx * 2 >= w || extraPx * 2 >= h) return null
147
147
 
148
- const imageData = ctx.getImageData(0, 0, w, h)
148
+ let imageData: ImageData
149
+ try {
150
+ imageData = ctx.getImageData(0, 0, w, h)
151
+ } catch {
152
+ // a tainted or unallocatable canvas can't be measured; export untrimmed rather than fail
153
+ return null
154
+ }
149
155
  const data = imageData.data
150
156
 
151
157
  // Determine how to detect "empty" pixels.
@@ -302,9 +308,11 @@ export async function trimSvgToContent(
302
308
 
303
309
  if (trimPadding <= 0) return null
304
310
 
305
- // Render SVG to a temporary canvas at 1:1 pixel ratio
306
- const canvasWidth = Math.floor(width)
307
- const canvasHeight = Math.floor(height)
311
+ // Render SVG to a temporary canvas at 1:1 pixel ratio, clamped like the raster path so a
312
+ // large export doesn't exceed the browser's canvas limits
313
+ let [canvasWidth, canvasHeight] = clampToBrowserMaxCanvasSize(width, height)
314
+ canvasWidth = Math.floor(canvasWidth)
315
+ canvasHeight = Math.floor(canvasHeight)
308
316
 
309
317
  if (canvasWidth <= 0 || canvasHeight <= 0) return null
310
318
 
@@ -313,7 +321,7 @@ export async function trimSvgToContent(
313
321
  if (!canvas) return null
314
322
 
315
323
  // Measure content bounds on the canvas
316
- const trimPaddingPx = trimPadding * scale
324
+ const trimPaddingPx = trimPadding * scale * (canvasWidth / width)
317
325
  const bounds = measureContentBounds(canvas, trimPaddingPx)
318
326
  if (!bounds) return null
319
327
 
@@ -1,4 +1,13 @@
1
- import { Geometry2d, RecordProps, Rectangle2d, ShapeUtil, T, TLShape, createShapeId } from '../..'
1
+ import {
2
+ BaseFrameLikeShapeUtil,
3
+ Geometry2d,
4
+ RecordProps,
5
+ Rectangle2d,
6
+ ShapeUtil,
7
+ T,
8
+ TLShape,
9
+ createShapeId,
10
+ } from '../..'
2
11
  import { createTLStore } from '../config/createTLStore'
3
12
  import { Editor } from '../editor/Editor'
4
13
  import { Box } from '../primitives/Box'
@@ -861,3 +870,36 @@ describe('getExportDefaultBounds', () => {
861
870
  })
862
871
  })
863
872
  })
873
+
874
+ // Deliberately not registered in TLGlobalShapePropsMap (test-only types leak into the api report)
875
+ const FRAME_LIKE_TYPE = 'test-frame-like'
876
+
877
+ class FrameLikeShape extends BaseFrameLikeShapeUtil<any> {
878
+ static override type = FRAME_LIKE_TYPE
879
+ static override props = { w: T.number, h: T.number }
880
+ getDefaultProps() {
881
+ return { w: 100, h: 100 }
882
+ }
883
+ getIndicatorPath() {
884
+ return undefined
885
+ }
886
+ component() {}
887
+ }
888
+
889
+ describe('exporting a single frame-like shape', () => {
890
+ it('does not need the default frame shape util to be registered', async () => {
891
+ const editor = new Editor({
892
+ shapeUtils: [FrameLikeShape],
893
+ bindingUtils: [],
894
+ tools: [],
895
+ store: createTLStore({ shapeUtils: [FrameLikeShape], bindingUtils: [] }),
896
+ getContainer: () => document.body,
897
+ })
898
+ const id = createShapeId('frame-like')
899
+ editor.createShape({ id, type: FRAME_LIKE_TYPE, x: 0, y: 0, props: { w: 100, h: 100 } } as any)
900
+
901
+ const result = await editor.getSvgElement([id], { background: true })
902
+ expect(result?.svg.style.backgroundColor).not.toBe('')
903
+ expect(result?.svg.style.backgroundColor).not.toBe('transparent')
904
+ })
905
+ })
@@ -1,5 +1,5 @@
1
1
  import { useAtom, useValue } from '@tldraw/state-react'
2
- import { TLFrameShape, TLShape, TLShapeId } from '@tldraw/tlschema'
2
+ import { DefaultColorStyle, TLShape, TLShapeId } from '@tldraw/tlschema'
3
3
  import { TLFontFace } from '@tldraw/tlschema'
4
4
  import { hasOwnProperty, promiseWithResolve, uniqueId } from '@tldraw/utils'
5
5
  import {
@@ -117,6 +117,7 @@ export function getSvgJsx(editor: Editor, ids: TLShapeId[], opts: TLImageExportO
117
117
  colorMode={colorMode}
118
118
  renderingShapes={renderingShapes}
119
119
  onMount={initialEffectPromise.resolve}
120
+ onError={exportDelay.fail}
120
121
  waitUntil={exportDelay.waitUntil}
121
122
  >
122
123
  {}
@@ -208,6 +209,7 @@ function SvgExport({
208
209
  colorMode,
209
210
  renderingShapes,
210
211
  onMount,
212
+ onError,
211
213
  waitUntil,
212
214
  }: {
213
215
  editor: Editor
@@ -221,6 +223,7 @@ function SvgExport({
221
223
  colorMode: 'light' | 'dark'
222
224
  renderingShapes: TLRenderingShape[]
223
225
  onMount(): void
226
+ onError(error: unknown): void
224
227
  waitUntil(promise: Promise<void>): void
225
228
  }) {
226
229
  const masksId = useUniqueSafeId()
@@ -283,6 +286,8 @@ function SvgExport({
283
286
  throw new Error('SvgExport should only render once - do not use with react strict mode')
284
287
  }
285
288
  didRenderRef.current = true
289
+ // the promise is caught at the end: a throwing `toSvg` would otherwise leave `onMount`
290
+ // pending forever, so the export would wait out its max delay and snapshot an empty svg
286
291
  ;(async () => {
287
292
  const shapeDefs: Record<string, { pending: false; element: ReactElement }> = {}
288
293
 
@@ -418,8 +423,17 @@ function SvgExport({
418
423
  defsById: { ...state.defsById, ...shapeDefs },
419
424
  }))
420
425
  })
421
- })()
422
- }, [bbox, editor, exportContext, masksId, renderingShapes, singleFrameShapeId, stateAtom])
426
+ })().catch(onError)
427
+ }, [
428
+ bbox,
429
+ editor,
430
+ exportContext,
431
+ masksId,
432
+ onError,
433
+ renderingShapes,
434
+ singleFrameShapeId,
435
+ stateAtom,
436
+ ])
423
437
 
424
438
  useEffect(() => {
425
439
  const fontsInUse = new Set<TLFontFace>()
@@ -449,19 +463,17 @@ function SvgExport({
449
463
  let backgroundColor = background ? colors.background : 'transparent'
450
464
 
451
465
  if (singleFrameShapeId && background) {
452
- const frameShapeUtil = editor.getShapeUtil('frame') as any as
453
- | undefined
454
- | {
455
- options: {
456
- showColors: boolean
457
- }
458
- }
459
- if (frameShapeUtil?.options.showColors) {
460
- const shape = editor.getShape(singleFrameShapeId)! as TLFrameShape
461
- backgroundColor = getColorValue(colors, shape.props.color, 'frameFill')
462
- } else {
463
- backgroundColor = colors.solid
464
- }
466
+ // the frame-like shape's own util decides this - it may not be the default 'frame' util
467
+ const shape = editor.getShape(singleFrameShapeId)!
468
+ const { showColors } = editor.getShapeUtil(shape).options as { showColors?: boolean }
469
+ // the color style may live under any prop key, so look it up through the style map and
470
+ // fall back to a literal `color` prop for frame-like shapes that don't use the style
471
+ const color = showColors
472
+ ? (editor.getShapeStyleIfExists(shape, DefaultColorStyle) ??
473
+ ('color' in shape.props ? shape.props.color : null))
474
+ : null
475
+ backgroundColor =
476
+ typeof color === 'string' ? getColorValue(colors, color, 'frameFill') : colors.solid
465
477
  }
466
478
 
467
479
  return (
@@ -119,6 +119,10 @@ test('parseCss', () => {
119
119
  },
120
120
  ],
121
121
  "imports": [
122
+ {
123
+ "extras": "",
124
+ "url": "https://example.com/font.css",
125
+ },
122
126
  {
123
127
  "extras": " screen and (min-width: 900px)",
124
128
  "url": "thing.css",
@@ -2,7 +2,7 @@ import { safeParseUrl } from '@tldraw/utils'
2
2
 
3
3
  export interface ParsedFontFace {
4
4
  fontFace: string
5
- urls: { original: string; resolved: string | null; embedded?: Promise<string | null> }[]
5
+ urls: { original: string; resolved: string | null }[]
6
6
  fontFamilies: Set<string>
7
7
  }
8
8
 
@@ -18,7 +18,7 @@ export interface ParsedFontFace {
18
18
  // Parse @import declarations:
19
19
  // https://developer.mozilla.org/en-US/docs/Web/CSS/@import#formal_syntax
20
20
  const importsRegex =
21
- /@import\s+(?:"([^"]+)"|'([^']+)'|url\s*\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\))([^;]+);/gi
21
+ /@import\s+(?:"([^"]+)"|'([^']+)'|url\s*\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\))([^;]*);/gi
22
22
 
23
23
  // Locate @font-face declarations:
24
24
  const fontFaceRegex = /@font-face\s*{([^}]+)}/gi
@@ -5,6 +5,7 @@ import type { TLErrorFallbackComponent } from '../components/default-components/
5
5
  import type { TLGridProps } from '../components/default-components/DefaultGrid'
6
6
  import type { TLShapeErrorFallbackComponent } from '../components/default-components/DefaultShapeErrorFallback'
7
7
  import type { TLShapeWrapperProps } from '../components/default-components/DefaultShapeWrapper'
8
+ import type { TLSpinnerProps } from '../components/default-components/DefaultSpinner'
8
9
 
9
10
  /** @public */
10
11
  export interface TLEditorComponents {
@@ -16,7 +17,7 @@ export interface TLEditorComponents {
16
17
  LoadingScreen?: ComponentType | null
17
18
  OnTheCanvas?: ComponentType | null
18
19
  ShapeWrapper?: ComponentType<TLShapeWrapperProps & RefAttributes<HTMLDivElement>> | null
19
- Spinner?: ComponentType<React.SVGProps<SVGSVGElement>> | null
20
+ Spinner?: ComponentType<TLSpinnerProps> | null
20
21
  SvgDefs?: ComponentType | null
21
22
 
22
23
  // These will always have defaults
@@ -19,6 +19,9 @@ export function useCanvasEvents() {
19
19
  const events = useMemo(
20
20
  function canvasEvents() {
21
21
  let isSecondaryClickPointerDown = false
22
+ // The button each accepted press started with, by pointer id. pointercancel
23
+ // reports button -1, so the synthetic pointer_up needs the original.
24
+ const pointerDownButtons = new Map<number, number>()
22
25
 
23
26
  function onPointerDown(e: React.PointerEvent) {
24
27
  if (editor.wasEventAlreadyHandled(e)) return
@@ -45,6 +48,15 @@ export function useCanvasEvents() {
45
48
 
46
49
  setPointerCapture(e.currentTarget, e)
47
50
 
51
+ // Only remember presses the editor will act on. A rejected palm in pen mode
52
+ // must not overwrite the button of the pen press it lands on top of, or the
53
+ // pen's pointercancel would release the wrong button and skip the
54
+ // stylus-eraser tool restore.
55
+ const isPenMode = editor.getInstanceState().isPenMode
56
+ if (!editor.inputs.getIsPinching() && !(isPenMode && e.pointerType !== 'pen')) {
57
+ pointerDownButtons.set(e.pointerId, button)
58
+ }
59
+
48
60
  editor.dispatch({
49
61
  type: 'pointer',
50
62
  target: 'canvas',
@@ -56,6 +68,7 @@ export function useCanvasEvents() {
56
68
 
57
69
  function onPointerUp(e: React.PointerEvent) {
58
70
  if (editor.wasEventAlreadyHandled(e)) return
71
+ pointerDownButtons.delete(e.pointerId)
59
72
  const button = isSecondaryClickPointerDown ? 2 : getPointerEventButton(e)
60
73
  if (button !== 0 && button !== 1 && button !== 2 && button !== 5) return
61
74
 
@@ -91,6 +104,34 @@ export function useCanvasEvents() {
91
104
  isSecondaryClickPointerDown = false
92
105
  }
93
106
 
107
+ function onPointerCancel(e: React.PointerEvent) {
108
+ if (editor.wasEventAlreadyHandled(e)) return
109
+ const pointerDownButton = pointerDownButtons.get(e.pointerId)
110
+ pointerDownButtons.delete(e.pointerId)
111
+ // In pen mode a cancelled touch is usually a rejected palm, and the pen's own
112
+ // interaction must survive it.
113
+ if (editor.getInstanceState().isPenMode && e.pointerType !== 'pen') return
114
+
115
+ releasePointerCapture(e.currentTarget, e)
116
+
117
+ // Nothing to end if the press itself was filtered out (pen mode, unsupported
118
+ // button), or if this pointer never started an accepted press.
119
+ if (!editor.inputs.getIsPointing() || pointerDownButton === undefined) return
120
+
121
+ // The browser has taken the pointer (edge swipe, incoming call) and will never send
122
+ // pointerup, so end the gesture here or the tool stays stuck in its pointing or
123
+ // dragging state and the next touch continues it.
124
+ editor.interrupt()
125
+ editor.dispatch({
126
+ type: 'pointer',
127
+ target: 'canvas',
128
+ name: 'pointer_up',
129
+ ...getPointerInfo(editor, e),
130
+ button: pointerDownButton,
131
+ })
132
+ isSecondaryClickPointerDown = false
133
+ }
134
+
94
135
  function onPointerEnter(e: React.PointerEvent) {
95
136
  if (editor.wasEventAlreadyHandled(e)) return
96
137
  if (editor.getInstanceState().isPenMode && e.pointerType !== 'pen') return
@@ -201,6 +242,7 @@ export function useCanvasEvents() {
201
242
  return {
202
243
  onPointerDown,
203
244
  onPointerUp,
245
+ onPointerCancel,
204
246
  onPointerEnter,
205
247
  onPointerLeave,
206
248
  onDragOver,
@@ -1,4 +1,4 @@
1
- import { areArraysShallowEqual, areObjectsShallowEqual } from '@tldraw/utils'
1
+ import { areArraysShallowEqual, areObjectsShallowEqual, isEqual } from '@tldraw/utils'
2
2
  import { useRef } from 'react'
3
3
 
4
4
  function useIdentity<T>(value: T, isEqual: (a: T, b: T) => boolean): T {
@@ -49,3 +49,14 @@ const areNullableObjectsShallowEqual = (
49
49
  export function useShallowObjectIdentity<T extends object | null | undefined>(obj: T): T {
50
50
  return useIdentity(obj, areNullableObjectsShallowEqual)
51
51
  }
52
+
53
+ /**
54
+ * For small option objects that may nest (e.g. camera constraints), where a shallow comparison
55
+ * would still see a fresh identity on every render. Functions and class instances inside still
56
+ * compare by reference, so an inline callback or a fresh `Extension.configure()` counts as a change.
57
+ *
58
+ * @internal
59
+ */
60
+ export function useDeepObjectIdentity<T extends object | null | undefined>(obj: T): T {
61
+ return useIdentity(obj, isEqual)
62
+ }
@@ -1,4 +1,5 @@
1
1
  import { atom, transact } from '@tldraw/state'
2
+ import { noop } from '@tldraw/utils'
2
3
  import { publishDates, version } from '../../version'
3
4
  import { getDefaultCdnBaseUrl } from '../utils/assets'
4
5
  import { importPublicKey, str2ab } from '../utils/licensing'
@@ -127,6 +128,7 @@ export class LicenseManager {
127
128
  ...NO_FEATURES,
128
129
  })
129
130
  public verbose = true
131
+ private isDisposed = false
130
132
 
131
133
  constructor(licenseKey: string | undefined, testPublicKey?: string) {
132
134
  this.isTest = process.env.NODE_ENV === 'test'
@@ -144,6 +146,9 @@ export class LicenseManager {
144
146
 
145
147
  this.getLicenseFromKey(licenseKey)
146
148
  .then((result) => {
149
+ // replaced by a manager for a newer key: this one's messages, tracking ping and state
150
+ // would describe a key the app no longer uses
151
+ if (this.isDisposed) return
147
152
  const licenseState = getLicenseState(
148
153
  result,
149
154
  (messages: string[]) => this.outputMessages(messages),
@@ -160,11 +165,17 @@ export class LicenseManager {
160
165
  })
161
166
  })
162
167
  .catch((error) => {
168
+ if (this.isDisposed) return
163
169
  console.error('License validation failed:', error)
164
170
  this.state.set('unlicensed')
165
171
  })
166
172
  }
167
173
 
174
+ /** Ignore the pending validation: nothing is logged, tracked or set once the manager is replaced. */
175
+ dispose() {
176
+ this.isDisposed = true
177
+ }
178
+
168
179
  /**
169
180
  * Returns whether a given licensable feature is enabled. Reactive: reading this inside a signal
170
181
  * recomputes when license validation resolves.
@@ -247,8 +258,9 @@ export class LicenseManager {
247
258
  url.searchParams.set('environment', process.env.NODE_ENV)
248
259
  }
249
260
 
261
+ // best-effort: a blocked or offline request must not surface as an unhandled rejection
250
262
  // eslint-disable-next-line no-restricted-globals
251
- fetch(url.toString())
263
+ fetch(url.toString()).catch(noop)
252
264
  }
253
265
 
254
266
  private async extractLicenseKey(licenseKey: string): Promise<LicenseInfo> {
@@ -0,0 +1,76 @@
1
+ import { act, render } from '@testing-library/react'
2
+ import { vi } from 'vitest'
3
+ import { LicenseFromKeyResult, LicenseManager } from './LicenseManager'
4
+ import { LICENSE_TIMEOUT, LicenseProvider, useLicenseContext } from './LicenseProvider'
5
+
6
+ const NO_KEY: LicenseFromKeyResult = { isLicenseParseable: false, reason: 'no-key-provided' }
7
+
8
+ describe('LicenseProvider', () => {
9
+ const managers: LicenseManager[] = []
10
+ function Probe() {
11
+ managers.push(useLicenseContext())
12
+ return null
13
+ }
14
+ const renderWithKey = (licenseKey: string | undefined) => (
15
+ <LicenseProvider licenseKey={licenseKey}>
16
+ <Probe />
17
+ </LicenseProvider>
18
+ )
19
+
20
+ beforeEach(() => {
21
+ managers.length = 0
22
+ })
23
+ afterEach(() => {
24
+ vi.restoreAllMocks()
25
+ vi.useRealTimers()
26
+ })
27
+
28
+ it('creates a new license manager when the license key changes', async () => {
29
+ const rendered = await act(async () => render(renderWithKey(undefined)))
30
+ const initial = managers.at(-1)!
31
+
32
+ await act(async () => rendered.rerender(renderWithKey('not-a-real-key')))
33
+ const changed = managers.at(-1)!
34
+ expect(changed).not.toBe(initial)
35
+
36
+ // same key again: the manager is stable
37
+ await act(async () => rendered.rerender(renderWithKey('not-a-real-key')))
38
+ expect(managers.at(-1)).toBe(changed)
39
+ })
40
+
41
+ it('shows the editor again when a new key arrives after the gate closed', async () => {
42
+ // validation never settles so the test drives the state by hand
43
+ vi.spyOn(LicenseManager.prototype, 'getLicenseFromKey').mockReturnValue(new Promise(() => {}))
44
+ vi.useFakeTimers()
45
+
46
+ const rendered = await act(async () => render(renderWithKey('expired-key')))
47
+ const expired = managers.at(-1)!
48
+ act(() => expired.state.set('expired'))
49
+ act(() => vi.advanceTimersByTime(LICENSE_TIMEOUT))
50
+ expect(rendered.queryByTestId('tl-license-expired')).not.toBeNull()
51
+
52
+ await act(async () => rendered.rerender(renderWithKey('fresh-key')))
53
+ expect(rendered.queryByTestId('tl-license-expired')).toBeNull()
54
+ expect(managers.at(-1)).not.toBe(expired)
55
+ })
56
+
57
+ it('ignores the validation result of a manager replaced by a new key', async () => {
58
+ const resolvers: ((result: LicenseFromKeyResult) => void)[] = []
59
+ vi.spyOn(LicenseManager.prototype, 'getLicenseFromKey').mockImplementation(
60
+ () => new Promise((resolve) => resolvers.push(resolve))
61
+ )
62
+ const track = vi.spyOn(LicenseManager.prototype as any, 'maybeTrack')
63
+
64
+ const rendered = await act(async () => render(renderWithKey(undefined)))
65
+ const orphan = managers.at(-1)!
66
+ await act(async () => rendered.rerender(renderWithKey('real-key')))
67
+ expect(managers.at(-1)).not.toBe(orphan)
68
+
69
+ await act(async () => resolvers[0](NO_KEY))
70
+ expect(orphan.state.get()).toBe('pending')
71
+ expect(track).not.toHaveBeenCalled()
72
+
73
+ await act(async () => resolvers[1](NO_KEY))
74
+ expect(managers.at(-1)!.state.get()).toBe('unlicensed')
75
+ })
76
+ })
@@ -1,5 +1,5 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
- import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
2
+ import { createContext, ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'
3
3
  import { useMaybeEditor } from '../hooks/useEditor'
4
4
  import { LicenseManager } from './LicenseManager'
5
5
 
@@ -46,21 +46,35 @@ export function LicenseProvider({
46
46
  licenseKey?: string
47
47
  children: ReactNode
48
48
  }) {
49
- const [licenseManager] = useState(() => new LicenseManager(licenseKey))
49
+ // Keyed on the license key: the editor is recreated when the key changes, and must not be
50
+ // handed a manager that validated the old key.
51
+ const licenseManager = useMemo(() => new LicenseManager(licenseKey), [licenseKey])
50
52
  const licenseState = useValue(licenseManager.state)
51
- const [showEditor, setShowEditor] = useState(true)
53
+ // The manager whose LICENSE_TIMEOUT elapsed; compared by identity so a new key un-gates the editor.
54
+ const [gatedManager, setGatedManager] = useState<LicenseManager | null>(null)
55
+ const showEditor = gatedManager !== licenseManager
56
+
57
+ // Dispose only the replaced manager, never on cleanup: strict mode re-runs effects with the
58
+ // same manager, and disposing it there would silence the live one.
59
+ const previousManager = useRef<LicenseManager | null>(null)
60
+ useEffect(() => {
61
+ if (previousManager.current && previousManager.current !== licenseManager) {
62
+ previousManager.current.dispose()
63
+ }
64
+ previousManager.current = licenseManager
65
+ }, [licenseManager])
52
66
 
53
67
  // When license expires or no license in production, show for 5 seconds then hide
54
68
  useEffect(() => {
55
69
  if (shouldHideEditorAfterDelay(licenseState) && showEditor) {
56
70
  // eslint-disable-next-line no-restricted-globals
57
71
  const timer = setTimeout(() => {
58
- setShowEditor(false)
72
+ setGatedManager(licenseManager)
59
73
  }, LICENSE_TIMEOUT)
60
74
 
61
75
  return () => clearTimeout(timer)
62
76
  }
63
- }, [licenseState, showEditor])
77
+ }, [licenseManager, licenseState, showEditor])
64
78
 
65
79
  // If license is expired or no license in production and 5 seconds have passed, don't render anything (blank screen)
66
80
  if (shouldHideEditorAfterDelay(licenseState) && !showEditor) {
@@ -225,4 +225,13 @@ describe('Arc2d.getSvgPathData', () => {
225
225
  })
226
226
  expect(arc.getSvgPathData()).toBe('M10, 0 A10 10 0 0 1 7.07, 7.07')
227
227
  })
228
+
229
+ it('does not mutate the stored points', () => {
230
+ const start = new Vec(10, 0)
231
+ const end = new Vec(10 / Math.SQRT2, 10 / Math.SQRT2)
232
+ const arc = new Arc2d({ center: new Vec(0, 0), start, end, sweepFlag: 1, largeArcFlag: 0 })
233
+ arc.getSvgPathData()
234
+ expect(start).toMatchObject({ x: 10, y: 0 })
235
+ expect(end).toMatchObject({ x: 10 / Math.SQRT2, y: 10 / Math.SQRT2 })
236
+ })
228
237
  })
@@ -105,7 +105,7 @@ export class Arc2d extends Geometry2d {
105
105
  _largeArcFlag: largeArcFlag,
106
106
  _sweepFlag: sweepFlag,
107
107
  } = this
108
- return `${first ? `M${start.toFixed()}` : ``} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.toFixed()}`
108
+ return `${first ? `M${start}` : ``} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end}`
109
109
  }
110
110
 
111
111
  override getLength() {
@@ -172,4 +172,17 @@ describe('CubicBezier2d.getSvgPathData', () => {
172
172
  })
173
173
  expect(b.getSvgPathData()).toBe('M 0.12, 0 C1.01, 2 3, 5 10, 10')
174
174
  })
175
+
176
+ it('does not mutate the stored points', () => {
177
+ const start = new Vec(0.123, 0)
178
+ const cp1 = new Vec(1.006, 2)
179
+ const cp2 = new Vec(3, 4.999)
180
+ const end = new Vec(10, 10.005)
181
+ const b = new CubicBezier2d({ start, cp1, cp2, end })
182
+ b.getSvgPathData()
183
+ expect(start).toMatchObject({ x: 0.123, y: 0 })
184
+ expect(cp1).toMatchObject({ x: 1.006, y: 2 })
185
+ expect(cp2).toMatchObject({ x: 3, y: 4.999 })
186
+ expect(end).toMatchObject({ x: 10, y: 10.005 })
187
+ })
175
188
  })
@@ -81,7 +81,7 @@ export class CubicBezier2d extends Polyline2d {
81
81
 
82
82
  getSvgPathData(first = true) {
83
83
  const { _a: a, _b: b, _c: c, _d: d } = this
84
- return `${first ? `M ${a.toFixed()} ` : ``} C${b.toFixed()} ${c.toFixed()} ${d.toFixed()}`
84
+ return `${first ? `M ${a} ` : ``} C${b} ${c} ${d}`
85
85
  }
86
86
 
87
87
  static GetAtT(segment: CubicBezier2d, t: number) {
@@ -1,5 +1,23 @@
1
- describe('Edge2d', () => {
2
- it('should be tested', () => {
3
- expect(true).toBe(true)
1
+ import { Vec } from '../Vec'
2
+ import { Edge2d } from './Edge2d'
3
+
4
+ describe('Edge2d.getSvgPathData', () => {
5
+ it('emits a move and a line command, rounded to two decimals', () => {
6
+ const edge = new Edge2d({ start: new Vec(0.123456, 0), end: new Vec(10, 10.987654) })
7
+ expect(edge.getSvgPathData()).toBe('M0.12, 0 L10, 10.99')
8
+ })
9
+
10
+ it('omits the move command when not first', () => {
11
+ const edge = new Edge2d({ start: new Vec(0.123456, 0), end: new Vec(10, 10.987654) })
12
+ expect(edge.getSvgPathData(false)).toBe(' L10, 10.99')
13
+ })
14
+
15
+ it('does not mutate the stored points', () => {
16
+ const start = new Vec(0.123456, 0)
17
+ const end = new Vec(10, 10.987654)
18
+ const edge = new Edge2d({ start, end })
19
+ edge.getSvgPathData()
20
+ expect(start).toMatchObject({ x: 0.123456, y: 0 })
21
+ expect(end).toMatchObject({ x: 10, y: 10.987654 })
4
22
  })
5
23
  })
@@ -68,6 +68,6 @@ export class Edge2d extends Geometry2d {
68
68
 
69
69
  getSvgPathData(first = true) {
70
70
  const { _start: start, _end: end } = this
71
- return `${first ? `M${start.toFixed()}` : ``} L${end.toFixed()}`
71
+ return `${first ? `M${start}` : ``} L${end}`
72
72
  }
73
73
  }
@@ -340,20 +340,38 @@ describe('Group2d.transform', () => {
340
340
  expect(t.vertices.length).toBe(8)
341
341
  })
342
342
 
343
- it('carries the label, debug colour and ignore flags forward', () => {
343
+ it('carries all the flags forward', () => {
344
344
  const g = new Group2d({
345
345
  children: [rectA()],
346
346
  isLabel: true,
347
+ isEmptyLabel: true,
348
+ isInternal: true,
349
+ excludeFromShapeBounds: true,
347
350
  debugColor: 'red',
348
351
  ignore: true,
349
352
  })
350
353
  expect(g.transform(Mat.Identity())).toMatchObject({
351
354
  isLabel: true,
355
+ isEmptyLabel: true,
356
+ isInternal: true,
357
+ excludeFromShapeBounds: true,
352
358
  debugColor: 'red',
353
359
  ignore: true,
354
360
  })
355
361
  })
356
362
 
363
+ it('applies the transform options over the carried flags', () => {
364
+ const g = new Group2d({ children: [rectA()], isLabel: true, isInternal: true })
365
+ expect(
366
+ g.transform(Mat.Identity(), {
367
+ isLabel: false,
368
+ isInternal: false,
369
+ debugColor: 'red',
370
+ ignore: true,
371
+ })
372
+ ).toMatchObject({ isLabel: false, isInternal: false, debugColor: 'red', ignore: true })
373
+ })
374
+
357
375
  it('scales hit testing with the transform', () => {
358
376
  const t = group().transform(Mat.Scale(2, 2))
359
377
  expect(t.hitTestPoint(new Vec(100, 50), 0, false)).toBe(true)
@@ -361,12 +379,15 @@ describe('Group2d.transform', () => {
361
379
  expect(t.distanceToPoint(new Vec(300, 50))).toBe(100)
362
380
  })
363
381
 
364
- // Locks in current behaviour, see #10562.
365
- it('drops the ignored children', () => {
382
+ // #10562: transform used to drop the ignored children.
383
+ it('keeps the transformed ignored children', () => {
366
384
  const ignored = new Rectangle2d({ x: 500, width: 10, height: 10, isFilled: true, ignore: true })
367
385
  const g = new Group2d({ children: [rectA(), ignored] })
368
- expect(g.ignoredChildren.length).toBe(1)
369
- expect((g.transform(Mat.Identity()) as Group2d).ignoredChildren).toEqual([])
386
+ const t = g.transform(Mat.Translate(10, 20)) as Group2d
387
+ expect(t.children.length).toBe(1)
388
+ expect(t.ignoredChildren.length).toBe(1)
389
+ expect(t.ignoredChildren[0].ignore).toBe(true)
390
+ expect(t.ignoredChildren[0].bounds).toMatchObject({ x: 510, y: 20, w: 10, h: 10 })
370
391
  })
371
392
  })
372
393