@tldraw/editor 5.2.0-next.ee0fa4d6244f → 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 (189) hide show
  1. package/README.md +7 -1
  2. package/dist-cjs/index.d.ts +117 -60
  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 +131 -57
  12. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  13. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +8 -58
  14. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  15. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +3 -3
  16. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +1 -2
  18. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +28 -4
  20. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +3 -3
  21. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js +24 -2
  22. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +14 -3
  24. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +4 -2
  26. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  27. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +7 -3
  28. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  29. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +0 -1
  30. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  31. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +15 -2
  32. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  33. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +79 -0
  34. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +7 -0
  35. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  36. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +4 -0
  37. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  38. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  39. package/dist-cjs/lib/{hooks/useTransform.js → editor/tools/cancelShapeCreationOnLongPress.js} +8 -23
  40. package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js.map +7 -0
  41. package/dist-cjs/lib/editor/types/event-types.js +0 -2
  42. package/dist-cjs/lib/editor/types/event-types.js.map +2 -2
  43. package/dist-cjs/lib/globals/environment.js +6 -1
  44. package/dist-cjs/lib/globals/environment.js.map +2 -2
  45. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  46. package/dist-cjs/lib/hooks/useCanvasEvents.js +17 -8
  47. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  48. package/dist-cjs/lib/hooks/useEditorComponents.js +0 -1
  49. package/dist-cjs/lib/hooks/useEditorComponents.js.map +2 -2
  50. package/dist-cjs/lib/hooks/usePresence.js.map +2 -2
  51. package/dist-cjs/lib/hooks/useScreenBounds.js +1 -0
  52. package/dist-cjs/lib/hooks/useScreenBounds.js.map +2 -2
  53. package/dist-cjs/lib/license/LicenseManager.js +5 -1
  54. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  55. package/dist-cjs/lib/primitives/Vec.js +3 -2
  56. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  57. package/dist-cjs/lib/utils/dom.js +12 -0
  58. package/dist-cjs/lib/utils/dom.js.map +2 -2
  59. package/dist-cjs/lib/utils/getPointerInfo.js +2 -1
  60. package/dist-cjs/lib/utils/getPointerInfo.js.map +2 -2
  61. package/dist-cjs/lib/utils/pointer.js +37 -0
  62. package/dist-cjs/lib/utils/pointer.js.map +7 -0
  63. package/dist-cjs/version.js +3 -3
  64. package/dist-cjs/version.js.map +1 -1
  65. package/dist-esm/index.d.mts +117 -60
  66. package/dist-esm/index.mjs +9 -13
  67. package/dist-esm/index.mjs.map +2 -2
  68. package/dist-esm/lib/components/MenuClickCapture.mjs +8 -5
  69. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  70. package/dist-esm/lib/components/Shape.mjs +48 -2
  71. package/dist-esm/lib/components/Shape.mjs.map +3 -3
  72. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +1 -16
  73. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  74. package/dist-esm/lib/editor/Editor.mjs +131 -57
  75. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  76. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +8 -58
  77. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  78. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +3 -3
  79. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  80. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +1 -2
  81. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
  82. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +28 -4
  83. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +3 -3
  84. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs +24 -2
  85. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs.map +2 -2
  86. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +14 -3
  87. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  88. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +4 -2
  89. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  90. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +7 -3
  91. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  92. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +0 -1
  93. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  94. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +15 -2
  95. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  96. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +59 -0
  97. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +7 -0
  98. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  99. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +4 -0
  100. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  101. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  102. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs +8 -0
  103. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs.map +7 -0
  104. package/dist-esm/lib/editor/types/event-types.mjs +0 -2
  105. package/dist-esm/lib/editor/types/event-types.mjs.map +2 -2
  106. package/dist-esm/lib/globals/environment.mjs +6 -1
  107. package/dist-esm/lib/globals/environment.mjs.map +2 -2
  108. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  109. package/dist-esm/lib/hooks/useCanvasEvents.mjs +17 -8
  110. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  111. package/dist-esm/lib/hooks/useEditorComponents.mjs +0 -1
  112. package/dist-esm/lib/hooks/useEditorComponents.mjs.map +2 -2
  113. package/dist-esm/lib/hooks/usePresence.mjs.map +2 -2
  114. package/dist-esm/lib/hooks/useScreenBounds.mjs +1 -0
  115. package/dist-esm/lib/hooks/useScreenBounds.mjs.map +2 -2
  116. package/dist-esm/lib/license/LicenseManager.mjs +5 -1
  117. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  118. package/dist-esm/lib/primitives/Vec.mjs +3 -2
  119. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  120. package/dist-esm/lib/utils/dom.mjs +12 -0
  121. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  122. package/dist-esm/lib/utils/getPointerInfo.mjs +2 -1
  123. package/dist-esm/lib/utils/getPointerInfo.mjs.map +2 -2
  124. package/dist-esm/lib/utils/pointer.mjs +17 -0
  125. package/dist-esm/lib/utils/pointer.mjs.map +7 -0
  126. package/dist-esm/version.mjs +3 -3
  127. package/dist-esm/version.mjs.map +1 -1
  128. package/editor.css +15 -3
  129. package/package.json +11 -8
  130. package/src/index.ts +4 -10
  131. package/src/lib/components/MenuClickCapture.tsx +8 -4
  132. package/src/lib/components/Shape.tsx +88 -1
  133. package/src/lib/components/default-components/DefaultCanvas.tsx +1 -17
  134. package/src/lib/editor/Editor.test.ts +36 -0
  135. package/src/lib/editor/Editor.ts +204 -75
  136. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +54 -74
  137. package/src/lib/editor/managers/ClickManager/ClickManager.ts +15 -65
  138. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +43 -16
  139. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +8 -5
  140. package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +4 -4
  141. package/src/lib/editor/managers/FocusManager/FocusManager.ts +1 -2
  142. package/src/lib/editor/managers/FontManager/FontManager.test.ts +37 -10
  143. package/src/lib/editor/managers/FontManager/FontManager.ts +46 -1
  144. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
  145. package/src/lib/editor/managers/HistoryManager/HistoryManager.ts +34 -4
  146. package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +61 -0
  147. package/src/lib/editor/managers/InputsManager/InputsManager.ts +16 -4
  148. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +9 -2
  149. package/src/lib/editor/managers/TextManager/TextManager.test.ts +16 -14
  150. package/src/lib/editor/managers/TextManager/TextManager.ts +17 -2
  151. package/src/lib/editor/managers/TickManager/TickManager.test.ts +0 -40
  152. package/src/lib/editor/managers/TickManager/TickManager.ts +0 -1
  153. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +12 -2
  154. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +27 -2
  155. package/src/lib/editor/overlays/strokeShapeIndicators.ts +86 -0
  156. package/src/lib/editor/shapes/ShapeUtil.ts +41 -0
  157. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +5 -0
  158. package/src/lib/editor/tools/StateNode.ts +0 -2
  159. package/src/lib/editor/tools/cancelShapeCreationOnLongPress.ts +31 -0
  160. package/src/lib/editor/types/event-types.ts +9 -6
  161. package/src/lib/globals/environment.ts +7 -0
  162. package/src/lib/hooks/EditorComponentsContext.tsx +0 -2
  163. package/src/lib/hooks/useCanvasEvents.ts +24 -12
  164. package/src/lib/hooks/useEditorComponents.tsx +0 -1
  165. package/src/lib/hooks/usePresence.ts +2 -2
  166. package/src/lib/hooks/useScreenBounds.ts +3 -0
  167. package/src/lib/license/LicenseManager.test.ts +24 -16
  168. package/src/lib/license/LicenseManager.ts +8 -2
  169. package/src/lib/primitives/Vec.ts +5 -2
  170. package/src/lib/test/ContentElementSlot.test.tsx +201 -0
  171. package/src/lib/utils/dom.ts +34 -0
  172. package/src/lib/utils/getPointerInfo.ts +2 -1
  173. package/src/lib/utils/pointer.test.ts +76 -0
  174. package/src/lib/utils/pointer.ts +42 -0
  175. package/src/version.ts +3 -3
  176. package/dist-cjs/lib/components/default-components/DefaultSelectionBackground.js +0 -49
  177. package/dist-cjs/lib/components/default-components/DefaultSelectionBackground.js.map +0 -7
  178. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js +0 -161
  179. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js.map +0 -7
  180. package/dist-cjs/lib/hooks/useTransform.js.map +0 -7
  181. package/dist-esm/lib/components/default-components/DefaultSelectionBackground.mjs +0 -19
  182. package/dist-esm/lib/components/default-components/DefaultSelectionBackground.mjs.map +0 -7
  183. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs +0 -141
  184. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs.map +0 -7
  185. package/dist-esm/lib/hooks/useTransform.mjs +0 -23
  186. package/dist-esm/lib/hooks/useTransform.mjs.map +0 -7
  187. package/src/lib/components/default-components/DefaultSelectionBackground.tsx +0 -25
  188. package/src/lib/editor/overlays/ShapeIndicatorOverlayUtil.ts +0 -216
  189. package/src/lib/hooks/useTransform.ts +0 -30
