@tldraw/editor 5.2.0-next.e2b8d10bf10e → 5.2.0

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 (172) hide show
  1. package/README.md +7 -1
  2. package/dist-cjs/index.d.ts +112 -53
  3. package/dist-cjs/index.js +7 -8
  4. package/dist-cjs/index.js.map +2 -2
  5. package/dist-cjs/lib/components/MenuClickCapture.js +8 -5
  6. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  7. package/dist-cjs/lib/components/Shape.js +47 -1
  8. package/dist-cjs/lib/components/Shape.js.map +3 -3
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +1 -16
  10. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  11. package/dist-cjs/lib/editor/Editor.js +97 -45
  12. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  13. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +3 -3
  14. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  15. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +28 -4
  16. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +3 -3
  17. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js +24 -2
  18. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +14 -3
  20. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +4 -2
  22. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +7 -3
  24. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +0 -1
  26. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  27. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +15 -2
  28. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  29. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +79 -0
  30. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +7 -0
  31. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  32. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +4 -0
  33. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  34. package/dist-cjs/lib/{hooks/useTransform.js → editor/tools/cancelShapeCreationOnLongPress.js} +8 -23
  35. package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js.map +7 -0
  36. package/dist-cjs/lib/editor/types/event-types.js.map +2 -2
  37. package/dist-cjs/lib/globals/environment.js +6 -1
  38. package/dist-cjs/lib/globals/environment.js.map +2 -2
  39. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  40. package/dist-cjs/lib/hooks/useCanvasEvents.js +17 -8
  41. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  42. package/dist-cjs/lib/hooks/useEditorComponents.js +0 -1
  43. package/dist-cjs/lib/hooks/useEditorComponents.js.map +2 -2
  44. package/dist-cjs/lib/hooks/usePresence.js.map +2 -2
  45. package/dist-cjs/lib/hooks/useScreenBounds.js +1 -0
  46. package/dist-cjs/lib/hooks/useScreenBounds.js.map +2 -2
  47. package/dist-cjs/lib/license/LicenseManager.js +5 -1
  48. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  49. package/dist-cjs/lib/primitives/Vec.js +3 -2
  50. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  51. package/dist-cjs/lib/utils/dom.js +12 -0
  52. package/dist-cjs/lib/utils/dom.js.map +2 -2
  53. package/dist-cjs/lib/utils/getPointerInfo.js +2 -1
  54. package/dist-cjs/lib/utils/getPointerInfo.js.map +2 -2
  55. package/dist-cjs/lib/utils/pointer.js +37 -0
  56. package/dist-cjs/lib/utils/pointer.js.map +7 -0
  57. package/dist-cjs/version.js +3 -3
  58. package/dist-cjs/version.js.map +1 -1
  59. package/dist-esm/index.d.mts +112 -53
  60. package/dist-esm/index.mjs +9 -13
  61. package/dist-esm/index.mjs.map +2 -2
  62. package/dist-esm/lib/components/MenuClickCapture.mjs +8 -5
  63. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  64. package/dist-esm/lib/components/Shape.mjs +48 -2
  65. package/dist-esm/lib/components/Shape.mjs.map +3 -3
  66. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +1 -16
  67. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  68. package/dist-esm/lib/editor/Editor.mjs +97 -45
  69. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  70. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +3 -3
  71. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  72. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +28 -4
  73. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +3 -3
  74. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs +24 -2
  75. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs.map +2 -2
  76. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +14 -3
  77. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  78. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +4 -2
  79. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  80. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +7 -3
  81. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  82. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +0 -1
  83. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  84. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +15 -2
  85. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  86. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +59 -0
  87. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +7 -0
  88. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  89. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +4 -0
  90. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  91. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs +8 -0
  92. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs.map +7 -0
  93. package/dist-esm/lib/editor/types/event-types.mjs.map +2 -2
  94. package/dist-esm/lib/globals/environment.mjs +6 -1
  95. package/dist-esm/lib/globals/environment.mjs.map +2 -2
  96. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  97. package/dist-esm/lib/hooks/useCanvasEvents.mjs +17 -8
  98. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  99. package/dist-esm/lib/hooks/useEditorComponents.mjs +0 -1
  100. package/dist-esm/lib/hooks/useEditorComponents.mjs.map +2 -2
  101. package/dist-esm/lib/hooks/usePresence.mjs.map +2 -2
  102. package/dist-esm/lib/hooks/useScreenBounds.mjs +1 -0
  103. package/dist-esm/lib/hooks/useScreenBounds.mjs.map +2 -2
  104. package/dist-esm/lib/license/LicenseManager.mjs +5 -1
  105. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  106. package/dist-esm/lib/primitives/Vec.mjs +3 -2
  107. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  108. package/dist-esm/lib/utils/dom.mjs +12 -0
  109. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  110. package/dist-esm/lib/utils/getPointerInfo.mjs +2 -1
  111. package/dist-esm/lib/utils/getPointerInfo.mjs.map +2 -2
  112. package/dist-esm/lib/utils/pointer.mjs +17 -0
  113. package/dist-esm/lib/utils/pointer.mjs.map +7 -0
  114. package/dist-esm/version.mjs +3 -3
  115. package/dist-esm/version.mjs.map +1 -1
  116. package/editor.css +15 -3
  117. package/package.json +11 -8
  118. package/src/index.ts +4 -10
  119. package/src/lib/components/MenuClickCapture.tsx +8 -4
  120. package/src/lib/components/Shape.tsx +88 -1
  121. package/src/lib/components/default-components/DefaultCanvas.tsx +1 -17
  122. package/src/lib/editor/Editor.test.ts +36 -0
  123. package/src/lib/editor/Editor.ts +145 -55
  124. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +43 -16
  125. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +8 -5
  126. package/src/lib/editor/managers/FontManager/FontManager.test.ts +37 -10
  127. package/src/lib/editor/managers/FontManager/FontManager.ts +46 -1
  128. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
  129. package/src/lib/editor/managers/HistoryManager/HistoryManager.ts +34 -4
  130. package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +61 -0
  131. package/src/lib/editor/managers/InputsManager/InputsManager.ts +16 -4
  132. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +9 -2
  133. package/src/lib/editor/managers/TextManager/TextManager.test.ts +16 -14
  134. package/src/lib/editor/managers/TextManager/TextManager.ts +17 -2
  135. package/src/lib/editor/managers/TickManager/TickManager.test.ts +0 -40
  136. package/src/lib/editor/managers/TickManager/TickManager.ts +0 -1
  137. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +12 -2
  138. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +27 -2
  139. package/src/lib/editor/overlays/strokeShapeIndicators.ts +86 -0
  140. package/src/lib/editor/shapes/ShapeUtil.ts +41 -0
  141. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +5 -0
  142. package/src/lib/editor/tools/cancelShapeCreationOnLongPress.ts +31 -0
  143. package/src/lib/editor/types/event-types.ts +7 -0
  144. package/src/lib/globals/environment.ts +7 -0
  145. package/src/lib/hooks/EditorComponentsContext.tsx +0 -2
  146. package/src/lib/hooks/useCanvasEvents.ts +24 -12
  147. package/src/lib/hooks/useEditorComponents.tsx +0 -1
  148. package/src/lib/hooks/usePresence.ts +2 -2
  149. package/src/lib/hooks/useScreenBounds.ts +3 -0
  150. package/src/lib/license/LicenseManager.test.ts +24 -16
  151. package/src/lib/license/LicenseManager.ts +8 -2
  152. package/src/lib/primitives/Vec.ts +5 -2
  153. package/src/lib/test/ContentElementSlot.test.tsx +201 -0
  154. package/src/lib/utils/dom.ts +34 -0
  155. package/src/lib/utils/getPointerInfo.ts +2 -1
  156. package/src/lib/utils/pointer.test.ts +76 -0
  157. package/src/lib/utils/pointer.ts +42 -0
  158. package/src/version.ts +3 -3
  159. package/dist-cjs/lib/components/default-components/DefaultSelectionBackground.js +0 -49
  160. package/dist-cjs/lib/components/default-components/DefaultSelectionBackground.js.map +0 -7
  161. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js +0 -161
  162. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js.map +0 -7
  163. package/dist-cjs/lib/hooks/useTransform.js.map +0 -7
  164. package/dist-esm/lib/components/default-components/DefaultSelectionBackground.mjs +0 -19
  165. package/dist-esm/lib/components/default-components/DefaultSelectionBackground.mjs.map +0 -7
  166. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs +0 -141
  167. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs.map +0 -7
  168. package/dist-esm/lib/hooks/useTransform.mjs +0 -23
  169. package/dist-esm/lib/hooks/useTransform.mjs.map +0 -7
  170. package/src/lib/components/default-components/DefaultSelectionBackground.tsx +0 -25
  171. package/src/lib/editor/overlays/ShapeIndicatorOverlayUtil.ts +0 -216
  172. package/src/lib/hooks/useTransform.ts +0 -30
