@tldraw/editor 3.16.0-next.eafb52d15064 → 3.16.0-next.fe14f1b4181f

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 (123) hide show
  1. package/dist-cjs/index.d.ts +30 -0
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/TldrawEditor.js +6 -2
  4. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  5. package/dist-cjs/lib/components/MenuClickCapture.js +0 -5
  6. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  7. package/dist-cjs/lib/components/Shape.js +7 -10
  8. package/dist-cjs/lib/components/Shape.js.map +2 -2
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +4 -23
  10. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  11. package/dist-cjs/lib/components/default-components/DefaultCollaboratorHint.js +1 -1
  12. package/dist-cjs/lib/components/default-components/DefaultCollaboratorHint.js.map +1 -1
  13. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
  14. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
  15. package/dist-cjs/lib/components/default-components/DefaultScribble.js +1 -1
  16. package/dist-cjs/lib/components/default-components/DefaultScribble.js.map +2 -2
  17. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js +9 -1
  18. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js.map +2 -2
  19. package/dist-cjs/lib/config/TLUserPreferences.js +1 -1
  20. package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
  21. package/dist-cjs/lib/editor/Editor.js +44 -15
  22. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +1 -1
  24. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +13 -0
  26. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  27. package/dist-cjs/lib/exports/getSvgJsx.js +35 -16
  28. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  29. package/dist-cjs/lib/hooks/useCanvasEvents.js +31 -25
  30. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  31. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +4 -1
  32. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  33. package/dist-cjs/lib/license/Watermark.js +6 -6
  34. package/dist-cjs/lib/license/Watermark.js.map +1 -1
  35. package/dist-cjs/lib/options.js +6 -0
  36. package/dist-cjs/lib/options.js.map +2 -2
  37. package/dist-cjs/lib/primitives/Box.js +3 -0
  38. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  39. package/dist-cjs/version.js +3 -3
  40. package/dist-cjs/version.js.map +1 -1
  41. package/dist-esm/index.d.mts +30 -0
  42. package/dist-esm/index.mjs +1 -1
  43. package/dist-esm/lib/TldrawEditor.mjs +6 -2
  44. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  45. package/dist-esm/lib/components/MenuClickCapture.mjs +0 -5
  46. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  47. package/dist-esm/lib/components/Shape.mjs +7 -10
  48. package/dist-esm/lib/components/Shape.mjs.map +2 -2
  49. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +4 -23
  50. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  51. package/dist-esm/lib/components/default-components/DefaultCollaboratorHint.mjs +1 -1
  52. package/dist-esm/lib/components/default-components/DefaultCollaboratorHint.mjs.map +1 -1
  53. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  54. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
  55. package/dist-esm/lib/components/default-components/DefaultScribble.mjs +1 -1
  56. package/dist-esm/lib/components/default-components/DefaultScribble.mjs.map +2 -2
  57. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs +9 -1
  58. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs.map +2 -2
  59. package/dist-esm/lib/config/TLUserPreferences.mjs +1 -1
  60. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  61. package/dist-esm/lib/editor/Editor.mjs +44 -15
  62. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  63. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +1 -1
  64. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  65. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +13 -0
  66. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  67. package/dist-esm/lib/exports/getSvgJsx.mjs +36 -16
  68. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  69. package/dist-esm/lib/hooks/useCanvasEvents.mjs +32 -26
  70. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  71. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +4 -1
  72. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  73. package/dist-esm/lib/license/Watermark.mjs +6 -6
  74. package/dist-esm/lib/license/Watermark.mjs.map +1 -1
  75. package/dist-esm/lib/options.mjs +6 -0
  76. package/dist-esm/lib/options.mjs.map +2 -2
  77. package/dist-esm/lib/primitives/Box.mjs +4 -1
  78. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  79. package/dist-esm/version.mjs +3 -3
  80. package/dist-esm/version.mjs.map +1 -1
  81. package/editor.css +301 -290
  82. package/package.json +14 -37
  83. package/src/lib/TldrawEditor.tsx +11 -6
  84. package/src/lib/components/MenuClickCapture.tsx +0 -8
  85. package/src/lib/components/Shape.tsx +6 -12
  86. package/src/lib/components/default-components/DefaultCanvas.tsx +5 -22
  87. package/src/lib/components/default-components/DefaultCollaboratorHint.tsx +1 -1
  88. package/src/lib/components/default-components/DefaultErrorFallback.tsx +1 -1
  89. package/src/lib/components/default-components/DefaultScribble.tsx +1 -1
  90. package/src/lib/components/default-components/DefaultShapeIndicator.tsx +5 -1
  91. package/src/lib/config/TLUserPreferences.ts +1 -1
  92. package/src/lib/editor/Editor.test.ts +12 -11
  93. package/src/lib/editor/Editor.ts +55 -20
  94. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +15 -14
  95. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +16 -15
  96. package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +49 -48
  97. package/src/lib/editor/managers/FontManager/FontManager.test.ts +24 -23
  98. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +7 -6
  99. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.test.ts +12 -11
  100. package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +57 -50
  101. package/src/lib/editor/managers/TextManager/TextManager.test.ts +51 -26
  102. package/src/lib/editor/managers/TickManager/TickManager.test.ts +14 -13
  103. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +21 -26
  104. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +1 -1
  105. package/src/lib/editor/shapes/ShapeUtil.ts +14 -0
  106. package/src/lib/exports/getSvgJsx.test.ts +868 -0
  107. package/src/lib/exports/getSvgJsx.tsx +78 -21
  108. package/src/lib/hooks/useCanvasEvents.ts +45 -38
  109. package/src/lib/hooks/usePassThroughWheelEvents.ts +6 -1
  110. package/src/lib/license/LicenseManager.test.ts +3 -1
  111. package/src/lib/license/Watermark.test.tsx +2 -1
  112. package/src/lib/license/Watermark.tsx +6 -6
  113. package/src/lib/options.ts +6 -0
  114. package/src/lib/primitives/Box.test.ts +126 -0
  115. package/src/lib/primitives/Box.ts +10 -1
  116. package/src/lib/utils/sync/LocalIndexedDb.test.ts +2 -1
  117. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +15 -15
  118. package/src/version.ts +3 -3
  119. package/dist-cjs/lib/utils/nearestMultiple.js +0 -34
  120. package/dist-cjs/lib/utils/nearestMultiple.js.map +0 -7
  121. package/dist-esm/lib/utils/nearestMultiple.mjs +0 -14
  122. package/dist-esm/lib/utils/nearestMultiple.mjs.map +0 -7
  123. package/src/lib/utils/nearestMultiple.ts +0 -13