@@ -982,6 +982,47 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
982
982
  * @public
983
983
  */
984
984
  onEditEnd?(shape: Shape): void
985
+
986
+ /**
987
+ * Provide an app-owned element to be rendered inside the shape, alongside the output of
988
+ * {@link ShapeUtil.component}. While the shape remains mounted, tldraw guarantees the
989
+ * element keeps the same DOM position: it is never unmounted, recreated, or relocated by
990
+ * reordering, reparenting, culling, or re-renders. When adopting the element, tldraw uses
991
+ * `Node.moveBefore` where available so stateful content like cross-origin iframes keeps
992
+ * its state across the move, falling back to `appendChild` elsewhere.
993
+ *
994
+ * Pair this with {@link ShapeUtil.onReleaseAppOwnedElement} to reclaim the element before
995
+ * the shape or editor unmounts.
996
+ *
997
+ * This is called once per shape mount, not on every prop change, so the adopted element is
998
+ * not refreshed when the shape's props change. If the element's content depends on props,
999
+ * return a stable element and mutate it in place (for example from {@link ShapeUtil.component}
1000
+ * or an effect) rather than returning a different element.
1001
+ *
1002
+ * @param shape - The shape.
1003
+ * @returns The element to adopt, or null to render nothing.
1004
+ * @public
1005
+ */
1006
+ getAppOwnedElement?(shape: Shape): HTMLElement | null
1007
+
1008
+ /**
1009
+ * A callback called before the shape's app-owned element slot is destroyed: when the shape
1010
+ * unmounts (for example when it is deleted or the current page changes) or when the whole
1011
+ * editor unmounts, including error teardown. The slot is still connected to the document
1012
+ * when this is called, so the app can move the element to another connected parent with
1013
+ * `Node.moveBefore` to preserve its state. An element left in the slot is destroyed along
1014
+ * with it.
1015
+ *
1016
+ * This is not only a "shape deleted" signal: it also fires on page changes, editor unmount,
1017
+ * and (in dev under React StrictMode) on the throwaway mount/unmount cycle while the shape
1018
+ * still exists. Make it safe to call when the shape is still present, and check the store
1019
+ * (`this.editor.getShape(shape.id)`) if you need to distinguish deletion from a remount.
1020
+ *
1021
+ * @param shape - The shape.
1022
+ * @param element - The element returned by {@link ShapeUtil.getAppOwnedElement}.
1023
+ * @public
1024
+ */
1025
+ onReleaseAppOwnedElement?(shape: Shape, element: HTMLElement): void
985
1026
  }
