@tldraw/editor 3.16.0-canary.e1b1e53d3c16 → 3.16.0-canary.eb9a487b3293

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 (97) hide show
  1. package/dist-cjs/index.d.ts +36 -101
  2. package/dist-cjs/index.js +1 -5
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +0 -4
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +2 -2
  7. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  8. package/dist-cjs/lib/editor/Editor.js +14 -109
  9. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  10. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +13 -0
  11. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  12. package/dist-cjs/lib/editor/types/misc-types.js.map +1 -1
  13. package/dist-cjs/lib/exports/getSvgJsx.js +34 -14
  14. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  15. package/dist-cjs/lib/hooks/useCanvasEvents.js +7 -5
  16. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  17. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js +4 -1
  18. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js.map +2 -2
  19. package/dist-cjs/lib/license/LicenseManager.js +17 -22
  20. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  21. package/dist-cjs/lib/license/LicenseProvider.js +5 -0
  22. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  23. package/dist-cjs/lib/license/Watermark.js +4 -4
  24. package/dist-cjs/lib/license/Watermark.js.map +1 -1
  25. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  26. package/dist-cjs/lib/primitives/Box.js +3 -0
  27. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  28. package/dist-cjs/lib/primitives/Vec.js +0 -4
  29. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  30. package/dist-cjs/lib/primitives/geometry/Geometry2d.js +26 -18
  31. package/dist-cjs/lib/primitives/geometry/Geometry2d.js.map +2 -2
  32. package/dist-cjs/lib/primitives/geometry/Group2d.js +3 -0
  33. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  34. package/dist-cjs/lib/utils/reparenting.js +2 -35
  35. package/dist-cjs/lib/utils/reparenting.js.map +3 -3
  36. package/dist-cjs/version.js +3 -3
  37. package/dist-cjs/version.js.map +1 -1
  38. package/dist-esm/index.d.mts +36 -101
  39. package/dist-esm/index.mjs +1 -5
  40. package/dist-esm/index.mjs.map +2 -2
  41. package/dist-esm/lib/TldrawEditor.mjs +0 -4
  42. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  43. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +2 -2
  44. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  45. package/dist-esm/lib/editor/Editor.mjs +14 -109
  46. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  47. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +13 -0
  48. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  49. package/dist-esm/lib/exports/getSvgJsx.mjs +34 -14
  50. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  51. package/dist-esm/lib/hooks/useCanvasEvents.mjs +7 -5
  52. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  53. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs +4 -1
  54. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs.map +2 -2
  55. package/dist-esm/lib/license/LicenseManager.mjs +17 -22
  56. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  57. package/dist-esm/lib/license/LicenseProvider.mjs +5 -0
  58. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  59. package/dist-esm/lib/license/Watermark.mjs +4 -4
  60. package/dist-esm/lib/license/Watermark.mjs.map +1 -1
  61. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  62. package/dist-esm/lib/primitives/Box.mjs +4 -1
  63. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  64. package/dist-esm/lib/primitives/Vec.mjs +0 -4
  65. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  66. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs +29 -19
  67. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs.map +2 -2
  68. package/dist-esm/lib/primitives/geometry/Group2d.mjs +3 -0
  69. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  70. package/dist-esm/lib/utils/reparenting.mjs +3 -40
  71. package/dist-esm/lib/utils/reparenting.mjs.map +2 -2
  72. package/dist-esm/version.mjs +3 -3
  73. package/dist-esm/version.mjs.map +1 -1
  74. package/editor.css +8 -0
  75. package/package.json +7 -7
  76. package/src/index.ts +1 -9
  77. package/src/lib/TldrawEditor.tsx +0 -11
  78. package/src/lib/components/default-components/DefaultCanvas.tsx +3 -1
  79. package/src/lib/editor/Editor.ts +20 -146
  80. package/src/lib/editor/shapes/ShapeUtil.ts +35 -0
  81. package/src/lib/editor/types/misc-types.ts +0 -6
  82. package/src/lib/exports/getSvgJsx.test.ts +868 -0
  83. package/src/lib/exports/getSvgJsx.tsx +76 -19
  84. package/src/lib/hooks/useCanvasEvents.ts +6 -6
  85. package/src/lib/hooks/usePassThroughMouseOverEvents.ts +4 -1
  86. package/src/lib/license/LicenseManager.test.ts +58 -51
  87. package/src/lib/license/LicenseManager.ts +32 -24
  88. package/src/lib/license/LicenseProvider.tsx +8 -0
  89. package/src/lib/license/Watermark.tsx +4 -4
  90. package/src/lib/license/useLicenseManagerState.ts +2 -2
  91. package/src/lib/primitives/Box.test.ts +126 -0
  92. package/src/lib/primitives/Box.ts +10 -1
  93. package/src/lib/primitives/Vec.ts +0 -5
  94. package/src/lib/primitives/geometry/Geometry2d.ts +49 -19
  95. package/src/lib/primitives/geometry/Group2d.ts +4 -0
  96. package/src/lib/utils/reparenting.ts +3 -69
  97. package/src/version.ts +3 -3