@@ -4,6 +4,7 @@ import {
4
4
  TLGroupShape,
5
5
  TLShape,
6
6
  TLShapeId,
7
+ getColorValue,
7
8
  getDefaultColorTheme,
8
9
  } from '@tldraw/tlschema'
9
10
  import { hasOwnProperty, promiseWithResolve, uniqueId } from '@tldraw/utils'
@@ -56,33 +57,21 @@ export function getSvgJsx(editor: Editor, ids: TLShapeId[], opts: TLImageExportO
56
57
  .filter(({ id }) => shapeIdsToInclude.has(id))
57
58
 
58
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
+
59
65
  let bbox: null | Box = null
60
66
  if (opts.bounds) {
61
- bbox = opts.bounds
67
+ bbox = opts.bounds.clone().expandBy(padding)
62
68
  } else {
63
- for (const { id } of renderingShapes) {
64
- const maskedPageBounds = editor.getShapeMaskedPageBounds(id)
65
- if (!maskedPageBounds) continue
66
- if (bbox) {
67
- bbox.union(maskedPageBounds)
68
- } else {
69
- bbox = maskedPageBounds.clone()
70
- }
71
- }
69
+ bbox = getExportDefaultBounds(editor, renderingShapes, padding, singleFrameShapeId)
72
70
  }
73
71
 
74
72
  // no unmasked shapes to export
75
73
  if (!bbox) return
76
74
 
77
- const singleFrameShapeId =
78
- ids.length === 1 && editor.isShapeOfType<TLFrameShape>(editor.getShape(ids[0])!, 'frame')
79
- ? ids[0]
80
- : null
81
- if (!singleFrameShapeId) {
82
- // Expand by an extra 32 pixels
83
- bbox.expandBy(padding)
84
- }
85
-
86
75
  // We want the svg image to be BIGGER THAN USUAL to account for image quality
87
76
  const w = bbox.width * scale
88
77
  const h = bbox.height * scale
@@ -119,6 +108,75 @@ export function getSvgJsx(editor: Editor, ids: TLShapeId[], opts: TLImageExportO
119
108
  return { jsx: svg, width: w, height: h, exportDelay }
120
109
  }
121
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
+
122
180
  function SvgExport({
123
181
  editor,
124
182
  preserveAspectRatio,
@@ -373,8 +431,7 @@ function SvgExport({
373
431
  | { options: { showColors: boolean } }
374
432
  if (frameShapeUtil?.options.showColors) {
375
433
  const shape = editor.getShape(singleFrameShapeId)! as TLFrameShape
376
- const color = theme[shape.props.color]
377
- backgroundColor = color.frame.fill
434
+ backgroundColor = getColorValue(theme, shape.props.color, 'frameFill')
378
435
  } else {
379
436
  backgroundColor = theme.solid
380
437
  }
@@ -1,5 +1,5 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
- import React, { useMemo } from 'react'
2
+ import React, { useEffect, useMemo } from 'react'
3
3
  import { RIGHT_MOUSE_BUTTON } from '../constants'
4
4
  import {
5
5
  preventDefault,
@@ -16,9 +16,6 @@ export function useCanvasEvents() {
16
16
 
17
17
  const events = useMemo(
18
18
  function canvasEvents() {
19
- // Track the last screen point
20
- let lastX: number, lastY: number
21
-
22
19
  function onPointerDown(e: React.PointerEvent) {
23
20
  if ((e as any).isKilled) return
24
21
 
@@ -44,35 +41,9 @@ export function useCanvasEvents() {
44
41
  })
45
42
  }
46
43
 
47
- function onPointerMove(e: React.PointerEvent) {
48
- if ((e as any).isKilled) return
49
-
50
- if (e.clientX === lastX && e.clientY === lastY) return
51
- lastX = e.clientX
52
- lastY = e.clientY
53
-
54
- // For tools that benefit from a higher fidelity of events,
55
- // we dispatch the coalesced events.
56
- // N.B. Sometimes getCoalescedEvents isn't present on iOS, ugh.
57
- const events =
58
- currentTool.useCoalescedEvents && e.nativeEvent.getCoalescedEvents
59
- ? e.nativeEvent.getCoalescedEvents()
60
- : [e]
61
- for (const singleEvent of events) {
62
- editor.dispatch({
63
- type: 'pointer',
64
- target: 'canvas',
65
- name: 'pointer_move',
66
- ...getPointerInfo(singleEvent),
67
- })
68
- }
69
- }
70
-
71
44
  function onPointerUp(e: React.PointerEvent) {
72
45
  if ((e as any).isKilled) return
73
46
  if (e.button !== 0 && e.button !== 1 && e.button !== 2 && e.button !== 5) return
74
- lastX = e.clientX
75
- lastY = e.clientY
76
47
 
77
48
  releasePointerCapture(e.currentTarget, e)
78
49
 
@@ -108,15 +79,15 @@ export function useCanvasEvents() {
108
79
  // check that e.target is an HTMLElement
109
80
  if (!(e.target instanceof HTMLElement)) return
110
81
 
82
+ const editingShapeId = editor.getEditingShape()?.id
111
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
112
87
  e.target.tagName !== 'A' &&
88
+ // or a TextArea.tsx ?
113
89
  e.target.tagName !== 'TEXTAREA' &&
114
- !e.target.isContentEditable &&
115
- // When in EditingShape state, we are actually clicking on a 'DIV'
116
- // not A/TEXTAREA/contenteditable element yet. So, to preserve cursor position
117
- // for edit mode on mobile we need to not preventDefault.
118
- // TODO: Find out if we still need this preventDefault in general though.
119
- !(editor.getEditingShape() && e.target.className.includes('tl-text-content'))
90
+ !e.target.isContentEditable
120
91
  ) {
121
92
  preventDefault(e)
122
93
  }
@@ -158,7 +129,6 @@ export function useCanvasEvents() {
158
129
 
159
130
  return {
160
131
  onPointerDown,
161
- onPointerMove,
162
132
  onPointerUp,
163
133
  onPointerEnter,
164
134
  onPointerLeave,
@@ -169,8 +139,45 @@ export function useCanvasEvents() {
169
139
  onClick,
170
140
  }
171
141
  },
172
- [editor, currentTool]
142
+ [editor]
173
143
  )
174
144
 
145
+ // onPointerMove is special: where we're only interested in the other events when they're
146
+ // happening _on_ the canvas (as opposed to outside of it, or on UI floating over it), we want
147
+ // the pointer position to be up to date regardless of whether it's over the tldraw canvas or
148
+ // not. So instead of returning a listener to be attached to the canvas, we directly attach a
149
+ // listener to the whole document instead.
150
+ useEffect(() => {
151
+ let lastX: number, lastY: number
152
+
153
+ function onPointerMove(e: PointerEvent) {
154
+ if ((e as any).isKilled) return
155
+ ;(e as any).isKilled = true
156
+
157
+ if (e.clientX === lastX && e.clientY === lastY) return
158
+ lastX = e.clientX
159
+ lastY = e.clientY
160
+
161
+ // For tools that benefit from a higher fidelity of events,
162
+ // we dispatch the coalesced events.
163
+ // N.B. Sometimes getCoalescedEvents isn't present on iOS, ugh.
164
+ const events =
165
+ currentTool.useCoalescedEvents && e.getCoalescedEvents ? e.getCoalescedEvents() : [e]
166
+ for (const singleEvent of events) {
167
+ editor.dispatch({
168
+ type: 'pointer',
169
+ target: 'canvas',
170
+ name: 'pointer_move',
171
+ ...getPointerInfo(singleEvent),
172
+ })
173
+ }
174
+ }
175
+
176
+ document.body.addEventListener('pointermove', onPointerMove)
177
+ return () => {
178
+ document.body.removeEventListener('pointermove', onPointerMove)
179
+ }
180
+ }, [editor, currentTool])
181
+
175
182
  return events
176
183
  }
@@ -1,14 +1,19 @@
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 usePassThroughWheelEvents(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 onWheel(e: WheelEvent) {
14
+ // Only pass through wheel events if the editor is focused
15
+ if (!editor?.getInstanceState().isFocused) return
16
+
12
17
  if ((e as any).isSpecialRedispatchedEvent) return
13
18
 
14
19
  // if the element is scrollable, don't redispatch the event
@@ -32,5 +37,5 @@ export function usePassThroughWheelEvents(ref: RefObject<HTMLElement>) {
32
37
  return () => {
33
38
  elm.removeEventListener('wheel', onWheel)
34
39
  }
35
- }, [container, ref])
40
+ }, [container, editor, ref])
36
41
  }
@@ -1,4 +1,5 @@
1
1
  import crypto from 'crypto'
2
+ import { vi } from 'vitest'
2
3
  import { publishDates } from '../../version'
3
4
  import { str2ab } from '../utils/licensing'
4
5
  import {
@@ -9,8 +10,9 @@ import {
9
10
  ValidLicenseKeyResult,
10
11
  } from './LicenseManager'
11
12
 
12
- jest.mock('../../version', () => {
13
+ vi.mock('../../version', () => {
13
14
  return {
15
+ version: '3.15.1',
14
16
  publishDates: {
15
17
  major: '2024-06-28T10:56:07.893Z',
16
18
  minor: '2024-07-02T16:49:50.397Z',
@@ -1,10 +1,11 @@
1
1
  import { act, render, waitFor } from '@testing-library/react'
2
+ import { vi } from 'vitest'
2
3
  import { TldrawEditor } from '../TldrawEditor'
3
4
  import { LicenseManager } from './LicenseManager'
4
5
 
5
6
  let mockLicenseState = 'unlicensed'
6
7
 
7
- jest.mock('./useLicenseManagerState', () => ({
8
+ vi.mock('./useLicenseManagerState', () => ({
8
9
  useLicenseManagerState: () => mockLicenseState,
9
10
  }))
10
11
 
@@ -86,15 +86,15 @@ To remove the watermark, please purchase a license at tldraw.dev.
86
86
 
87
87
  .${className} {
88
88
  position: absolute;
89
- bottom: var(--space-2);
90
- right: var(--space-2);
89
+ bottom: var(--tl-space-2);
90
+ right: var(--tl-space-2);
91
91
  width: 96px;
92
92
  height: 32px;
93
93
  display: flex;
94
94
  align-items: center;
95
95
  justify-content: center;
96
- z-index: var(--layer-watermark) !important;
97
- background-color: color-mix(in srgb, var(--color-background) 62%, transparent);
96
+ z-index: var(--tl-layer-watermark) !important;
97
+ background-color: color-mix(in srgb, var(--tl-color-background) 62%, transparent);
98
98
  opacity: 1;
99
99
  border-radius: 5px;
100
100
  pointer-events: all;
@@ -108,7 +108,7 @@ To remove the watermark, please purchase a license at tldraw.dev.
108
108
  height: 32px;
109
109
  pointer-events: all;
110
110
  cursor: inherit;
111
- color: var(--color-text);
111
+ color: var(--tl-color-text);
112
112
  opacity: .38;
113
113
  border: 0;
114
114
  padding: 0;
@@ -137,7 +137,7 @@ To remove the watermark, please purchase a license at tldraw.dev.
137
137
  }
138
138
 
139
139
  .${className}:hover {
140
- background-color: var(--color-background);
140
+ background-color: var(--tl-color-background);
141
141
  transition: background-color 0.2s ease-in-out;
142
142
  transition-delay: 0.32s;
143
143
  }
@@ -27,6 +27,8 @@ export interface TldrawOptions {
27
27
  readonly multiClickDurationMs: number
28
28
  readonly coarseDragDistanceSquared: number
29
29
  readonly dragDistanceSquared: number
30
+ readonly uiDragDistanceSquared: number
31
+ readonly uiCoarseDragDistanceSquared: number
30
32
  readonly defaultSvgPadding: number
31
33
  readonly cameraSlideFriction: number
32
34
  readonly gridSteps: readonly {
@@ -98,6 +100,10 @@ export const defaultTldrawOptions = {
98
100
  multiClickDurationMs: 200,
99
101
  coarseDragDistanceSquared: 36, // 6 squared
100
102
  dragDistanceSquared: 16, // 4 squared
103
+ uiDragDistanceSquared: 16, // 4 squared
104
+ // it's really easy to accidentally drag from the toolbar on mobile, so we use a much larger
105
+ // threshold than usual here to try and prevent accidental drags.
106
+ uiCoarseDragDistanceSquared: 625, // 25 squared
101
107
  defaultSvgPadding: 32,
102
108
  cameraSlideFriction: 0.09,
103
109
  gridSteps: [
@@ -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
  }
@@ -1,12 +1,13 @@
1
1
  import { createTLSchema } from '@tldraw/tlschema'
2
2
  import { openDB } from 'idb'
3
+ import { vi } from 'vitest'
3
4
  import { hardReset } from './hardReset'
4
5
  import { getAllIndexDbNames, LocalIndexedDb } from './LocalIndexedDb'
5
6
 
6
7
  const schema = createTLSchema({ shapes: {}, bindings: {} })
7
8
  describe('LocalIndexedDb', () => {
8
9
  beforeEach(() => {
9
- jest.useRealTimers()
10
+ vi.useRealTimers()
10
11
  })
11
12
  afterEach(async () => {
12
13
  await hardReset({ shouldReload: false })
@@ -1,6 +1,6 @@
1
1
  import { PageRecordType } from '@tldraw/tlschema'
2
2
  import { IndexKey, promiseWithResolve } from '@tldraw/utils'
3
- import { afterEach } from 'node:test'
3
+ import { Mock, vi } from 'vitest'
4
4
  import { createTLStore } from '../../config/createTLStore'
5
5
  import { TLLocalSyncClient } from './TLLocalSyncClient'
6
6
  import { hardReset } from './hardReset'
@@ -10,20 +10,20 @@ class BroadcastChannelMock {
10
10
  constructor(_name: string) {
11
11
  // noop
12
12
  }
13
- postMessage = jest.fn((_msg: any) => {
13
+ postMessage = vi.fn((_msg: any) => {
14
14
  // noop
15
15
  })
16
- close = jest.fn(() => {
16
+ close = vi.fn(() => {
17
17
  // noop
18
18
  })
19
19
  }
20
20
 
21
21
  function testClient(channel = new BroadcastChannelMock('test')) {
22
22
  const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
23
- const onLoad = jest.fn(() => {
23
+ const onLoad = vi.fn(() => {
24
24
  return
25
25
  })
26
- const onLoadError = jest.fn(() => {
26
+ const onLoadError = vi.fn(() => {
27
27
  return
28
28
  })
29
29
  const client = new TLLocalSyncClient(
@@ -36,26 +36,26 @@ function testClient(channel = new BroadcastChannelMock('test')) {
36
36
  channel
37
37
  )
38
38
 
39
- client.db.storeSnapshot = jest.fn(() => Promise.resolve())
40
- client.db.storeChanges = jest.fn(() => Promise.resolve())
39
+ client.db.storeSnapshot = vi.fn(() => Promise.resolve())
40
+ client.db.storeChanges = vi.fn(() => Promise.resolve())
41
41
 
42
42
  return {
43
- client: client as { db: { storeSnapshot: jest.Mock; storeChanges: jest.Mock } } & typeof client,
43
+ client: client as { db: { storeSnapshot: Mock; storeChanges: Mock } } & typeof client,
44
44
  store,
45
45
  onLoad,
46
46
  onLoadError,
47
47
  channel,
48
48
  tick: async () => {
49
- jest.advanceTimersByTime(500)
49
+ vi.advanceTimersByTime(500)
50
50
  await Promise.resolve()
51
51
  await client.db.pending()
52
- jest.advanceTimersByTime(500)
52
+ vi.advanceTimersByTime(500)
53
53
  await Promise.resolve()
54
54
  },
55
55
  }
56
56
  }
57
57
 
58
- const reloadMock = jest.fn()
58
+ const reloadMock = vi.fn()
59
59
 
60
60
  beforeAll(() => {
61
61
  Object.defineProperty(window, 'location', {
@@ -65,14 +65,14 @@ beforeAll(() => {
65
65
  })
66
66
 
67
67
  beforeEach(() => {
68
- jest.clearAllMocks()
68
+ vi.clearAllMocks()
69
69
  })
70
70
 
71
71
  afterEach(async () => {
72
72
  await hardReset({ shouldReload: false })
73
73
  })
74
74
 
75
- jest.useFakeTimers()
75
+ vi.useFakeTimers()
76
76
 
77
77
  test('the client connects on instantiation, announcing its schema', async () => {
78
78
  const { channel, tick } = testClient()
@@ -86,7 +86,7 @@ test('the client connects on instantiation, announcing its schema', async () =>
86
86
  test('when a client receives an announce with a newer schema version it reloads itself', async () => {
87
87
  const { client, channel, onLoadError, tick } = testClient()
88
88
  await tick()
89
- jest.advanceTimersByTime(10000)
89
+ vi.advanceTimersByTime(10000)
90
90
  expect(reloadMock).not.toHaveBeenCalled()
91
91
  channel.onmessage?.({
92
92
  data: {
@@ -104,7 +104,7 @@ test('when a client receives an announce with a newer schema version it reloads
104
104
  test('when a client receives an announce with a newer schema version shortly after loading it does not reload but instead reports a loadError', async () => {
105
105
  const { client, channel, onLoadError, tick } = testClient()
106
106
  await tick()
107
- jest.advanceTimersByTime(1000)
107
+ vi.advanceTimersByTime(1000)
108
108
  expect(reloadMock).not.toHaveBeenCalled()
109
109
  channel.onmessage?.({
110
110
  data: {
package/src/version.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '3.16.0-next.eafb52d15064'
4
+ export const version = '3.16.0-next.fe14f1b4181f'
5
5
  export const publishDates = {
6
6
  major: '2024-09-13T14:36:29.063Z',
7
- minor: '2025-08-08T12:19:11.878Z',
8
- patch: '2025-08-08T12:19:11.878Z',
7
+ minor: '2025-08-27T11:23:00.744Z',
8
+ patch: '2025-08-27T11:23:00.744Z',
9
9
  }