986
1027
 
987
1028
  /**
@@ -4,6 +4,7 @@ import { Vec } from '../../../../primitives/Vec'
4
4
  import type { Editor } from '../../../Editor'
5
5
  import { TLBaseBoxShape } from '../../../shapes/BaseBoxShapeUtil'
6
6
  import { TLPointerEventInfo } from '../../../types/event-types'
7
+ import { cancelShapeCreationOnLongPress } from '../../cancelShapeCreationOnLongPress'
7
8
  import { StateNode } from '../../StateNode'
8
9
  import type { BaseBoxShapeTool } from '../BaseBoxShapeTool'
9
10
 
@@ -65,6 +66,10 @@ export class Pointing extends StateNode {
65
66
  this.complete()
66
67
  }
67
68
 
69
+ override onLongPress() {
70
+ cancelShapeCreationOnLongPress(this.editor, () => this.cancel())
71
+ }
72
+
68
73
  override onCancel() {
69
74
  this.cancel()
70
75
  }
@@ -268,8 +268,6 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
268
268
  onLongPress?(info: TLPointerEventInfo): void
269
269
  onPointerUp?(info: TLPointerEventInfo): void
270
270
  onDoubleClick?(info: TLClickEventInfo): void
271
- onTripleClick?(info: TLClickEventInfo): void
272
- onQuadrupleClick?(info: TLClickEventInfo): void
273
271
  onRightClick?(info: TLPointerEventInfo): void
274
272
  onMiddleClick?(info: TLPointerEventInfo): void
275
273
  onKeyDown?(info: TLKeyboardEventInfo): void
@@ -0,0 +1,31 @@
1
+ import type { Editor } from '../Editor'
2
+
3
+ /**
4
+ * Shared handler for a long-press in a discrete shape-creation tool's pointing
5
+ * state (geo, note, line, text, arrow, frame). On a coarse pointer this cancels
6
+ * any pending creation so the long-press leaves no shape behind (issue #8277).
7
+ * It does not open a context menu — that is a select-tool surface, reached on
8
+ * touch only from the select tool, and on desktop from a right-click (which the
9
+ * editor routes through the select tool).
10
+ *
11
+ * Gated on `isCoarsePointer`: on a fine pointer (mouse) the long-press timer
12
+ * powers the deliberate "pause before drag to start a precise arrow" gesture,
13
+ * which we must not disturb. The bug only manifests on coarse pointers, because
14
+ * that is also where the browser fires `contextmenu` at the long-press mark.
15
+ *
16
+ * The `cancelPendingCreation` callback is the tool's own cleanup — it knows
17
+ * whether it needs to `bailToMark` a shape it created early or simply transition
18
+ * back to idle. Continuous-gesture tools (draw, highlight, eraser, laser) are
19
+ * excluded: their press is their own action, so they handle (or ignore) the
20
+ * long-press themselves.
21
+ *
22
+ * @internal
23
+ */
24
+ export function cancelShapeCreationOnLongPress(
25
+ editor: Editor,
26
+ cancelPendingCreation: () => void
27
+ ): void {
28
+ if (!editor.getInstanceState().isCoarsePointer) return
29
+
30
+ cancelPendingCreation()
31
+ }
@@ -24,7 +24,7 @@ export type TLPointerEventName =
24
24
  | 'middle_click'
25
25
 
26
26
  /** @public */
27
- export type TLCLickEventName = 'double_click' | 'triple_click' | 'quadruple_click'
27
+ export type TLCLickEventName = 'double_click'
28
28
 
29
29
  /** @public */
30
30
  export type TLPinchEventName = 'pinch_start' | 'pinch' | 'pinch_end'