@@ -57,33 +57,21 @@ export function getSvgJsx(editor: Editor, ids: TLShapeId[], opts: TLImageExportO
57
57
  .filter(({ id }) => shapeIdsToInclude.has(id))
58
58
 
59
59
  // --- Common bounding box of all shapes
60
+ const singleFrameShapeId =
61
+ ids.length === 1 && editor.isShapeOfType<TLFrameShape>(editor.getShape(ids[0])!, 'frame')
62
+ ? ids[0]
63
+ : null
64
+
60
65
  let bbox: null | Box = null
61
66
  if (opts.bounds) {
62
- bbox = opts.bounds
67
+ bbox = opts.bounds.clone().expandBy(padding)
63
68
  } else {
64
- for (const { id } of renderingShapes) {
65
- const maskedPageBounds = editor.getShapeMaskedPageBounds(id)
66
- if (!maskedPageBounds) continue
67
- if (bbox) {
68
- bbox.union(maskedPageBounds)
69
- } else {
70
- bbox = maskedPageBounds.clone()
71
- }
72
- }
69
+ bbox = getExportDefaultBounds(editor, renderingShapes, padding, singleFrameShapeId)
73
70
  }
74
71
 
75
72
  // no unmasked shapes to export
76
73
  if (!bbox) return
77
74
 
78
- const singleFrameShapeId =
79
- ids.length === 1 && editor.isShapeOfType<TLFrameShape>(editor.getShape(ids[0])!, 'frame')
80
- ? ids[0]
81
- : null
82
- if (!singleFrameShapeId) {
83
- // Expand by an extra 32 pixels
84
- bbox.expandBy(padding)
85
- }
86
-
87
75
  // We want the svg image to be BIGGER THAN USUAL to account for image quality
88
76
  const w = bbox.width * scale
89
77
  const h = bbox.height * scale
@@ -120,6 +108,75 @@ export function getSvgJsx(editor: Editor, ids: TLShapeId[], opts: TLImageExportO
120
108
  return { jsx: svg, width: w, height: h, exportDelay }
121
109
  }
122
110
 
111
+ /**
112
+ * Calculates the default bounds for an SVG export. This function handles:
113
+ * 1. Computing masked page bounds for each shape
114
+ * 2. Container logic: if a shape is marked as an export bounds container and it
115
+ * contains all other shapes, use its bounds and skip padding
116
+ * 3. Otherwise, create a union of all shape bounds and apply padding
117
+ *
118
+ * The container logic is useful for cases like annotating on an image - if the image
119
+ * contains all annotations, we want to export exactly the image bounds without extra padding.
120
+ *
121
+ * @param editor - The editor instance
122
+ * @param renderingShapes - The shapes to include in the export
123
+ * @param padding - Padding to add around the bounds (only applied if no container bounds)
124
+ * @param singleFrameShapeId - If exporting a single frame, this is its ID (skips padding)
125
+ * @returns The calculated bounds box, or null if no shapes to export
126
+ */
127
+ export function getExportDefaultBounds(
128
+ editor: Editor,
129
+ renderingShapes: TLRenderingShape[],
130
+ padding: number,
131
+ singleFrameShapeId: TLShapeId | null
132
+ ) {
133
+ let isBoundedByContainer = false
134
+ let bbox: null | Box = null
135
+
136
+ for (const { id } of renderingShapes) {
137
+ const maskedPageBounds = editor.getShapeMaskedPageBounds(id)
138
+ if (!maskedPageBounds) continue
139
+
140
+ // Check if this shape is an export bounds container (e.g., an image being annotated)
141
+ const shape = editor.getShape(id)!
142
+ const isContainer = editor.getShapeUtil(shape).isExportBoundsContainer(shape)
143
+
144
+ if (bbox) {
145
+ // Container logic: if this is a container and it contains all shapes processed so far,
146
+ // use the container's bounds instead of the union. This prevents extra padding around
147
+ // things like annotated images.
148
+ if (isContainer && Box.ContainsApproximately(maskedPageBounds, bbox)) {
149
+ isBoundedByContainer = true
150
+ bbox = maskedPageBounds.clone()
151
+ } else {
152
+ // If we were previously bounded by a container but this shape extends outside it,
153
+ // we're no longer bounded by a container
154
+ if (isBoundedByContainer && !Box.ContainsApproximately(bbox, maskedPageBounds)) {
155
+ isBoundedByContainer = false
156
+ }
157
+ // Expand the bounding box to include this shape
158
+ bbox.union(maskedPageBounds)
159
+ }
160
+ } else {
161
+ // First shape sets the initial bounds
162
+ isBoundedByContainer = isContainer
163
+ bbox = maskedPageBounds.clone()
164
+ }
165
+ }
166
+
167
+ // No unmasked shapes to export
168
+ if (!bbox) return null
169
+
170
+ // Only apply padding if:
171
+ // - Not exporting a single frame (frames have their own padding rules)
172
+ // - Not bounded by a container (containers define their own bounds precisely)
173
+ if (!singleFrameShapeId && !isBoundedByContainer) {
174
+ bbox.expandBy(padding)
175
+ }
176
+
177
+ return bbox
178
+ }
179
+
123
180
  function SvgExport({
124
181
  editor,
125
182
  preserveAspectRatio,
@@ -79,15 +79,15 @@ export function useCanvasEvents() {
79
79
  // check that e.target is an HTMLElement
80
80
  if (!(e.target instanceof HTMLElement)) return
81
81
 
82
+ const editingShapeId = editor.getEditingShape()?.id
82
83
  if (
84
+ // if the target is not inside the editing shape
85
+ !(editingShapeId && e.target.closest(`[data-shape-id="${editingShapeId}"]`)) &&
86
+ // and the target is not an clickable element
83
87
  e.target.tagName !== 'A' &&
88
+ // or a TextArea.tsx ?
84
89
  e.target.tagName !== 'TEXTAREA' &&
85
- !e.target.isContentEditable &&
86
- // When in EditingShape state, we are actually clicking on a 'DIV'
87
- // not A/TEXTAREA/contenteditable element yet. So, to preserve cursor position
88
- // for edit mode on mobile we need to not preventDefault.
89
- // TODO: Find out if we still need this preventDefault in general though.
90
- !(editor.getEditingShape() && e.target.className.includes('tl-text-content'))
90
+ !e.target.isContentEditable
91
91
  ) {
92
92
  preventDefault(e)
93
93
  }
@@ -1,14 +1,17 @@
1
1
  import { RefObject, useEffect } from 'react'
2
2
  import { preventDefault } from '../utils/dom'
3
3
  import { useContainer } from './useContainer'
4
+ import { useMaybeEditor } from './useEditor'
4
5
 
5
6
  /** @public */
6
7
  export function usePassThroughMouseOverEvents(ref: RefObject<HTMLElement>) {
7
8
  if (!ref) throw Error('usePassThroughWheelEvents must be passed a ref')
8
9
  const container = useContainer()
10
+ const editor = useMaybeEditor()
9
11
 
10
12
  useEffect(() => {
11
13
  function onMouseOver(e: MouseEvent) {
14
+ if (!editor?.getInstanceState().isFocused) return
12
15
  if ((e as any).isSpecialRedispatchedEvent) return
13
16
  preventDefault(e)
14
17
  const cvs = container.querySelector('.tl-canvas')
@@ -25,5 +28,5 @@ export function usePassThroughMouseOverEvents(ref: RefObject<HTMLElement>) {
25
28
  return () => {
26
29
  elm.removeEventListener('mouseover', onMouseOver)
27
30
  }
28
- }, [container, ref])
31
+ }, [container, editor, ref])
29
32
  }
@@ -4,7 +4,7 @@ import { publishDates } from '../../version'
4
4
  import { str2ab } from '../utils/licensing'
5
5
  import {
6
6
  FLAGS,
7
- isEditorUnlicensed,
7
+ getLicenseState,
8
8
  LicenseManager,
9
9
  PROPERTIES,
10
10
  ValidLicenseKeyResult,
@@ -487,115 +487,122 @@ function getDefaultLicenseResult(overrides: Partial<ValidLicenseKeyResult>): Val
487
487
  }
488
488
  }
489
489
 
490
- describe(isEditorUnlicensed, () => {
491
- it('shows watermark when license is not parseable', () => {
490
+ describe('getLicenseState', () => {
491
+ it('returns "unlicensed" for unparseable license', () => {
492
492
  const licenseResult = getDefaultLicenseResult({
493
493
  // @ts-ignore
494
494
  isLicenseParseable: false,
495
495
  })
496
- expect(isEditorUnlicensed(licenseResult)).toBe(true)
496
+ expect(getLicenseState(licenseResult)).toBe('unlicensed')
497
497
  })
498
498
 
499
- it('shows watermark when domain is not valid', () => {
499
+ it('returns "unlicensed" for invalid domain in production', () => {
500
500
  const licenseResult = getDefaultLicenseResult({
501
501
  isDomainValid: false,
502
+ isDevelopment: false,
502
503
  })
503
- expect(isEditorUnlicensed(licenseResult)).toBe(true)
504
+ expect(getLicenseState(licenseResult)).toBe('unlicensed')
504
505
  })
505
506
 
506
- it('shows watermark when annual license has expired', () => {
507
+ it('returns "licensed" for invalid domain in development mode', () => {
508
+ const licenseResult = getDefaultLicenseResult({
509
+ isDomainValid: false,
510
+ isDevelopment: true,
511
+ })
512
+ expect(getLicenseState(licenseResult)).toBe('licensed')
513
+ })
514
+
515
+ it('returns "unlicensed" for expired annual license', () => {
507
516
  const licenseResult = getDefaultLicenseResult({
508
517
  isAnnualLicense: true,
509
518
  isAnnualLicenseExpired: true,
519
+ isInternalLicense: false,
510
520
  })
511
- expect(isEditorUnlicensed(licenseResult)).toBe(true)
521
+ expect(getLicenseState(licenseResult)).toBe('unlicensed')
512
522
  })
513
523
 
514
- it('shows watermark when annual license has expired, even if dev mode', () => {
524
+ it('returns "unlicensed" for expired annual license even in dev mode', () => {
515
525
  const licenseResult = getDefaultLicenseResult({
516
526
  isAnnualLicense: true,
517
527
  isAnnualLicenseExpired: true,
518
528
  isDevelopment: true,
529
+ isInternalLicense: false,
519
530
  })
520
- expect(isEditorUnlicensed(licenseResult)).toBe(true)
531
+ expect(getLicenseState(licenseResult)).toBe('unlicensed')
521
532
  })
522
533
 
523
- it('shows watermark when perpetual license has expired', () => {
534
+ it('returns "unlicensed" for expired perpetual license', () => {
524
535
  const licenseResult = getDefaultLicenseResult({
525
536
  isPerpetualLicense: true,
526
537
  isPerpetualLicenseExpired: true,
538
+ isInternalLicense: false,
527
539
  })
528
- expect(isEditorUnlicensed(licenseResult)).toBe(true)
540
+ expect(getLicenseState(licenseResult)).toBe('unlicensed')
529
541
  })
530
542
 
531
- it('does not show watermark when license is valid and not expired', () => {
543
+ it('returns "internal-expired" for expired internal annual license with valid domain', () => {
544
+ const expiryDate = new Date(2023, 1, 1)
532
545
  const licenseResult = getDefaultLicenseResult({
533
546
  isAnnualLicense: true,
534
- isAnnualLicenseExpired: false,
535
- isInternalLicense: false,
547
+ isAnnualLicenseExpired: true,
548
+ isInternalLicense: true,
549
+ isDomainValid: true,
550
+ expiryDate,
536
551
  })
537
- expect(isEditorUnlicensed(licenseResult)).toBe(false)
552
+ expect(getLicenseState(licenseResult)).toBe('internal-expired')
538
553
  })
539
554
 
540
- it('does not show watermark when perpetual license is valid and not expired', () => {
555
+ it('returns "internal-expired" for expired internal perpetual license with valid domain', () => {
556
+ const expiryDate = new Date(2023, 1, 1)
541
557
  const licenseResult = getDefaultLicenseResult({
542
558
  isPerpetualLicense: true,
543
- isPerpetualLicenseExpired: false,
544
- isInternalLicense: false,
545
- })
546
- expect(isEditorUnlicensed(licenseResult)).toBe(false)
547
- })
548
-
549
- it('does not show watermark when in development mode', () => {
550
- const licenseResult = getDefaultLicenseResult({
551
- isDevelopment: true,
559
+ isPerpetualLicenseExpired: true,
560
+ isInternalLicense: true,
561
+ isDomainValid: true,
562
+ expiryDate,
552
563
  })
553
- expect(isEditorUnlicensed(licenseResult)).toBe(false)
564
+ expect(getLicenseState(licenseResult)).toBe('internal-expired')
554
565
  })
555
566
 
556
- it('does not show watermark when license is parseable and domain is valid', () => {
567
+ it('returns "unlicensed" for expired internal license with invalid domain', () => {
568
+ const expiryDate = new Date(2023, 1, 1)
557
569
  const licenseResult = getDefaultLicenseResult({
558
- isLicenseParseable: true,
559
- isDomainValid: true,
570
+ isAnnualLicense: true,
571
+ isAnnualLicenseExpired: true,
572
+ isInternalLicense: true,
573
+ isDomainValid: false,
574
+ expiryDate,
560
575
  })
561
- expect(isEditorUnlicensed(licenseResult)).toBe(false)
576
+ expect(getLicenseState(licenseResult)).toBe('unlicensed')
562
577
  })
563
578
 
564
- it('does not show watermark when license is parseable and domain is not valid and dev mode', () => {
579
+ it('returns "licensed-with-watermark" for watermarked license', () => {
565
580
  const licenseResult = getDefaultLicenseResult({
566
- isLicenseParseable: true,
567
- isDomainValid: false,
568
- isDevelopment: true,
581
+ isLicensedWithWatermark: true,
569
582
  })
570
- expect(isEditorUnlicensed(licenseResult)).toBe(false)
583
+ expect(getLicenseState(licenseResult)).toBe('licensed-with-watermark')
571
584
  })
572
585
 
573
- it('throws when an internal annual license has expired', () => {
574
- const expiryDate = new Date(2023, 1, 1)
586
+ it('returns "licensed" for valid annual license', () => {
575
587
  const licenseResult = getDefaultLicenseResult({
576
588
  isAnnualLicense: true,
577
- isAnnualLicenseExpired: true,
578
- isInternalLicense: true,
579
- expiryDate,
589
+ isAnnualLicenseExpired: false,
580
590
  })
581
- expect(() => isEditorUnlicensed(licenseResult)).toThrow(/License: Internal license expired/)
591
+ expect(getLicenseState(licenseResult)).toBe('licensed')
582
592
  })
583
593
 
584
- it('throws when an internal perpetual license has expired', () => {
585
- const expiryDate = new Date(2023, 1, 1)
594
+ it('returns "licensed" for valid perpetual license', () => {
586
595
  const licenseResult = getDefaultLicenseResult({
587
596
  isPerpetualLicense: true,
588
- isPerpetualLicenseExpired: true,
589
- isInternalLicense: true,
590
- expiryDate,
597
+ isPerpetualLicenseExpired: false,
591
598
  })
592
- expect(() => isEditorUnlicensed(licenseResult)).toThrow(/License: Internal license expired/)
599
+ expect(getLicenseState(licenseResult)).toBe('licensed')
593
600
  })
594
601
 
595
- it('shows watermark when license has that flag specified', () => {
602
+ it('returns "licensed" for valid license in development mode', () => {
596
603
  const licenseResult = getDefaultLicenseResult({
597
- isLicensedWithWatermark: true,
604
+ isDevelopment: true,
598
605
  })
599
- expect(isEditorUnlicensed(licenseResult)).toBe(false)
606
+ expect(getLicenseState(licenseResult)).toBe('licensed')
600
607
  })
601
608
  })
@@ -33,6 +33,14 @@ export interface LicenseInfo {
33
33
  flags: number
34
34
  expiryDate: string
35
35
  }
36
+
37
+ /** @internal */
38
+ export type LicenseState =
39
+ | 'pending'
40
+ | 'licensed'
41
+ | 'licensed-with-watermark'
42
+ | 'unlicensed'
43
+ | 'internal-expired'
36
44
  /** @internal */
37
45
  export type InvalidLicenseReason =
38
46
  | 'invalid-license-key'
@@ -73,10 +81,7 @@ export class LicenseManager {
73
81
  public isDevelopment: boolean
74
82
  public isTest: boolean
75
83
  public isCryptoAvailable: boolean
76
- state = atom<'pending' | 'licensed' | 'licensed-with-watermark' | 'unlicensed'>(
77
- 'license state',
78
- 'pending'
79
- )
84
+ state = atom<LicenseState>('license state', 'pending')
80
85
  public verbose = true
81
86
 
82
87
  constructor(
@@ -89,21 +94,20 @@ export class LicenseManager {
89
94
  this.publicKey = testPublicKey || this.publicKey
90
95
  this.isCryptoAvailable = !!crypto.subtle
91
96
 
92
- this.getLicenseFromKey(licenseKey).then((result) => {
93
- const isUnlicensed = isEditorUnlicensed(result)
97
+ this.getLicenseFromKey(licenseKey)
98
+ .then((result) => {
99
+ const licenseState = getLicenseState(result)
94
100
 
95
- if (!this.isDevelopment && isUnlicensed) {
96
- fetch(WATERMARK_TRACK_SRC)
97
- }
101
+ if (!this.isDevelopment && licenseState === 'unlicensed') {
102
+ fetch(WATERMARK_TRACK_SRC)
103
+ }
98
104
 
99
- if (isUnlicensed) {
105
+ this.state.set(licenseState)
106
+ })
107
+ .catch((error) => {
108
+ console.error('License validation failed:', error)
100
109
  this.state.set('unlicensed')
101
- } else if ((result as ValidLicenseKeyResult).isLicensedWithWatermark) {
102
- this.state.set('licensed-with-watermark')
103
- } else {
104
- this.state.set('licensed')
105
- }
106
- })
110
+ })
107
111
  }
108
112
 
109
113
  private getIsDevelopment(testEnvironment?: TestEnvironment) {
@@ -367,15 +371,19 @@ export class LicenseManager {
367
371
  static className = 'tl-watermark_SEE-LICENSE'
368
372
  }
369
373
 
370
- export function isEditorUnlicensed(result: LicenseFromKeyResult) {
371
- if (!result.isLicenseParseable) return true
372
- if (!result.isDomainValid && !result.isDevelopment) return true
374
+ export function getLicenseState(result: LicenseFromKeyResult): LicenseState {
375
+ if (!result.isLicenseParseable) return 'unlicensed'
376
+ if (!result.isDomainValid && !result.isDevelopment) return 'unlicensed'
373
377
  if (result.isPerpetualLicenseExpired || result.isAnnualLicenseExpired) {
374
- if (result.isInternalLicense) {
375
- throw new Error('License: Internal license expired.')
376
- }
377
- return true
378
+ // Check if it's an expired internal license with valid domain
379
+ const internalExpired = result.isInternalLicense && result.isDomainValid
380
+ return internalExpired ? 'internal-expired' : 'unlicensed'
381
+ }
382
+
383
+ // License is valid, determine if it has watermark
384
+ if (result.isLicensedWithWatermark) {
385
+ return 'licensed-with-watermark'
378
386
  }
379
387
 
380
- return false
388
+ return 'licensed'
381
389
  }
@@ -1,3 +1,4 @@
1
+ import { useValue } from '@tldraw/state-react'
1
2
  import { createContext, ReactNode, useContext, useState } from 'react'
2
3
  import { LicenseManager } from './LicenseManager'
3
4
 
@@ -16,5 +17,12 @@ export function LicenseProvider({
16
17
  children: ReactNode
17
18
  }) {
18
19
  const [licenseManager] = useState(() => new LicenseManager(licenseKey))
20
+ const licenseState = useValue(licenseManager.state)
21
+
22
+ // If internal license has expired, don't render the editor at all
23
+ if (licenseState === 'internal-expired') {
24
+ return <div data-testid="tl-license-expired" style={{ display: 'none' }} />
25
+ }
26
+
19
27
  return <LicenseContext.Provider value={licenseManager}>{children}</LicenseContext.Provider>
20
28
  }
@@ -86,8 +86,8 @@ To remove the watermark, please purchase a license at tldraw.dev.
86
86
 
87
87
  .${className} {
88
88
  position: absolute;
89
- bottom: var(--tl-space-2);
90
- right: var(--tl-space-2);
89
+ bottom: max(var(--tl-space-2), env(safe-area-inset-bottom));
90
+ right: max(var(--tl-space-2), env(safe-area-inset-right));
91
91
  width: 96px;
92
92
  height: 32px;
93
93
  display: flex;
@@ -116,12 +116,12 @@ To remove the watermark, please purchase a license at tldraw.dev.
116
116
  }
117
117
 
118
118
  .${className}[data-debug='true'] {
119
- bottom: 46px;
119
+ bottom: max(46px, env(safe-area-inset-bottom));
120
120
  }
121
121
 
122
122
  .${className}[data-mobile='true'] {
123
123
  border-radius: 4px 0px 0px 4px;
124
- right: -2px;
124
+ right: max(-2px, calc(env(safe-area-inset-right) - 2px));
125
125
  width: 8px;
126
126
  height: 48px;
127
127
  }
@@ -1,7 +1,7 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
- import { LicenseManager } from './LicenseManager'
2
+ import { LicenseManager, LicenseState } from './LicenseManager'
3
3
 
4
4
  /** @internal */
5
- export function useLicenseManagerState(licenseManager: LicenseManager) {
5
+ export function useLicenseManagerState(licenseManager: LicenseManager): LicenseState {
6
6
  return useValue('watermarkState', () => licenseManager.state.get(), [licenseManager])
7
7
  }
@@ -510,6 +510,132 @@ describe('Box', () => {
510
510
  })
511
511
  })
512
512
 
513
+ describe('Box.ContainsApproximately', () => {
514
+ it('returns true when first box exactly contains second', () => {
515
+ const boxA = new Box(0, 0, 100, 100)
516
+ const boxB = new Box(10, 10, 50, 50)
517
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
518
+ })
519
+
520
+ it('returns false when first box clearly does not contain second', () => {
521
+ const boxA = new Box(0, 0, 50, 50)
522
+ const boxB = new Box(10, 10, 100, 100)
523
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(false)
524
+ })
525
+
526
+ it('returns true when containment is within default precision tolerance', () => {
527
+ // Box B extends very slightly outside A (within floating-point precision)
528
+ const boxA = new Box(0, 0, 100, 100)
529
+ const boxB = new Box(10, 10, 80, 80)
530
+ // Move B's max edges just slightly outside A's bounds
531
+ boxB.w = 90.000000000001 // maxX = 100.000000000001 (slightly beyond 100)
532
+ boxB.h = 90.000000000001 // maxY = 100.000000000001 (slightly beyond 100)
533
+
534
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
535
+ expect(Box.Contains(boxA, boxB)).toBe(false) // strict contains would fail
536
+ })
537
+
538
+ it('returns false when containment exceeds default precision tolerance', () => {
539
+ const boxA = new Box(0, 0, 100, 100)
540
+ const boxB = new Box(10, 10, 80, 80)
541
+ // Move B's max edges clearly outside A's bounds
542
+ boxB.w = 95 // maxX = 105 (clearly beyond 100)
543
+ boxB.h = 95 // maxY = 105 (clearly beyond 100)
544
+
545
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(false)
546
+ })
547
+
548
+ it('respects custom precision parameter', () => {
549
+ const boxA = new Box(0, 0, 100, 100)
550
+ const boxB = new Box(10, 10, 85, 85) // maxX=95, maxY=95
551
+
552
+ // With loose precision (10), should contain (95 is within 100-10=90 tolerance)
553
+ expect(Box.ContainsApproximately(boxA, boxB, 10)).toBe(true)
554
+
555
+ // With tight precision (4), should still contain (95 is within 100-4=96)
556
+ expect(Box.ContainsApproximately(boxA, boxB, 4)).toBe(true)
557
+
558
+ // Since 95 < 100, the precision parameter doesn't affect containment here
559
+ expect(Box.ContainsApproximately(boxA, boxB, 4.9)).toBe(true)
560
+ })
561
+
562
+ it('handles negative coordinates correctly', () => {
563
+ const boxA = new Box(-50, -50, 100, 100) // bounds: (-50,-50) to (50,50)
564
+ const boxB = new Box(-40, -40, 79.999999999, 79.999999999) // bounds: (-40,-40) to (39.999999999, 39.999999999)
565
+
566
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
567
+ })
568
+
569
+ it('handles edge case where boxes are identical', () => {
570
+ const boxA = new Box(10, 20, 100, 200)
571
+ const boxB = new Box(10, 20, 100, 200)
572
+
573
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
574
+ })
575
+
576
+ it('handles edge case where inner box touches outer box edges', () => {
577
+ const boxA = new Box(0, 0, 100, 100)
578
+ const boxB = new Box(0, 0, 100, 100) // exactly the same
579
+
580
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
581
+
582
+ // Slightly smaller inner box
583
+ const boxC = new Box(0.000001, 0.000001, 99.999998, 99.999998)
584
+ expect(Box.ContainsApproximately(boxA, boxC)).toBe(true)
585
+ })
586
+
587
+ it('handles floating-point precision issues in real-world scenarios', () => {
588
+ // Simulate common floating-point arithmetic issues
589
+ const containerBox = new Box(0, 0, 100, 100)
590
+
591
+ // Box that should be contained but has floating-point errors
592
+ const innerBox = new Box(10, 10, 80, 80)
593
+ // Simulate floating-point arithmetic that results in tiny overruns
594
+ innerBox.w = 90.00000000000001 // maxX = 100.00000000000001 (tiny overrun)
595
+ innerBox.h = 90.00000000000001 // maxY = 100.00000000000001 (tiny overrun)
596
+
597
+ expect(Box.ContainsApproximately(containerBox, innerBox)).toBe(true)
598
+ expect(Box.Contains(containerBox, innerBox)).toBe(false) // strict contains fails due to precision
599
+ })
600
+
601
+ it('fails when any edge exceeds tolerance', () => {
602
+ const boxA = new Box(10, 10, 100, 100) // bounds: (10,10) to (110,110)
603
+
604
+ // Test each edge exceeding tolerance
605
+ const testCases = [
606
+ { name: 'left edge', box: new Box(5, 20, 80, 80) }, // minX too small
607
+ { name: 'top edge', box: new Box(20, 5, 80, 80) }, // minY too small
608
+ { name: 'right edge', box: new Box(20, 20, 95, 80) }, // maxX too large (20+95=115 > 110)
609
+ { name: 'bottom edge', box: new Box(20, 20, 80, 95) }, // maxY too large (20+95=115 > 110)
610
+ ]
611
+
612
+ testCases.forEach(({ box }) => {
613
+ expect(Box.ContainsApproximately(boxA, box, 1)).toBe(false) // tight precision
614
+ })
615
+ })
616
+
617
+ it('works with zero-sized dimensions', () => {
618
+ const boxA = new Box(0, 0, 100, 100)
619
+ const boxB = new Box(50, 50, 0, 0) // zero-sized box (point)
620
+
621
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
622
+ })
623
+
624
+ it('handles precision parameter edge cases', () => {
625
+ const boxA = new Box(0, 0, 100, 100)
626
+ const boxB = new Box(10, 10, 91, 91) // maxX=101, maxY=101 (clearly outside)
627
+
628
+ // Zero precision should work like strict Contains
629
+ expect(Box.ContainsApproximately(boxA, boxB, 0)).toBe(false)
630
+
631
+ // Small precision should still fail (101 > 100)
632
+ expect(Box.ContainsApproximately(boxA, boxB, 0.5)).toBe(false)
633
+
634
+ // Sufficient precision should succeed (101 <= 100 + 2)
635
+ expect(Box.ContainsApproximately(boxA, boxB, 2)).toBe(true)
636
+ })
637
+ })
638
+
513
639
  describe('Box.Includes', () => {
514
640
  it('returns true when boxes collide or contain', () => {
515
641
  const boxA = new Box(0, 0, 50, 50)
@@ -1,6 +1,6 @@
1
1
  import { BoxModel } from '@tldraw/tlschema'
2
2
  import { Vec, VecLike } from './Vec'
3
- import { PI, PI2, toPrecision } from './utils'
3
+ import { approximatelyLte, PI, PI2, toPrecision } from './utils'
4
4
 
5
5
  /** @public */
6
6
  export type BoxLike = BoxModel | Box
@@ -417,6 +417,15 @@ export class Box {
417
417
  return A.minX < B.minX && A.minY < B.minY && A.maxY > B.maxY && A.maxX > B.maxX
418
418
  }
419
419
 
420
+ static ContainsApproximately(A: Box, B: Box, precision?: number) {
421
+ return (
422
+ approximatelyLte(A.minX, B.minX, precision) &&
423
+ approximatelyLte(A.minY, B.minY, precision) &&
424
+ approximatelyLte(B.maxX, A.maxX, precision) &&
425
+ approximatelyLte(B.maxY, A.maxY, precision)
426
+ )
427
+ }
428
+
420
429
  static Includes(A: Box, B: Box) {
421
430
  return Box.Collides(A, B) || Box.Contains(A, B)
422
431
  }
@@ -240,11 +240,6 @@ export class Vec {
240
240
  return Vec.EqualsXY(this, x, y)
241
241
  }
242
242
 
243
- /** @deprecated use `uni` instead */
244
- norm() {
245
- return this.uni()
246
- }
247
-
248
243
  toFixed() {
249
244
  this.x = toFixed(this.x)
250
245
  this.y = toFixed(this.y)