@@ -1,14 +1,16 @@
1
1
  import { react } from '@tldraw/state'
2
2
  import { useQuickReactor, useStateTracking } from '@tldraw/state-react'
3
3
  import { TLShape, TLShapeId } from '@tldraw/tlschema'
4
+ import { warnOnce } from '@tldraw/utils'
4
5
  import { memo, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
6
+ import { Editor } from '../editor/Editor'
5
7
  import { ShapeUtil } from '../editor/shapes/ShapeUtil'
6
8
  import { useEditorComponents } from '../hooks/EditorComponentsContext'
7
9
  import { useEditor } from '../hooks/useEditor'
8
10
  import { useShapeCulling } from '../hooks/useShapeCulling'
9
11
  import { Mat } from '../primitives/Mat'
10
12
  import { areShapesContentEqual } from '../utils/areShapesContentEqual'
11
- import { setStyleProperty } from '../utils/dom'
13
+ import { moveElementInto, setStyleProperty } from '../utils/dom'
12
14
  import { OptionalErrorBoundary } from './ErrorBoundary'
13
15
 
14
16
  /*
@@ -153,11 +155,96 @@ export const Shape = memo(function Shape({
153
155
  <OptionalErrorBoundary fallback={ShapeErrorFallback as any} onError={annotateError}>
154
156
  <InnerShape shape={shape} util={util} />
155
157
  </OptionalErrorBoundary>
158
+ {util.getAppOwnedElement && <ContentElementSlot id={id} util={util} />}
156
159
  </ShapeWrapper>
157
160
  </>
158
161
  )
159
162
  })
160
163
 
164
+ /*
165
+ The content element slot hosts an app-owned element provided by the shape util's
166
+ getAppOwnedElement method. The contract: while the shape is mounted, tldraw never
167
+ unmounts, recreates, or relocates the element (the canvas renders shapes in stable
168
+ id-sorted DOM order, so reorders and reparenting never move DOM nodes), and before
169
+ the slot is destroyed — shape deletion, page change, error teardown, or the whole
170
+ editor unmounting — onReleaseAppOwnedElement is called while the slot is still
171
+ connected to the document, so the app can move the element to a new parent with
172
+ Node.moveBefore without resetting its state. That last guarantee relies on this
173
+ being a layout effect: React runs layout effect cleanups before it detaches the
174
+ host nodes of a deleted subtree, which is not true of passive effects.
175
+ */
176
+ const ContentElementSlot = memo(function ContentElementSlot({
177
+ id,
178
+ util,
179
+ }: {
180
+ id: TLShapeId
181
+ util: ShapeUtil
182
+ }) {
183
+ const editor = useEditor()
184
+ const slotRef = useRef<HTMLDivElement>(null)
185
+
186
+ useLayoutEffect(() => {
187
+ const slot = slotRef.current
188
+ if (!slot) return
189
+
190
+ const shape = editor.getShape(id)
191
+ if (!shape) return
192
+
193
+ // getAppOwnedElement is called once per shape mount (the effect deps below are
194
+ // [editor, id, util], none of which change on a prop edit), so the adopted element is
195
+ // not refreshed when the shape's props change. A util whose element identity depends on
196
+ // props must mutate the existing element in place rather than return a new one. Likewise,
197
+ // if this returns null now and an element only later, the element is never adopted for
198
+ // this mount — return a stable element up front and populate it asynchronously instead.
199
+ const element = util.getAppOwnedElement?.(shape)
200
+ if (!element) return
201
+
202
+ // An element that was connected before adoption (e.g. parked off-canvas by the
203
+ // app between editor sessions) should survive the move without reloading.
204
+ const wasConnectedBeforeAdoption = element.isConnected
205
+
206
+ if (element.parentNode !== slot) {
207
+ moveElementInto(slot, element)
208
+ }
209
+
210
+ if (process.env.NODE_ENV !== 'production' && wasConnectedBeforeAdoption) {
211
+ warnIfContentElementReloads(editor, slot, element)
212
+ }
213
+
214
+ return () => {
215
+ // Note: under React StrictMode (dev only) this effect runs mount → cleanup → mount,
216
+ // so onReleaseAppOwnedElement fires once even though the shape was not removed, and on
217
+ // the appendChild fallback path the element is detached and re-adopted (reloading it).
218
+ // onReleaseAppOwnedElement must therefore be safe to call when the shape still exists;
219
+ // don't treat it as a definitive "shape removed" signal (check the store if you need
220
+ // to know). With Node.moveBefore the re-adoption preserves state, so this is benign.
221
+ const latestShape = editor.getShape(id) ?? shape
222
+ util.onReleaseAppOwnedElement?.(latestShape, element)
223
+ if (process.env.NODE_ENV !== 'production' && element.parentNode === slot) {
224
+ warnOnce(
225
+ `The content element for shape "${id}" was not reclaimed by onReleaseAppOwnedElement and will be destroyed along with its slot, losing any state it holds.`
226
+ )
227
+ }
228
+ }
229
+ }, [editor, id, util])
230
+
231
+ return <div ref={slotRef} className="tl-content-slot" draggable={false} />
232
+ })
233
+
234
+ // Dev-mode assertion that adoption kept the platform's state-preservation promise: a
235
+ // load event firing on a previously-connected adopted element means the embed reloaded.
236
+ // load doesn't bubble, but capture-phase listeners on ancestors still observe it.
237
+ function warnIfContentElementReloads(editor: Editor, slot: HTMLElement, element: HTMLElement) {
238
+ const onLoad = (event: Event) => {
239
+ if (event.target !== element && !element.contains(event.target as Node)) return
240
+ warnOnce(
241
+ 'A load event fired on an adopted content element. The element reloaded when it was moved into the shape, losing its state. State-preserving moves need Node.moveBefore (Chromium 133+, Firefox 144+) and a continuously connected element.'
242
+ )
243
+ }
244
+ slot.addEventListener('load', onLoad, { capture: true })
245
+ editor.timers.setTimeout(() => slot.removeEventListener('load', onLoad, { capture: true }), 1000)
246
+ }
247
+
161
248
  export const InnerShape = memo(
162
249
  function InnerShape<T extends TLShape>({ shape, util }: { shape: T; util: ShapeUtil<T> }) {
163
250
  return useStateTracking(
@@ -32,7 +32,7 @@ export interface TLCanvasComponentProps {
32
32
  export function DefaultCanvas({ className }: TLCanvasComponentProps) {
33
33
  const editor = useEditor()
34
34
 
35
- const { SelectionBackground, Background, SvgDefs } = useEditorComponents()
35
+ const { Background, SvgDefs } = useEditorComponents()
36
36
 
37
37
  const rCanvas = useRef<HTMLDivElement>(null)
38
38
  const rHtmlLayer = useRef<HTMLDivElement>(null)
@@ -160,7 +160,6 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
160
160
  {isGridMode && Grid && <GridWrapper />}
161
161
  <div ref={rHtmlLayer} className="tl-html-layer tl-shapes" draggable={false}>
162
162
  <OnTheCanvasWrapper />
163
- {SelectionBackground && <SelectionBackgroundWrapper />}
164
163
  {hideShapes ? null : <ShapesLayer canvasRef={rCanvas} />}
165
164
  </div>
166
165
  <CanvasOverlays />
@@ -345,21 +344,6 @@ function DebugSvgCopy({ id, mode }: { id: TLShapeId; mode: 'img' | 'iframe' }) {
345
344
  )
346
345
  }
347
346
 
348
- function SelectionBackgroundWrapper() {
349
- const editor = useEditor()
350
- const selectionRotation = useValue('selection rotation', () => editor.getSelectionRotation(), [
351
- editor,
352
- ])
353
- const selectionBounds = useValue(
354
- 'selection bounds',
355
- () => editor.getSelectionRotatedPageBounds(),
356
- [editor]
357
- )
358
- const { SelectionBackground } = useEditorComponents()
359
- if (!selectionBounds || !SelectionBackground) return null
360
- return <SelectionBackground bounds={selectionBounds} rotation={selectionRotation} />
361
- }
362
-
363
347
  function OnTheCanvasWrapper() {
364
348
  const { OnTheCanvas } = useEditorComponents()
365
349
  if (!OnTheCanvas) return null
@@ -96,6 +96,42 @@ describe('updateShape', () => {
96
96
  })
97
97
  })
98
98
 
99
+ describe('updateViewportScreenBounds', () => {
100
+ it('bails without crashing or updating bounds when the container document has no body', () => {
101
+ const documentSpy = vi
102
+ .spyOn(editor, 'getContainerDocument')
103
+ .mockReturnValue({ body: null } as unknown as Document)
104
+
105
+ const prevScreenBounds = editor.getInstanceState().screenBounds
106
+ const prevInsets = editor.getInstanceState().insets
107
+
108
+ try {
109
+ expect(() => editor.updateViewportScreenBounds(new Box(10, 20, 100, 200))).not.toThrow()
110
+ // A torn-down document is not measurable, so the bounds are left untouched.
111
+ expect(editor.getInstanceState().screenBounds).toEqual(prevScreenBounds)
112
+ expect(editor.getInstanceState().insets).toEqual(prevInsets)
113
+ } finally {
114
+ documentSpy.mockRestore()
115
+ }
116
+ })
117
+
118
+ it('uses the container document body to calculate right and bottom insets', () => {
119
+ const documentSpy = vi.spyOn(editor, 'getContainerDocument').mockReturnValue({
120
+ body: {
121
+ scrollWidth: 500,
122
+ scrollHeight: 400,
123
+ },
124
+ } as unknown as Document)
125
+
126
+ try {
127
+ editor.updateViewportScreenBounds(new Box(10, 20, 100, 200))
128
+ expect(editor.getInstanceState().insets).toEqual([true, true, true, true])
129
+ } finally {
130
+ documentSpy.mockRestore()
131
+ }
132
+ })
133
+ })
134
+
99
135
  describe('zoomToFit', () => {
100
136
  it('no-op when isLocked is set', () => {
101
137
  editor.getCurrentPageShapeIds = vi.fn(() => new Set([createShapeId('box1')]))
@@ -409,9 +409,6 @@ export class Editor extends EventEmitter<TLEventMap> {
409
409
  this._tickManager = new TickManager(this)
410
410
  this.disposables.add(() => this._tickManager.dispose())
411
411
  this.disposables.add(() => {
412
- // Reset camera state to 'idle' so the store isn't left stuck at 'moving'
413
- // when tick events stop (e.g. React strict mode disposes while camera is moving)
414
- this.off('tick', this._decayCameraStateTimeout)
415
412
  this._setCameraState('idle')
416
413
  })
417
414
 
@@ -419,6 +416,7 @@ export class Editor extends EventEmitter<TLEventMap> {
419
416
  this.disposables.add(() => this.fonts.dispose())
420
417
 
421
418
  this.inputs = new InputsManager(this)
419
+ this.disposables.add(() => this.inputs.dispose())
422
420
  this.performance = new PerformanceManager(this)
423
421
  this.disposables.add(() => this.performance.dispose())
424
422
  this.collaborators = new CollaboratorsManager(this)
@@ -1179,6 +1177,7 @@ export class Editor extends EventEmitter<TLEventMap> {
1179
1177
  this.store.dispose()
1180
1178
  this.isDisposed = true
1181
1179
  this.emit('dispose')
1180
+ this.removeAllListeners()
1182
1181
  }
1183
1182
 
1184
1183
  /* ------------------ Themes (shadowing the theme manager) ------------------ */
@@ -1551,6 +1550,15 @@ export class Editor extends EventEmitter<TLEventMap> {
1551
1550
  return this.history.getMarkIdMatching(idSubstring)
1552
1551
  }
1553
1552
 
1553
+ /**
1554
+ * Whether the editor is currently replaying history (i.e. an undo or redo is being applied).
1555
+ *
1556
+ * @internal
1557
+ */
1558
+ isReplayingHistory(): boolean {
1559
+ return this.history.isReplaying()
1560
+ }
1561
+
1554
1562
  /**
1555
1563
  * Coalesces all changes since the given mark into a single change, removing any intermediate marks.
1556
1564
  *
@@ -3068,8 +3076,8 @@ export class Editor extends EventEmitter<TLEventMap> {
3068
3076
  return baseCamera
3069
3077
  }
3070
3078
 
3071
- private _getFollowingPresence(targetUserId: string | null) {
3072
- const visited = [this.user.getId()]
3079
+ private _getFollowingPresence(targetUserId: TLUserId | null) {
3080
+ const visited = [this.user.getRecordId()]
3073
3081
  const collaborators = this.getCollaborators()
3074
3082
  let leaderPresence = null as null | TLInstancePresence
3075
3083
  while (targetUserId && !visited.includes(targetUserId)) {
@@ -4029,16 +4037,34 @@ export class Editor extends EventEmitter<TLEventMap> {
4029
4037
 
4030
4038
  this.once('stop-camera-animation', cancel)
4031
4039
 
4040
+ const dirZ = direction.z ?? 0
4041
+
4032
4042
  const moveCamera = (elapsed: number) => {
4033
4043
  const { x: cx, y: cy, z: cz } = this.getCamera()
4034
- const movementVec = Vec.Mul(direction, (currentSpeed * elapsed) / cz)
4044
+
4045
+ // Pan movement from x/y direction
4046
+ const dx = (direction.x * (currentSpeed * elapsed)) / cz
4047
+ const dy = (direction.y * (currentSpeed * elapsed)) / cz
4048
+
4049
+ let newCx = cx + dx
4050
+ let newCy = cy + dy
4051
+ let newCz = cz
4052
+
4053
+ // animate zoom if z direction is passed in
4054
+ if (dirZ !== 0) {
4055
+ newCz = cz * (1 + dirZ * currentSpeed * elapsed)
4056
+ // Adjust x/y to keep the viewport center fixed while zooming
4057
+ const center = this.getViewportScreenCenter()
4058
+ newCx += center.x / newCz - center.x / cz
4059
+ newCy += center.y / newCz - center.y / cz
4060
+ }
4035
4061
 
4036
4062
  // Apply friction
4037
4063
  currentSpeed *= 1 - friction
4038
4064
  if (currentSpeed < speedThreshold) {
4039
4065
  cancel()
4040
4066
  } else {
4041
- this._setCamera(new Vec(cx + movementVec.x, cy + movementVec.y, cz))
4067
+ this._setCamera(new Vec(newCx, newCy, newCz))
4042
4068
  }
4043
4069
  }
4044
4070
 
@@ -4060,7 +4086,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4060
4086
  * @param opts - The camera move options.
4061
4087
  * @public
4062
4088
  */
4063
- zoomToUser(userId: string, opts: TLCameraMoveOptions = { animation: { duration: 500 } }): this {
4089
+ zoomToUser(userId: TLUserId, opts: TLCameraMoveOptions = { animation: { duration: 500 } }): this {
4064
4090
  const presence = this.getCollaborators().find((c) => c.userId === userId)
4065
4091
 
4066
4092
  if (!presence) return this
@@ -4139,6 +4165,10 @@ export class Editor extends EventEmitter<TLEventMap> {
4139
4165
  }
4140
4166
 
4141
4167
  const doc = this.getContainerDocument()
4168
+ // If the container's document has been torn down (e.g. an iframe being
4169
+ // removed), its body is null and there's nothing meaningful to measure.
4170
+ if (!doc.body) return this
4171
+
4142
4172
  const insets = [
4143
4173
  // top
4144
4174
  screenBounds.minY !== 0,
@@ -4400,7 +4430,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4400
4430
 
4401
4431
  /**
4402
4432
  * Collect user IDs referenced by a set of shapes via shape-specific props
4403
- * (e.g. `textFirstEditedBy` on notes).
4433
+ * (e.g. `textLastEditedBy` on notes).
4404
4434
  *
4405
4435
  * @internal
4406
4436
  */
@@ -4432,11 +4462,11 @@ export class Editor extends EventEmitter<TLEventMap> {
4432
4462
  *
4433
4463
  * @public
4434
4464
  */
4435
- startFollowingUser(userId: string): this {
4465
+ startFollowingUser(userId: TLUserId): this {
4436
4466
  // if we were already following someone, stop following them
4437
4467
  this.stopFollowingUser()
4438
4468
 
4439
- const thisUserId = this.user.getId()
4469
+ const thisUserId = this.user.getExternalId()
4440
4470
 
4441
4471
  if (!thisUserId) {
4442
4472
  console.warn('You should set the userId for the current instance before following a user')
@@ -8084,32 +8114,53 @@ export class Editor extends EventEmitter<TLEventMap> {
8084
8114
  * @public
8085
8115
  */
8086
8116
  resizeShape(shape: TLShapeId | TLShape, scale: VecLike, opts: TLResizeShapeOptions = {}): this {
8117
+ const partial = this.getResizeShapePartial(shape, scale, opts)
8118
+ if (partial) this.updateShapes([partial])
8119
+ return this
8120
+ }
8121
+
8122
+ /**
8123
+ * Get the update for a resized shape without committing it to the store. Interactions that
8124
+ * resize many shapes at once use this to collect all of the updates and commit them in a
8125
+ * single batch. Returns null when there is nothing to update.
8126
+ *
8127
+ * Shapes that are rotated out of alignment with the scale axis cannot be resized with a
8128
+ * single update; those shapes are resized immediately (as `resizeShape` would do) and null
8129
+ * is returned.
8130
+ *
8131
+ * @internal
8132
+ */
8133
+ getResizeShapePartial(
8134
+ shape: TLShapeId | TLShape,
8135
+ scale: VecLike,
8136
+ opts: TLResizeShapeOptions = {}
8137
+ ): TLShapePartial | null {
8087
8138
  const id = typeof shape === 'string' ? shape : shape.id
8088
- if (this.getIsReadonly()) return this
8139
+ if (this.getIsReadonly()) return null
8089
8140
 
8090
8141
  if (!Number.isFinite(scale.x)) scale = new Vec(1, scale.y)
8091
8142
  if (!Number.isFinite(scale.y)) scale = new Vec(scale.x, 1)
8092
8143
 
8093
8144
  const initialShape = opts.initialShape ?? this.getShape(id)
8094
- if (!initialShape) return this
8145
+ if (!initialShape) return null
8095
8146
 
8096
8147
  const scaleOrigin = opts.scaleOrigin ?? this.getShapePageBounds(id)?.center
8097
- if (!scaleOrigin) return this
8148
+ if (!scaleOrigin) return null
8098
8149
 
8099
8150
  const pageTransform = opts.initialPageTransform
8100
8151
  ? Mat.Cast(opts.initialPageTransform)
8101
8152
  : this.getShapePageTransform(id)
8102
- if (!pageTransform) return this
8153
+ if (!pageTransform) return null
8103
8154
 
8104
8155
  const pageRotation = pageTransform.rotation()
8105
8156
 
8106
- if (pageRotation == null) return this
8157
+ if (pageRotation == null) return null
8107
8158
 
8108
8159
  const scaleAxisRotation = opts.scaleAxisRotation ?? pageRotation
8109
8160
 
8110
8161
  const initialBounds = opts.initialBounds ?? this.getShapeGeometry(id).bounds
8111
8162
 
8112
- if (!initialBounds) return this
8163
+ if (!initialBounds) return null
8113
8164
 
8114
8165
  const isAspectRatioLocked =
8115
8166
  opts.isAspectRatioLocked ?? this.getShapeUtil(initialShape).isAspectRatioLocked(initialShape)
@@ -8119,7 +8170,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8119
8170
  // from whichever axis is being scaled the least, to avoid the shape getting bigger
8120
8171
  // than the bounds of the selection
8121
8172
  // const minScale = Math.min(Math.abs(scale.x), Math.abs(scale.y))
8122
- return this._resizeUnalignedShape(id, scale, {
8173
+ this._resizeUnalignedShape(id, scale, {
8123
8174
  ...opts,
8124
8175
  initialBounds,
8125
8176
  scaleOrigin,
@@ -8128,6 +8179,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8128
8179
  isAspectRatioLocked,
8129
8180
  initialShape,
8130
8181
  })
8182
+ return null
8131
8183
  }
8132
8184
 
8133
8185
  const util = this.getShapeUtil(initialShape)
@@ -8140,7 +8192,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8140
8192
  }
8141
8193
  }
8142
8194
 
8143
- let didResize = false
8195
+ let workingShape: TLShape | null = null
8144
8196
 
8145
8197
  if (util.onResize && util.canResize(initialShape)) {
8146
8198
  // get the model changes from the shape util
@@ -8172,7 +8224,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8172
8224
  // need to adjust the shape's x and y points in case the parent has moved since start of resizing
8173
8225
  const { x, y } = this.getPointInParentSpace(initialShape.id, initialPagePoint)
8174
8226
 
8175
- let workingShape = initialShape
8227
+ workingShape = initialShape
8176
8228
  if (!opts.skipStartAndEndCallbacks) {
8177
8229
  workingShape = applyPartialToRecordWithProps(
8178
8230
  initialShape,
@@ -8194,10 +8246,6 @@ export class Editor extends EventEmitter<TLEventMap> {
8194
8246
  }
8195
8247
  )
8196
8248
 
8197
- if (resizedShape) {
8198
- didResize = true
8199
- }
8200
-
8201
8249
  workingShape = applyPartialToRecordWithProps(workingShape, {
8202
8250
  id,
8203
8251
  type: initialShape.type as any,
@@ -8213,40 +8261,47 @@ export class Editor extends EventEmitter<TLEventMap> {
8213
8261
  )
8214
8262
  }
8215
8263
 
8216
- this.updateShapes([workingShape])
8264
+ if (resizedShape) {
8265
+ return workingShape
8266
+ }
8217
8267
  }
8218
8268
 
8219
- if (!didResize) {
8220
- // reposition shape (rather than resizing it) based on where its resized center would be
8269
+ // the shape was not resized by its util, so reposition it (rather than resizing it)
8270
+ // based on where its resized center would be
8221
8271
 
8222
- const initialPageCenter = Mat.applyToPoint(pageTransform, initialBounds.center)
8223
- // get the model changes from the shape util
8224
- const newPageCenter = this._scalePagePoint(
8225
- initialPageCenter,
8226
- scaleOrigin,
8227
- scale,
8228
- scaleAxisRotation
8229
- )
8272
+ const initialPageCenter = Mat.applyToPoint(pageTransform, initialBounds.center)
8273
+ // get the model changes from the shape util
8274
+ const newPageCenter = this._scalePagePoint(
8275
+ initialPageCenter,
8276
+ scaleOrigin,
8277
+ scale,
8278
+ scaleAxisRotation
8279
+ )
8230
8280
 
8231
- const initialPageCenterInParentSpace = this.getPointInParentSpace(
8232
- initialShape.id,
8233
- initialPageCenter
8234
- )
8235
- const newPageCenterInParentSpace = this.getPointInParentSpace(initialShape.id, newPageCenter)
8281
+ const initialPageCenterInParentSpace = this.getPointInParentSpace(
8282
+ initialShape.id,
8283
+ initialPageCenter
8284
+ )
8285
+ const newPageCenterInParentSpace = this.getPointInParentSpace(initialShape.id, newPageCenter)
8236
8286
 
8237
- const delta = Vec.Sub(newPageCenterInParentSpace, initialPageCenterInParentSpace)
8238
- // apply the changes to the model
8239
- this.updateShapes([
8240
- {
8241
- id,
8242
- type: initialShape.type as any,
8243
- x: initialShape.x + delta.x,
8244
- y: initialShape.y + delta.y,
8245
- },
8246
- ])
8287
+ const delta = Vec.Sub(newPageCenterInParentSpace, initialPageCenterInParentSpace)
8288
+
8289
+ if (workingShape) {
8290
+ // the util's onResize ran but returned no change; keep the working update (which may
8291
+ // include changes from onResizeStart / onResizeEnd) and reposition the shape
8292
+ return {
8293
+ ...workingShape,
8294
+ x: initialShape.x + delta.x,
8295
+ y: initialShape.y + delta.y,
8296
+ }
8247
8297
  }
8248
8298
 
8249
- return this
8299
+ return {
8300
+ id,
8301
+ type: initialShape.type as any,
8302
+ x: initialShape.x + delta.x,
8303
+ y: initialShape.y + delta.y,
8304
+ }
8250
8305
  }
8251
8306
 
8252
8307
  /** @internal */
@@ -10126,6 +10181,12 @@ export class Editor extends EventEmitter<TLEventMap> {
10126
10181
 
10127
10182
  if (ids.length === 0) return undefined
10128
10183
 
10184
+ // Text geometry is measured from the loaded font, so the export's bounds - and the
10185
+ // layout of any text within it - depend on the right fonts having loaded. Wait for them
10186
+ // before we measure; otherwise an export taken before fonts finish loading (e.g. right
10187
+ // after the editor mounts) is sized and laid out with fallback-font metrics.
10188
+ await this.fonts.loadRequiredFontsForCurrentPage(this.options.maxFontsToLoadBeforeRender)
10189
+
10129
10190
  return exportToSvg(this, ids, opts)
10130
10191
  }
10131
10192
 
@@ -11172,8 +11233,15 @@ export class Editor extends EventEmitter<TLEventMap> {
11172
11233
  inputs.setIsPointing(true)
11173
11234
  inputs.setIsDragging(false)
11174
11235
 
11175
- // If pen mode is off but we're not already in pen mode, turn that on
11176
- if (!isPenMode && isPen) this.updateInstanceState({ isPenMode: true })
11236
+ // If pen mode is off, turn it on for direct-display pen input only (e.g. Apple
11237
+ // Pencil on an iPad or a Surface Pen on a touchscreen). Indirect desktop tablet
11238
+ // styluses still draw as pens, but should not auto-enable pen mode.
11239
+ if (!isPenMode && info.isPenDirect) {
11240
+ this.updateInstanceState({ isPenMode: true })
11241
+ // Once pen mode is on, touch input is ignored, so we discard the
11242
+ // in-progress touch interaction .
11243
+ this.interrupt()
11244
+ }
11177
11245
 
11178
11246
  // On devices with erasers (like the Surface Pen or Wacom Pen), button 5 is the eraser
11179
11247
  if (info.button === STYLUS_ERASER_BUTTON) {
@@ -11323,7 +11391,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11323
11391
  // Don't pass right-click panning events to the state chart
11324
11392
  // as it causes unintended shape selection on release
11325
11393
  if (slideSpeed > 0) {
11326
- this.slideCamera({ speed: slideSpeed, direction: slideDirection })
11394
+ this.slideCamera({
11395
+ speed: slideSpeed,
11396
+ direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
11397
+ })
11327
11398
  }
11328
11399
  this._selectedShapeIdsAtPointerDown = []
11329
11400
  this._didCaptureSelectionAtPointerDown = false
@@ -11332,7 +11403,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11332
11403
  }
11333
11404
 
11334
11405
  if (slideSpeed > 0) {
11335
- this.slideCamera({ speed: slideSpeed, direction: slideDirection })
11406
+ this.slideCamera({
11407
+ speed: slideSpeed,
11408
+ direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
11409
+ })
11336
11410
  }
11337
11411
  } else {
11338
11412
  if (info.button === STYLUS_ERASER_BUTTON) {
@@ -11447,6 +11521,22 @@ export class Editor extends EventEmitter<TLEventMap> {
11447
11521
  info.name = 'right_click'
11448
11522
  }
11449
11523
 
11524
+ // The context menu is a select-tool surface: every item acts on the
11525
+ // current selection. Route a right-click through the select tool before
11526
+ // the event reaches the state chart, so the select tool's idle resolves
11527
+ // the selection at the click point (select the shape under the pointer,
11528
+ // or clear it) no matter which tool was active. Without this, opening the
11529
+ // menu from another tool leaves a stale selection that doesn't match the
11530
+ // click (#8828). Guarded on the select tool existing, since the bare
11531
+ // editor can be configured without it.
11532
+ if (
11533
+ info.name === 'right_click' &&
11534
+ this.getCurrentToolId() !== 'select' &&
11535
+ this.getStateDescendant('select')
11536
+ ) {
11537
+ this.setCurrentTool('select')
11538
+ }
11539
+
11450
11540
  // If a left click pointer event, send the event to the click manager.
11451
11541
  const { isPenMode } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
11452
11542
  if (info.isPen === isPenMode) {