@@ -63,6 +63,13 @@ export type TLPointerEventInfo = TLBaseEventInfo & {
63
63
  pointerId: number
64
64
  button: number
65
65
  isPen: boolean
66
+ /**
67
+ * Whether this pen event appears to be direct manipulation on the display (e.g. Apple Pencil on
68
+ * an iPad or a Surface Pen on a touchscreen) rather than indirect input from a desktop graphics
69
+ * tablet (e.g. a Wacom Intuos). Only direct-display pens should auto-enable pen mode. Drawing and
70
+ * pressure behavior is driven by `isPen` and applies to all pens regardless of this flag.
71
+ */
72
+ isPenDirect?: boolean
66
73
  } & TLPointerEventTarget
67
74
 
68
75
  /** @public */
@@ -72,7 +79,7 @@ export type TLClickEventInfo = TLBaseEventInfo & {
72
79
  point: VecLike
73
80
  pointerId: number
74
81
  button: number
75
- phase: 'down' | 'up' | 'settle'
82
+ phase: 'down' | 'up' | 'settle-down' | 'settle-up'
76
83
  } & TLPointerEventTarget
77
84
 
78
85
  /** @public */
@@ -173,8 +180,6 @@ export interface TLEventHandlers {
173
180
  onLongPress: TLPointerEvent
174
181
  onRightClick: TLPointerEvent
175
182
  onDoubleClick: TLClickEvent
176
- onTripleClick: TLClickEvent
177
- onQuadrupleClick: TLClickEvent
178
183
  onMiddleClick: TLPointerEvent
179
184
  onPointerUp: TLPointerEvent
180
185
  onKeyDown: TLKeyboardEvent
@@ -206,7 +211,5 @@ export const EVENT_NAME_MAP: Record<
206
211
  complete: 'onComplete',
207
212
  interrupt: 'onInterrupt',
208
213
  double_click: 'onDoubleClick',
209
- triple_click: 'onTripleClick',
210
- quadruple_click: 'onQuadrupleClick',
211
214
  tick: 'onTick',
212
215
  }
@@ -17,6 +17,10 @@ const tlenv = {
17
17
  isWebview: false,
18
18
  isDarwin: false,
19
19
  hasCanvasSupport: false,
20
+ // Whether the device has a touch screen (an integrated coarse pointer, e.g. an iPad or a
21
+ // touchscreen laptop). Unlike `isCoarsePointer`, this reflects the device's hardware rather than
22
+ // the pointer currently in use, so it stays stable when a pen or mouse is used.
23
+ isTouchDevice: false,
20
24
  }
21
25
 
22
26
  let isForcedFinePointer = false
@@ -32,6 +36,9 @@ if (typeof window !== 'undefined') {
32
36
  }
33
37
  tlenv.hasCanvasSupport = 'Promise' in window && 'HTMLCanvasElement' in window
34
38
  isForcedFinePointer = tlenv.isFirefox && !tlenv.isAndroid && !tlenv.isIos
39
+ tlenv.isTouchDevice =
40
+ ('navigator' in window && window.navigator.maxTouchPoints > 0) ||
41
+ (typeof window.matchMedia === 'function' && window.matchMedia('(any-pointer: coarse)').matches)
35
42
  }
36
43
 
37
44
  /**
@@ -2,7 +2,6 @@ import { ComponentType, RefAttributes, createContext, useContext } from 'react'
2
2
  import type { TLCanvasComponentProps } from '../components/default-components/DefaultCanvas'
3
3
  import type { TLErrorFallbackComponent } from '../components/default-components/DefaultErrorFallback'
4
4
  import type { TLGridProps } from '../components/default-components/DefaultGrid'
5
- import type { TLSelectionBackgroundProps } from '../components/default-components/DefaultSelectionBackground'
6
5
  import type { TLShapeErrorFallbackComponent } from '../components/default-components/DefaultShapeErrorFallback'
7
6
  import type { TLShapeWrapperProps } from '../components/default-components/DefaultShapeWrapper'
8
7
 
@@ -14,7 +13,6 @@ export interface TLEditorComponents {
14
13
  InFrontOfTheCanvas?: ComponentType | null
15
14
  LoadingScreen?: ComponentType | null
16
15
  OnTheCanvas?: ComponentType | null
17
- SelectionBackground?: ComponentType<TLSelectionBackgroundProps> | null
18
16
  ShapeWrapper?: ComponentType<TLShapeWrapperProps & RefAttributes<HTMLDivElement>> | null
19
17
  Spinner?: ComponentType<React.SVGProps<SVGSVGElement>> | null
20
18
  SvgDefs?: ComponentType | null
@@ -8,6 +8,7 @@ import {
8
8
  setPointerCapture,
9
9
  } from '../utils/dom'
10
10
  import { getPointerInfo } from '../utils/getPointerInfo'
11
+ import { getPointerEventButton, isDirectDisplayPen, isSecondaryClickEvent } from '../utils/pointer'
11
12
  import { useEditor } from './useEditor'
12
13
 
13
14
  export function useCanvasEvents() {
@@ -17,12 +18,16 @@ export function useCanvasEvents() {
17
18
 
18
19
  const events = useMemo(
19
20
  function canvasEvents() {
21
+ let isSecondaryClickPointerDown = false
22
+
20
23
  function onPointerDown(e: React.PointerEvent) {
21
24
  if (editor.wasEventAlreadyHandled(e)) return
25
+ const button = getPointerEventButton(e)
26
+ isSecondaryClickPointerDown = button === 2
22
27
 
23
28
  // With right-click panning disabled, fire right_click on press and let the
24
29
  // native contextmenu through so the menu opens at the pointer-down location.
25
- if (e.button === 2 && !editor.options.rightClickPanning) {
30
+ if (button === 2 && !editor.options.rightClickPanning) {
26
31
  editor.dispatch({
27
32
  type: 'pointer',
28
33
  target: 'canvas',
@@ -32,7 +37,11 @@ export function useCanvasEvents() {
32
37
  return
33
38
  }
34
39
 
35
- if (e.button !== 0 && e.button !== 1 && e.button !== 2 && e.button !== 5) return
40
+ if (button !== 0 && button !== 1 && button !== 2 && button !== 5) return
41
+
42
+ // Detect direct-display pen input (Apple Pencil, Surface Pen on a touchscreen) so we
43
+ // only auto-enable pen mode for it, not for an indirect desktop tablet stylus.
44
+ const isPenDirect = isDirectDisplayPen(e)
36
45
 
37
46
  setPointerCapture(e.currentTarget, e)
38
47
 
@@ -41,17 +50,19 @@ export function useCanvasEvents() {
41
50
  target: 'canvas',
42
51
  name: 'pointer_down',
43
52
  ...getPointerInfo(editor, e),
53
+ isPenDirect,
44
54
  })
45
55
  }
46
56
 
47
57
  function onPointerUp(e: React.PointerEvent) {
48
58
  if (editor.wasEventAlreadyHandled(e)) return
49
- if (e.button !== 0 && e.button !== 1 && e.button !== 2 && e.button !== 5) return
59
+ const button = isSecondaryClickPointerDown ? 2 : getPointerEventButton(e)
60
+ if (button !== 0 && button !== 1 && button !== 2 && button !== 5) return
50
61
 
51
62
  const rightClickPanning = editor.options.rightClickPanning
52
63
  // Check before dispatch (which resets isPanning)
53
64
  const wasRightClickPanning =
54
- rightClickPanning && e.button === 2 && editor.inputs.getIsPanning()
65
+ rightClickPanning && button === 2 && editor.inputs.getIsPanning()
55
66
 
56
67
  releasePointerCapture(e.currentTarget, e)
57
68
 
@@ -60,10 +71,11 @@ export function useCanvasEvents() {
60
71
  target: 'canvas',
61
72
  name: 'pointer_up',
62
73
  ...getPointerInfo(editor, e),
74
+ button,
63
75
  })
64
76
 
65
77
  // Static right-click: fire contextmenu at the pointer-up location
66
- if (rightClickPanning && e.button === 2 && !wasRightClickPanning) {
78
+ if (rightClickPanning && button === 2 && !wasRightClickPanning) {
67
79
  const contextMenuEvent = new PointerEvent('contextmenu', {
68
80
  bubbles: true,
69
81
  clientX: e.clientX,
@@ -76,6 +88,7 @@ export function useCanvasEvents() {
76
88
  })
77
89
  e.currentTarget.dispatchEvent(contextMenuEvent)
78
90
  }
91
+ isSecondaryClickPointerDown = false
79
92
  }
80
93
 
81
94
  function onPointerEnter(e: React.PointerEvent) {
@@ -171,16 +184,15 @@ export function useCanvasEvents() {
171
184
  // Synthetic events — our own dispatch from onPointerUp, or tests using
172
185
  // fireEvent.contextMenu — pass through so Radix can open the menu.
173
186
  if (!e.nativeEvent.isTrusted) return
174
- // Only suppress the native browser contextmenu when it follows a real
175
- // right-click (button=2 with no ctrl modifier). For those, our pointer
176
- // handling has already decided what to do (either we'll dispatch a
177
- // synthetic contextmenu on pointerup to open the menu at the release
178
- // position, or we panned and don't want a menu at all).
187
+ // Only suppress the native browser contextmenu when it follows a
188
+ // secondary click. For those, our pointer handling has already
189
+ // decided what to do (either we'll dispatch a synthetic contextmenu on
190
+ // pointerup to open the menu at the release position, or we panned and
191
+ // don't want a menu at all).
179
192
  //
180
193
  // Other contextmenu sources must reach Radix so the menu opens:
181
- // - ctrl+click on macOS (button=0, or button=2 with ctrlKey=true)
182
194
  // - long-press on touch devices (button=0, pointerType=touch)
183
- if (e.button !== 2 || e.ctrlKey) return
195
+ if (!isSecondaryClickEvent(e)) return
184
196
  preventDefault(e)
185
197
  }
186
198
 
@@ -33,7 +33,6 @@ export function EditorComponentsProvider({
33
33
  InFrontOfTheCanvas: null,
34
34
  LoadingScreen: DefaultLoadingScreen,
35
35
  OnTheCanvas: null,
36
- SelectionBackground: null,
37
36
  ShapeWrapper: DefaultShapeWrapper,
38
37
  Spinner: DefaultSpinner,
39
38
  SvgDefs: DefaultSvgDefs,
@@ -1,5 +1,5 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
- import { TLInstancePresence } from '@tldraw/tlschema'
2
+ import { TLInstancePresence, TLUserId } from '@tldraw/tlschema'
3
3
  import { useEditor } from './useEditor'
4
4
 
5
5
  // TODO: maybe move this to a computed property on the App class?
@@ -7,7 +7,7 @@ import { useEditor } from './useEditor'
7
7
  * @returns The latest presence of the user matching userId
8
8
  * @public
9
9
  */
10
- export function usePresence(userId: string): TLInstancePresence | null {
10
+ export function usePresence(userId: TLUserId): TLInstancePresence | null {
11
11
  const editor = useEditor()
12
12
 
13
13
  const latestPresence = useValue(
@@ -11,6 +11,9 @@ export function useScreenBounds(ref: React.RefObject<HTMLElement | null>) {
11
11
  const updateBounds = throttle(
12
12
  () => {
13
13
  if (!ref.current) return
14
+ // If the container's document has been torn down (e.g. an iframe
15
+ // being removed), there's nothing to measure — bail.
16
+ if (!ref.current.ownerDocument.body) return
14
17
  editor.updateViewportScreenBounds(ref.current)
15
18
  },
16
19
  200,
@@ -60,22 +60,30 @@ describe('LicenseManager', () => {
60
60
  expect(result).toMatchObject({ isLicenseParseable: false, reason: 'no-key-provided' })
61
61
  })
62
62
 
63
- it('Signals that it is development mode when localhost', async () => {
64
- const schemes = ['http', 'https']
65
- for (const scheme of schemes) {
66
- // @ts-ignore
67
- delete window.location
68
- // @ts-ignore
69
- window.location = new URL(`${scheme}://localhost:3000`)
70
-
71
- const testEnvLicenseManager = new LicenseManager('', keyPair.publicKey)
72
- const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
73
- const result = await testEnvLicenseManager.getLicenseFromKey(licenseKey)
74
- expect(result).toMatchObject({
75
- isLicenseParseable: true,
76
- isDomainValid: false,
77
- isDevelopment: true,
78
- })
63
+ it('Signals that it is development mode for loopback hosts', async () => {
64
+ process.env.NODE_ENV = 'production'
65
+ try {
66
+ const schemes = ['http', 'https']
67
+ const hosts = ['localhost', '127.0.0.1', '127.0.0.2', '[::1]']
68
+ for (const scheme of schemes) {
69
+ for (const host of hosts) {
70
+ // @ts-ignore
71
+ delete window.location
72
+ // @ts-ignore
73
+ window.location = new URL(`${scheme}://${host}:3000`)
74
+
75
+ const testEnvLicenseManager = new LicenseManager('', keyPair.publicKey)
76
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
77
+ const result = await testEnvLicenseManager.getLicenseFromKey(licenseKey)
78
+ expect(result).toMatchObject({
79
+ isLicenseParseable: true,
80
+ isDomainValid: false,
81
+ isDevelopment: true,
82
+ })
83
+ }
84
+ }
85
+ } finally {
86
+ process.env.NODE_ENV = 'test'
79
87
  }
80
88
  })
81
89
 
@@ -125,14 +125,20 @@ export class LicenseManager {
125
125
  }
126
126
 
127
127
  private getIsDevelopment() {
128
- // If we are using https on a non-localhost domain we assume it's a production env and a development one otherwise
128
+ // If we are using https on a non-loopback domain we assume it's a production env and a development one otherwise
129
129
  return (
130
130
  !['https:', 'vscode-webview:'].includes(window.location.protocol) ||
131
- window.location.hostname === 'localhost' ||
131
+ this.isLoopbackHost(window.location.hostname) ||
132
132
  process.env.NODE_ENV !== 'production'
133
133
  )
134
134
  }
135
135
 
136
+ private isLoopbackHost(hostname: string) {
137
+ // localhost, IPv4 loopback (127.0.0.0/8) and IPv6 loopback (::1) are all local development hosts
138
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
139
+ return host === 'localhost' || host === '::1' || /^127(?:\.\d{1,3}){3}$/.test(host)
140
+ }
141
+
136
142
  private getTrackType(result: LicenseFromKeyResult, licenseState: LicenseState): TrackType {
137
143
  // Track watermark for unlicensed production deployments
138
144
  if (licenseState === 'unlicensed-production') {
@@ -1,5 +1,8 @@
1
1
  import { VecModel } from '@tldraw/tlschema'
2
2
  import { EASINGS } from './easings'
3
+
4
+ const { easeInQuad, easeInOutQuad } = EASINGS
5
+
3
6
  function clamp(n: number, min: number, max?: number): number {
4
7
  return Math.max(min, typeof max !== 'undefined' ? Math.min(n, max) : n)
5
8
  }
@@ -634,13 +637,13 @@ export class Vec {
634
637
  * @param steps - The number of points to return.
635
638
  * @param ease - The easing to use.
636
639
  */
637
- static PointsBetween(A: VecModel, B: VecModel, steps = 6, ease = EASINGS.easeInQuad): Vec[] {
640
+ static PointsBetween(A: VecModel, B: VecModel, steps = 6, ease = easeInQuad): Vec[] {
638
641
  const results: Vec[] = []
639
642
 
640
643
  for (let i = 0; i < steps; i++) {
641
644
  const t = ease(i / (steps - 1))
642
645
  const point = Vec.Lrp(A, B, t)
643
- point.z = Math.min(1, 0.5 + Math.abs(0.5 - EASINGS.easeInOutQuad(t)) * 0.65)
646
+ point.z = Math.min(1, 0.5 + Math.abs(0.5 - easeInOutQuad(t)) * 0.65)
644
647
  results.push(point)
645
648
  }
646
649
 
@@ -0,0 +1,201 @@
1
+ import { act, render } from '@testing-library/react'
2
+ import { BaseBoxShapeUtil, createShapeId, Editor, RecordProps, T, TLShape } from '../..'
3
+ import { TldrawEditor } from '../TldrawEditor'
4
+
5
+ const PERSISTENT_SHAPE_TYPE = 'test-persistent'
6
+
7
+ declare module '@tldraw/tlschema' {
8
+ export interface TLGlobalShapePropsMap {
9
+ [PERSISTENT_SHAPE_TYPE]: { w: number; h: number }
10
+ }
11
+ }
12
+
13
+ type IPersistentShape = TLShape<typeof PERSISTENT_SHAPE_TYPE>
14
+
15
+ // App-owned element registry and parking lot, the way an app using
16
+ // getContentElement would keep elements alive across editor sessions.
17
+ const elements = new Map<string, HTMLElement>()
18
+ let parkingLot: HTMLDivElement
19
+ const releaseCalls: Array<{ shapeId: string; element: HTMLElement; slotWasConnected: boolean }> = []
20
+
21
+ class PersistentShapeUtil extends BaseBoxShapeUtil<IPersistentShape> {
22
+ static override type = PERSISTENT_SHAPE_TYPE
23
+ static override props: RecordProps<IPersistentShape> = {
24
+ w: T.number,
25
+ h: T.number,
26
+ }
27
+ getDefaultProps(): IPersistentShape['props'] {
28
+ return { w: 100, h: 100 }
29
+ }
30
+ getIndicatorPath() {
31
+ return undefined
32
+ }
33
+ component() {
34
+ return null
35
+ }
36
+ override getAppOwnedElement(shape: IPersistentShape) {
37
+ let element = elements.get(shape.id)
38
+ if (!element) {
39
+ element = document.createElement('div')
40
+ element.dataset.testElementFor = shape.id
41
+ elements.set(shape.id, element)
42
+ }
43
+ return element
44
+ }
45
+ override onReleaseAppOwnedElement(shape: IPersistentShape, element: HTMLElement) {
46
+ releaseCalls.push({
47
+ shapeId: shape.id,
48
+ element,
49
+ slotWasConnected: element.parentElement?.isConnected ?? false,
50
+ })
51
+ parkingLot.appendChild(element)
52
+ }
53
+ }
54
+
55
+ function getSlotFor(shapeId: string) {
56
+ return document.querySelector(`[data-shape-id="${shapeId}"] .tl-content-slot`)
57
+ }
58
+
59
+ describe('content element slot', () => {
60
+ beforeEach(() => {
61
+ elements.clear()
62
+ releaseCalls.length = 0
63
+ parkingLot = document.createElement('div')
64
+ document.body.appendChild(parkingLot)
65
+ })
66
+
67
+ afterEach(() => {
68
+ parkingLot.remove()
69
+ })
70
+
71
+ async function renderEditor() {
72
+ let editor!: Editor
73
+ const result = await act(async () =>
74
+ render(
75
+ <TldrawEditor
76
+ shapeUtils={[PersistentShapeUtil]}
77
+ onMount={(e) => {
78
+ editor = e
79
+ }}
80
+ />
81
+ )
82
+ )
83
+ return { ...result, editor }
84
+ }
85
+
86
+ it('adopts the app-owned element into the shape and releases it before editor unmount', async () => {
87
+ const { editor, unmount } = await renderEditor()
88
+ const id = createShapeId()
89
+
90
+ await act(async () => {
91
+ editor.createShape<IPersistentShape>({ id, type: PERSISTENT_SHAPE_TYPE, x: 0, y: 0 })
92
+ })
93
+
94
+ const element = elements.get(id)!
95
+ expect(element).toBeDefined()
96
+ expect(element.parentElement).toBe(getSlotFor(id))
97
+ expect(element.isConnected).toBe(true)
98
+
99
+ await act(async () => {
100
+ unmount()
101
+ })
102
+
103
+ // The release callback fired exactly once, while the slot was still
104
+ // connected to the document, so a state-preserving move was possible.
105
+ expect(releaseCalls).toHaveLength(1)
106
+ expect(releaseCalls[0]).toMatchObject({ shapeId: id, element, slotWasConnected: true })
107
+ // The app reclaimed the element into its parking lot and it survived teardown.
108
+ expect(element.parentElement).toBe(parkingLot)
109
+ expect(element.isConnected).toBe(true)
110
+ })
111
+
112
+ it('releases the element when the shape is deleted', async () => {
113
+ const { editor, unmount } = await renderEditor()
114
+ const id = createShapeId()
115
+
116
+ await act(async () => {
117
+ editor.createShape<IPersistentShape>({ id, type: PERSISTENT_SHAPE_TYPE, x: 0, y: 0 })
118
+ })
119
+
120
+ const element = elements.get(id)!
121
+ expect(element.parentElement).toBe(getSlotFor(id))
122
+
123
+ await act(async () => {
124
+ editor.deleteShape(id)
125
+ })
126
+
127
+ expect(releaseCalls).toHaveLength(1)
128
+ expect(releaseCalls[0]).toMatchObject({ shapeId: id, element, slotWasConnected: true })
129
+ expect(element.parentElement).toBe(parkingLot)
130
+
131
+ await act(async () => {
132
+ unmount()
133
+ })
134
+
135
+ // The slot is gone; no second release for the deleted shape.
136
+ expect(releaseCalls).toHaveLength(1)
137
+ })
138
+
139
+ it('re-adopts a parked element when the editor remounts, without recreating it', async () => {
140
+ const first = await renderEditor()
141
+ const id = createShapeId()
142
+
143
+ await act(async () => {
144
+ first.editor.createShape<IPersistentShape>({ id, type: PERSISTENT_SHAPE_TYPE, x: 0, y: 0 })
145
+ })
146
+
147
+ const element = elements.get(id)!
148
+
149
+ await act(async () => {
150
+ first.unmount()
151
+ })
152
+ expect(element.parentElement).toBe(parkingLot)
153
+
154
+ const second = await renderEditor()
155
+ await act(async () => {
156
+ second.editor.createShape<IPersistentShape>({ id, type: PERSISTENT_SHAPE_TYPE, x: 0, y: 0 })
157
+ })
158
+
159
+ // The same element instance was adopted into the new editor's slot.
160
+ expect(elements.get(id)).toBe(element)
161
+ expect(element.parentElement).toBe(getSlotFor(id))
162
+
163
+ await act(async () => {
164
+ second.unmount()
165
+ })
166
+ expect(element.parentElement).toBe(parkingLot)
167
+ })
168
+
169
+ it('keeps the element in the same DOM position when z-order changes', async () => {
170
+ const { editor, unmount } = await renderEditor()
171
+ const id = createShapeId()
172
+ const otherId = createShapeId()
173
+
174
+ await act(async () => {
175
+ editor.createShape<IPersistentShape>({ id, type: PERSISTENT_SHAPE_TYPE, x: 0, y: 0 })
176
+ editor.createShape<IPersistentShape>({
177
+ id: otherId,
178
+ type: PERSISTENT_SHAPE_TYPE,
179
+ x: 50,
180
+ y: 50,
181
+ })
182
+ })
183
+
184
+ const element = elements.get(id)!
185
+ const slot = getSlotFor(id)
186
+ expect(element.parentElement).toBe(slot)
187
+
188
+ await act(async () => {
189
+ editor.sendToBack([id])
190
+ editor.bringToFront([id])
191
+ })
192
+
193
+ // No release fired and the element is still in the same slot node.
194
+ expect(releaseCalls).toHaveLength(0)
195
+ expect(element.parentElement).toBe(slot)
196
+
197
+ await act(async () => {
198
+ unmount()
199
+ })
200
+ })
201
+ })
@@ -100,6 +100,40 @@ export function setStyleProperty(
100
100
  elm.style.setProperty(property, String(value))
101
101
  }
102
102
 
103
+ /**
104
+ * Move an element into a new parent, preserving its state where the platform allows it.
105
+ *
106
+ * Uses `Node.moveBefore` (Chromium 133+, Firefox 144+) when both the element and parent are
107
+ * connected to the same document — this moves the element without resetting its state, so
108
+ * iframes don't reload and media keeps playing. Otherwise (older browsers, disconnected nodes,
109
+ * or a cross-document move) it falls back to `appendChild`, which moves the element but resets
110
+ * its state like an iframe reload.
111
+ *
112
+ * This is the primitive tldraw uses to adopt `ShapeUtil.getAppOwnedElement` elements, exposed so
113
+ * apps can perform symmetric state-preserving moves from `ShapeUtil.onReleaseAppOwnedElement` —
114
+ * for example moving an element to an off-canvas parking lot between editor sessions.
115
+ *
116
+ * @param parent - The element to move `element` into, as its last child.
117
+ * @param element - The element to move.
118
+ * @public
119
+ */
120
+ export function moveElementInto(parent: HTMLElement, element: HTMLElement) {
121
+ if (
122
+ element.isConnected &&
123
+ parent.isConnected &&
124
+ typeof (parent as any).moveBefore === 'function' &&
125
+ element.ownerDocument === parent.ownerDocument
126
+ ) {
127
+ try {
128
+ ;(parent as any).moveBefore(element, null)
129
+ return
130
+ } catch {
131
+ // fall through to appendChild
132
+ }
133
+ }
134
+ parent.appendChild(element)
135
+ }
136
+
103
137
  /** @internal */
104
138
  export function elementShouldCaptureKeys(el: Element | null, includeButtonsAndMenus = true) {
105
139
  if (!el) return false