@tldraw/editor 5.3.0-next.fc37bd96825b → 5.3.1

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 (112) hide show
  1. package/dist-cjs/index.d.ts +138 -3
  2. package/dist-cjs/index.js +13 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +24 -19
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/EditorPortal.js +44 -0
  7. package/dist-cjs/lib/components/EditorPortal.js.map +7 -0
  8. package/dist-cjs/lib/components/LiveCollaborators.js +102 -0
  9. package/dist-cjs/lib/components/LiveCollaborators.js.map +7 -0
  10. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +4 -10
  11. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +3 -3
  12. package/dist-cjs/lib/components/default-components/DefaultCursor.js +59 -0
  13. package/dist-cjs/lib/components/default-components/DefaultCursor.js.map +7 -0
  14. package/dist-cjs/lib/editor/Editor.js +7 -2
  15. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  16. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +4 -4
  17. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  18. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  19. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  20. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  21. package/dist-cjs/lib/hooks/useCursor.js +1 -0
  22. package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
  23. package/dist-cjs/lib/hooks/useDocumentEvents.js +3 -2
  24. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  25. package/dist-cjs/lib/hooks/useEditorComponents.js +2 -0
  26. package/dist-cjs/lib/hooks/useEditorComponents.js.map +2 -2
  27. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +49 -18
  28. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +3 -3
  29. package/dist-cjs/lib/hooks/useTransform.js +45 -0
  30. package/dist-cjs/lib/hooks/useTransform.js.map +7 -0
  31. package/dist-cjs/lib/license/LicenseManager.js +51 -2
  32. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  33. package/dist-cjs/lib/license/useLicenseManagerState.js +7 -0
  34. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  35. package/dist-cjs/lib/utils/collaborators.js +27 -0
  36. package/dist-cjs/lib/utils/collaborators.js.map +7 -0
  37. package/dist-cjs/lib/utils/getHtmlLayerTransform.js +33 -0
  38. package/dist-cjs/lib/utils/getHtmlLayerTransform.js.map +7 -0
  39. package/dist-cjs/version.js +3 -3
  40. package/dist-cjs/version.js.map +1 -1
  41. package/dist-esm/index.d.mts +138 -3
  42. package/dist-esm/index.mjs +19 -4
  43. package/dist-esm/index.mjs.map +2 -2
  44. package/dist-esm/lib/TldrawEditor.mjs +23 -18
  45. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  46. package/dist-esm/lib/components/EditorPortal.mjs +24 -0
  47. package/dist-esm/lib/components/EditorPortal.mjs.map +7 -0
  48. package/dist-esm/lib/components/LiveCollaborators.mjs +82 -0
  49. package/dist-esm/lib/components/LiveCollaborators.mjs.map +7 -0
  50. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -11
  51. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  52. package/dist-esm/lib/components/default-components/DefaultCursor.mjs +29 -0
  53. package/dist-esm/lib/components/default-components/DefaultCursor.mjs.map +7 -0
  54. package/dist-esm/lib/editor/Editor.mjs +7 -2
  55. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  56. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +5 -5
  57. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  58. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  59. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  60. package/dist-esm/lib/hooks/useCursor.mjs +1 -0
  61. package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
  62. package/dist-esm/lib/hooks/useDocumentEvents.mjs +3 -2
  63. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  64. package/dist-esm/lib/hooks/useEditorComponents.mjs +2 -0
  65. package/dist-esm/lib/hooks/useEditorComponents.mjs.map +2 -2
  66. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +50 -19
  67. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +3 -3
  68. package/dist-esm/lib/hooks/useTransform.mjs +25 -0
  69. package/dist-esm/lib/hooks/useTransform.mjs.map +7 -0
  70. package/dist-esm/lib/license/LicenseManager.mjs +52 -3
  71. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  72. package/dist-esm/lib/license/useLicenseManagerState.mjs +7 -0
  73. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  74. package/dist-esm/lib/utils/collaborators.mjs +7 -0
  75. package/dist-esm/lib/utils/collaborators.mjs.map +7 -0
  76. package/dist-esm/lib/utils/getHtmlLayerTransform.mjs +13 -0
  77. package/dist-esm/lib/utils/getHtmlLayerTransform.mjs.map +7 -0
  78. package/dist-esm/version.mjs +3 -3
  79. package/dist-esm/version.mjs.map +1 -1
  80. package/editor.css +43 -4
  81. package/package.json +7 -7
  82. package/src/index.ts +14 -2
  83. package/src/lib/TldrawEditor.tsx +21 -13
  84. package/src/lib/components/EditorPortal.tsx +77 -0
  85. package/src/lib/components/LiveCollaborators.tsx +133 -0
  86. package/src/lib/components/default-components/DefaultCanvas.tsx +5 -17
  87. package/src/lib/components/default-components/DefaultCursor.tsx +65 -0
  88. package/src/lib/editor/Editor.ts +14 -2
  89. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +46 -1
  90. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +10 -5
  91. package/src/lib/editor/shapes/ShapeUtil.ts +24 -0
  92. package/src/lib/editor/types/external-content.ts +5 -0
  93. package/src/lib/hooks/EditorComponentsContext.tsx +2 -0
  94. package/src/lib/hooks/useCursor.ts +1 -0
  95. package/src/lib/hooks/useDocumentEvents.ts +9 -8
  96. package/src/lib/hooks/useEditorComponents.tsx +2 -0
  97. package/src/lib/hooks/usePassThroughWheelEvents.ts +83 -24
  98. package/src/lib/hooks/useTransform.ts +38 -0
  99. package/src/lib/license/LicenseManager.test.ts +146 -0
  100. package/src/lib/license/LicenseManager.ts +98 -2
  101. package/src/lib/license/useLicenseManagerState.ts +17 -1
  102. package/src/lib/test/EditorPortal.test.tsx +48 -0
  103. package/src/lib/test/usePassThroughWheelEvents.test.tsx +77 -0
  104. package/src/lib/utils/collaborators.test.ts +34 -0
  105. package/src/lib/utils/collaborators.ts +25 -0
  106. package/src/lib/utils/getHtmlLayerTransform.ts +22 -0
  107. package/src/version.ts +3 -3
  108. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js +0 -51
  109. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js.map +0 -7
  110. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs +0 -31
  111. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs.map +0 -7
  112. package/src/lib/hooks/usePassThroughMouseOverEvents.ts +0 -32
@@ -24,6 +24,7 @@ import React, {
24
24
  } from 'react'
25
25
  import { version } from '../version'
26
26
  import { DefaultErrorFallback } from './components/default-components/DefaultErrorFallback'
27
+ import { EditorPortalProvider } from './components/EditorPortal'
27
28
  import { OptionalErrorBoundary } from './components/ErrorBoundary'
28
29
  import { createTLCurrentUser, TLCurrentUser } from './config/createTLCurrentUser'
29
30
  import { TLStoreBaseOptions } from './config/createTLStore'
@@ -304,6 +305,7 @@ export const TldrawEditor = memo(function TldrawEditor({
304
305
  registerFontsFromThemes(resolvedThemes)
305
306
 
306
307
  const [container, setContainer] = useState<HTMLElement | null>(null)
308
+ const [portalHost, setPortalHost] = useState<HTMLElement | null>(null)
307
309
  const user = useMemo(() => _user ?? createTLCurrentUser(), [_user])
308
310
 
309
311
  const ErrorFallback =
@@ -352,24 +354,30 @@ export const TldrawEditor = memo(function TldrawEditor({
352
354
  {container && (
353
355
  <LicenseProvider licenseKey={rest.licenseKey}>
354
356
  <ContainerProvider container={container}>
355
- <EditorComponentsProvider overrides={components}>
356
- {store ? (
357
- store instanceof Store ? (
358
- // Store is ready to go, whether externally synced or not
359
- <TldrawEditorWithReadyStore {...withDefaults} store={store} user={user} />
357
+ <EditorPortalProvider host={portalHost}>
358
+ <EditorComponentsProvider overrides={components}>
359
+ {store ? (
360
+ store instanceof Store ? (
361
+ // Store is ready to go, whether externally synced or not
362
+ <TldrawEditorWithReadyStore {...withDefaults} store={store} user={user} />
363
+ ) : (
364
+ // Store is a synced store, so handle syncing stages internally
365
+ <TldrawEditorWithLoadingStore {...withDefaults} store={store} user={user} />
366
+ )
360
367
  ) : (
361
- // Store is a synced store, so handle syncing stages internally
362
- <TldrawEditorWithLoadingStore {...withDefaults} store={store} user={user} />
363
- )
364
- ) : (
365
- // We have no store (it's undefined) so create one and possibly sync it
366
- <TldrawEditorWithOwnStore {...withDefaults} store={store} user={user} />
367
- )}
368
- </EditorComponentsProvider>
368
+ // We have no store (it's undefined) so create one and possibly sync it
369
+ <TldrawEditorWithOwnStore {...withDefaults} store={store} user={user} />
370
+ )}
371
+ </EditorComponentsProvider>
372
+ </EditorPortalProvider>
369
373
  </ContainerProvider>
370
374
  </LicenseProvider>
371
375
  )}
372
376
  </OptionalErrorBoundary>
377
+ {/* The host for <EditorPortal>, last among the container's children so that anything
378
+ portaled through it lands after the canvas and the UI — behind the UI's "skip to main
379
+ content" link in the tab order, which only works while nothing precedes it. */}
380
+ <div className="tl-portal-host" ref={setPortalHost} />
373
381
  </div>
374
382
  )
375
383
  })
@@ -0,0 +1,77 @@
1
+ import { ReactNode, createContext, useContext } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ const EditorPortalContext = createContext<HTMLElement | null>(null)
5
+
6
+ /** @internal */
7
+ export function EditorPortalProvider({
8
+ host,
9
+ children,
10
+ }: {
11
+ host: HTMLElement | null
12
+ children: ReactNode
13
+ }) {
14
+ return <EditorPortalContext.Provider value={host}>{children}</EditorPortalContext.Provider>
15
+ }
16
+
17
+ /**
18
+ * The DOM node that {@link EditorPortal} renders into: an empty `display: contents` element the
19
+ * editor renders last among its container's children, after the canvas and after the UI.
20
+ *
21
+ * Null until the container has mounted, so prefer {@link EditorPortal}, which handles that for you.
22
+ *
23
+ * @public
24
+ */
25
+ export function useEditorPortalHost(): HTMLElement | null {
26
+ return useContext(EditorPortalContext)
27
+ }
28
+
29
+ /** @public */
30
+ export interface EditorPortalProps {
31
+ children: ReactNode
32
+ }
33
+
34
+ /**
35
+ * Renders its children into the end of the editor's container, escaping the canvas layer.
36
+ *
37
+ * Use this for anything anchored to canvas coordinates that also has to draw over the UI — a
38
+ * popover on a shape, a comment thread, an annotation panel. The `OnTheCanvas` and
39
+ * `InFrontOfTheCanvas` slots are fixed layers, each its own stacking context, so content mounted
40
+ * there can't paint above whatever outranks its layer whatever z-index it asks for. Mount in
41
+ * whichever slot suits your data flow, wrap the part that needs its own layer in this, and give
42
+ * that part a z-index.
43
+ *
44
+ * This package's own layers stop at 250 (`--tl-layer-canvas-in-front`); above that belongs to
45
+ * whatever renders the UI. In `tldraw` that's `--tl-layer-panels` (300) and `--tl-layer-menus`
46
+ * (400), defined by `tldraw`'s stylesheet rather than this one.
47
+ *
48
+ * Positioning still resolves against the editor's container, so a `position: absolute` child uses
49
+ * ordinary container coordinates.
50
+ *
51
+ * Prefer this over `createPortal(children, useContainer())`, which picks its DOM position from the
52
+ * commit that mounts it — landing the node ahead of the UI and of the "skip to main content" link
53
+ * that only works while nothing precedes it. This host is a real element placed last, so ordering
54
+ * is fixed rather than dependent on mount timing.
55
+ *
56
+ * @example
57
+ * ```tsx
58
+ * // 400 clears tldraw's menus layer; the bare editor's own layers stop at 250.
59
+ * function ShapeAnnotation({ point }: { point: VecLike }) {
60
+ * return (
61
+ * <EditorPortal>
62
+ * <div style={{ position: 'absolute', left: point.x, top: point.y, zIndex: 400 }}>
63
+ * Above the UI
64
+ * </div>
65
+ * </EditorPortal>
66
+ * )
67
+ * }
68
+ * ```
69
+ *
70
+ * @public
71
+ * @react
72
+ */
73
+ export function EditorPortal({ children }: EditorPortalProps) {
74
+ const host = useEditorPortalHost()
75
+ if (!host) return null
76
+ return createPortal(children, host)
77
+ }
@@ -0,0 +1,133 @@
1
+ import { track, useQuickReactor, useValue } from '@tldraw/state-react'
2
+ import { TLInstancePresence } from '@tldraw/tlschema'
3
+ import { useLayoutEffect, useRef } from 'react'
4
+ import { useEditorComponents } from '../hooks/EditorComponentsContext'
5
+ import { useEditor } from '../hooks/useEditor'
6
+ import { useSharedSafeId } from '../hooks/useSafeId'
7
+ import { isCursorInViewport } from '../utils/collaborators'
8
+ import { setStyleProperty } from '../utils/dom'
9
+ import { getHtmlLayerTransform } from '../utils/getHtmlLayerTransform'
10
+
11
+ /**
12
+ * The collaborator cursor layer: a DOM layer stacked as a sibling of the canvas — above all canvas
13
+ * content, below the in-front layer and the UI panels — hosting each visible collaborator's cursor
14
+ * (arrow, name tag, chat message). Off-viewport collaborators are the canvas-drawn hint arrows' job
15
+ * (CollaboratorHintOverlayUtil), not this layer's.
16
+ *
17
+ * Cursors are DOM rather than canvas-drawn so their chrome styles and composes like the rest of
18
+ * the UI. Re-render traffic is kept narrow: per-cursor positioning writes `transform` directly
19
+ * (see `useTransform`), so a pointer move re-renders only the moved cursor; the camera transform
20
+ * is written imperatively below, so a pure pan re-renders only cursors whose viewport visibility
21
+ * flips (an equality-gated boolean per cursor); a zoom change re-renders every visible cursor,
22
+ * because each one rescales by `1/zoom`.
23
+ *
24
+ * @public @react
25
+ */
26
+ export const LiveCollaborators = track(function LiveCollaborators() {
27
+ const editor = useEditor()
28
+ const { CollaboratorCursor } = useEditorComponents()
29
+
30
+ // The inner layer carries the camera transform, so the cursor components position in page
31
+ // space exactly as canvas content does. Unlike the canvas's html layers this one unmounts
32
+ // while no collaborators are visible, so the transform is written at two moments: on every
33
+ // camera change (the reactor), and after every render (the layout effect, like useTransform
34
+ // does per cursor) — the reactor's last run hit a null ref while the layer was unmounted,
35
+ // and without the render-time write a reappearing layer would keep an identity transform
36
+ // until the next camera move.
37
+ const rHtmlLayer = useRef<HTMLDivElement | null>(null)
38
+ useLayoutEffect(() => {
39
+ const elm = rHtmlLayer.current
40
+ if (!elm) return
41
+ setStyleProperty(elm, 'transform', getHtmlLayerTransform(editor))
42
+ })
43
+ useQuickReactor(
44
+ 'position collaborators layer',
45
+ function positionCollaboratorsWhenCameraMoves() {
46
+ // Reads the camera even while the layer is unmounted, keeping the subscription alive.
47
+ const transform = getHtmlLayerTransform(editor)
48
+ setStyleProperty(rHtmlLayer.current, 'transform', transform)
49
+ },
50
+ [editor]
51
+ )
52
+
53
+ // Visibility (activity state, following, highlighting) is handled by the editor.
54
+ const collaborators = editor.getVisibleCollaboratorsOnCurrentPage()
55
+ if (collaborators.length === 0) return null
56
+ if (!CollaboratorCursor) return null
57
+
58
+ return (
59
+ <div className="tl-collaborators">
60
+ <svg className="tl-svg-context" aria-hidden="true">
61
+ <defs>
62
+ <CursorDef />
63
+ </defs>
64
+ </svg>
65
+ <div ref={rHtmlLayer} className="tl-html-layer">
66
+ {collaborators.map((presence) => (
67
+ <Collaborator key={presence.userId} latestPresence={presence} />
68
+ ))}
69
+ </div>
70
+ </div>
71
+ )
72
+ })
73
+
74
+ const Collaborator = track(function Collaborator({
75
+ latestPresence,
76
+ }: {
77
+ latestPresence: TLInstancePresence
78
+ }) {
79
+ const editor = useEditor()
80
+ const { CollaboratorCursor } = useEditorComponents()
81
+
82
+ const { userId, chatMessage, userName, cursor, color } = latestPresence
83
+
84
+ // The viewport read lives inside this equality-gated computed rather than the tracked render:
85
+ // pans move the viewport every frame, but this component only needs to know when the cursor
86
+ // crosses the edge, so it subscribes to the boolean and re-renders only when it flips.
87
+ const cursorInViewport = useValue(
88
+ 'cursor in viewport',
89
+ () =>
90
+ !!cursor && isCursorInViewport(cursor, editor.getViewportPageBounds(), editor.getZoomLevel()),
91
+ [editor, cursor]
92
+ )
93
+ // The zoom read is tracked directly: the cursor scales by 1/zoom, so zoom changes must
94
+ // re-render it. Pure pans leave the zoom value unchanged, so they never invalidate this.
95
+ const zoomLevel = editor.getZoomLevel()
96
+
97
+ if (!cursor) return null
98
+
99
+ // Off-viewport collaborators show as the canvas-drawn hint arrows
100
+ // (CollaboratorHintOverlayUtil), which shares this predicate — only the cursor itself is DOM.
101
+ if (!cursorInViewport) return null
102
+ if (!CollaboratorCursor) return null
103
+ return (
104
+ <CollaboratorCursor
105
+ className="tl-collaborator__cursor"
106
+ userId={userId}
107
+ point={cursor}
108
+ color={color}
109
+ zoom={zoomLevel}
110
+ name={userName !== 'New User' ? userName : null}
111
+ chatMessage={chatMessage ?? ''}
112
+ />
113
+ )
114
+ })
115
+
116
+ function CursorDef() {
117
+ return (
118
+ <g id={useSharedSafeId('cursor')}>
119
+ <g fill="rgba(0,0,0,.2)" transform="translate(-11,-11)">
120
+ <path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
121
+ <path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
122
+ </g>
123
+ <g fill="white" transform="translate(-12,-12)">
124
+ <path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
125
+ <path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
126
+ </g>
127
+ <g fill="currentColor" transform="translate(-12,-12)">
128
+ <path d="m19.751 24.4155-1.844.774-3.1-7.374 1.841-.775z" />
129
+ <path d="m13 10.814v11.188l2.969-2.866.428-.139h4.768z" />
130
+ </g>
131
+ </g>
132
+ )
133
+ }
@@ -1,7 +1,7 @@
1
1
  import { react } from '@tldraw/state'
2
2
  import { useQuickReactor, useValue } from '@tldraw/state-react'
3
3
  import { TLShapeId } from '@tldraw/tlschema'
4
- import { modulate, objectMapValues } from '@tldraw/utils'
4
+ import { objectMapValues } from '@tldraw/utils'
5
5
  import classNames from 'classnames'
6
6
  import { Fragment, JSX, useEffect, useRef, useState } from 'react'
7
7
  import { tlenv } from '../../globals/environment'
@@ -16,9 +16,10 @@ import { useGestureEvents } from '../../hooks/useGestureEvents'
16
16
  import { useScreenBounds } from '../../hooks/useScreenBounds'
17
17
  import { ShapeCullingProvider, useShapeCulling } from '../../hooks/useShapeCulling'
18
18
  import { Box } from '../../primitives/Box'
19
- import { toDomPrecision } from '../../primitives/utils'
20
19
  import { debugFlags } from '../../utils/debug-flags'
21
20
  import { setStyleProperty } from '../../utils/dom'
21
+ import { getHtmlLayerTransform } from '../../utils/getHtmlLayerTransform'
22
+ import { LiveCollaborators } from '../LiveCollaborators'
22
23
  import { MenuClickCapture } from '../MenuClickCapture'
23
24
  import { Shape } from '../Shape'
24
25
  import { CanvasOverlays } from './CanvasOverlays'
@@ -94,21 +95,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
94
95
  useQuickReactor(
95
96
  'position layers',
96
97
  function positionLayersWhenCameraMoves() {
97
- const { x, y, z } = editor.getCamera()
98
-
99
- // Because the html container has a width/height of 1px, we
100
- // need to create a small offset when zoomed to ensure that
101
- // the html container and svg container are lined up exactly.
102
- const offset =
103
- z >= 1 ? modulate(z, [1, 8], [0.125, 0.5], true) : modulate(z, [0.1, 1], [-2, 0.125], true)
104
-
105
- setStyleProperty(
106
- rHtmlLayer.current,
107
- 'transform',
108
- `scale(${toDomPrecision(z)}) translate(${toDomPrecision(
109
- x + offset
110
- )}px,${toDomPrecision(y + offset)}px)`
111
- )
98
+ setStyleProperty(rHtmlLayer.current, 'transform', getHtmlLayerTransform(editor))
112
99
  },
113
100
  [editor, container]
114
101
  )
@@ -166,6 +153,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
166
153
  <MovingCameraHitTestBlocker />
167
154
  </div>
168
155
  <InFrontOfTheCanvasWrapper />
156
+ <LiveCollaborators />
169
157
  <MenuClickCapture />
170
158
  </>
171
159
  )
@@ -0,0 +1,65 @@
1
+ import { VecModel } from '@tldraw/tlschema'
2
+ import classNames from 'classnames'
3
+ import { memo, useRef } from 'react'
4
+ import { useSharedSafeId } from '../../hooks/useSafeId'
5
+ import { useTransform } from '../../hooks/useTransform'
6
+
7
+ /** @public */
8
+ export interface TLCursorProps {
9
+ userId: string
10
+ className?: string
11
+ point: VecModel | null
12
+ zoom: number
13
+ color?: string
14
+ name: string | null
15
+ chatMessage: string
16
+ }
17
+
18
+ /**
19
+ * The default collaborator cursor: the arrow glyph plus a name tag or chat bubble. The arrow is a
20
+ * `<use>` of a shared `<defs>` symbol that `LiveCollaborators` renders, so a `DefaultCursor`
21
+ * used outside that layer draws no arrow.
22
+ *
23
+ * @public @react
24
+ */
25
+ export const DefaultCursor = memo(function DefaultCursor({
26
+ className,
27
+ zoom,
28
+ point,
29
+ color,
30
+ name,
31
+ chatMessage,
32
+ }: TLCursorProps) {
33
+ const rCursor = useRef<HTMLDivElement>(null)
34
+ useTransform(rCursor, point?.x, point?.y, 1 / zoom)
35
+
36
+ const cursorId = useSharedSafeId('cursor')
37
+
38
+ if (!point) return null
39
+
40
+ return (
41
+ <div ref={rCursor} className={classNames('tl-collaborators__item', className)}>
42
+ <svg className="tl-cursor" aria-hidden="true">
43
+ <use href={`#${cursorId}`} color={color} />
44
+ </svg>
45
+ {chatMessage ? (
46
+ <>
47
+ {name && (
48
+ <div className="tl-nametag-title" style={{ color }}>
49
+ {name}
50
+ </div>
51
+ )}
52
+ <div className="tl-nametag-chat" style={{ backgroundColor: color }}>
53
+ {chatMessage}
54
+ </div>
55
+ </>
56
+ ) : (
57
+ name && (
58
+ <div className="tl-nametag" style={{ backgroundColor: color }}>
59
+ {name}
60
+ </div>
61
+ )
62
+ )}
63
+ </div>
64
+ )
65
+ })
@@ -7145,7 +7145,11 @@ export class Editor extends EventEmitter<TLEventMap> {
7145
7145
 
7146
7146
  shape.index = index
7147
7147
  })
7148
- const shapesToCreate = shapesToCreateWithOriginals.map(({ shape }) => shape)
7148
+ const shapesToCreate = shapesToCreateWithOriginals.map(({ shape, originalShape }) => {
7149
+ // Give the shape util a chance to modify the duplicate, e.g. to re-stamp note
7150
+ // attribution to the current user so we don't forge the original author's identity.
7151
+ return this.getShapeUtil(shape).onBeforeDuplicate?.(originalShape, shape) ?? shape
7152
+ })
7149
7153
 
7150
7154
  if (!this.canCreateShapes(shapesToCreate)) {
7151
7155
  alertMaxShapes(this)
@@ -10039,7 +10043,15 @@ export class Editor extends EventEmitter<TLEventMap> {
10039
10043
  const newId = shapeIdMap.get(oldShape.id)!
10040
10044
 
10041
10045
  // Create the new shape (new except for the id)
10042
- const newShape = { ...oldShape, id: newId }
10046
+ let newShape = { ...oldShape, id: newId }
10047
+
10048
+ // Give the shape util a chance to modify the copy, e.g. to re-stamp note
10049
+ // attribution to the current user so we don't forge the original author's identity.
10050
+ // When ids are preserved the shape keeps its identity (e.g. moveShapesToPage
10051
+ // relocating it), so it's not a duplicate and the hook must not run.
10052
+ if (!preserveIds) {
10053
+ newShape = this.getShapeUtil(newShape).onBeforeDuplicate?.(oldShape, newShape) ?? newShape
10054
+ }
10043
10055
 
10044
10056
  if (rootShapeIds.includes(oldShape.id)) {
10045
10057
  newShape.parentId = currentPageId
@@ -33,7 +33,7 @@ function createPresence(userId: TLUserId): TLInstancePresence {
33
33
  }
34
34
 
35
35
  function createEditor(presences: TLInstancePresence[] = []) {
36
- const setInterval = vi.fn(() => 123)
36
+ const setInterval = vi.fn((_fn: () => void, _ms: number) => 123)
37
37
  const getInstanceState = vi.fn(() => ({
38
38
  followingUserId: null,
39
39
  highlightedUserIds: [],
@@ -67,6 +67,7 @@ function createEditor(presences: TLInstancePresence[] = []) {
67
67
  describe(CollaboratorsManager, () => {
68
68
  afterEach(() => {
69
69
  vi.clearAllMocks()
70
+ vi.useRealTimers()
70
71
  })
71
72
 
72
73
  it('starts the visibility clock on the first visible collaborators read', () => {
@@ -143,6 +144,50 @@ describe(CollaboratorsManager, () => {
143
144
  expect(manager.getVisibleCollaborators()).toHaveLength(1)
144
145
  })
145
146
 
147
+ it('keeps array identity when a presence update leaves the derived lists unchanged', () => {
148
+ const peerHere = createPresence(createUserId('peer-here'))
149
+ const peerElsewhere = createPresence(createUserId('peer-elsewhere'))
150
+ peerElsewhere.currentPageId = PageRecordType.createId('other-page')
151
+ const { editor } = createEditor([peerHere, peerElsewhere])
152
+ const manager = new CollaboratorsManager(editor)
153
+
154
+ const onPage = manager.getCollaboratorsOnCurrentPage()
155
+ const visibleOnPage = manager.getVisibleCollaboratorsOnCurrentPage()
156
+ expect(onPage).toEqual([peerHere])
157
+
158
+ // The off-page peer sends a presence update: the current-page lists are unaffected, so
159
+ // they keep the exact same array identity and don't invalidate downstream subscribers.
160
+ editor.store.put([{ ...peerElsewhere, chatMessage: 'hello from another page' }])
161
+ expect(manager.getCollaboratorsOnCurrentPage()).toBe(onPage)
162
+ expect(manager.getVisibleCollaboratorsOnCurrentPage()).toBe(visibleOnPage)
163
+ })
164
+
165
+ it('keeps array identity across a visibility-clock tick that changes nothing', () => {
166
+ vi.useFakeTimers()
167
+ const peer = createPresence(createUserId('peer'))
168
+ const { editor, setInterval, getInstanceState } = createEditor([peer])
169
+ const manager = new CollaboratorsManager(editor)
170
+
171
+ const visible = manager.getVisibleCollaborators()
172
+ const visibleOnPage = manager.getVisibleCollaboratorsOnCurrentPage()
173
+ expect(visible).toEqual([peer])
174
+ expect(getInstanceState).toHaveBeenCalledTimes(1)
175
+
176
+ // The clock ticks on a fixed interval whether or not anything changed. Advance far enough
177
+ // to move the clock atom, but not far enough to change anyone's activity state.
178
+ const tick = setInterval.mock.calls[0][0]
179
+ vi.advanceTimersByTime(1000)
180
+ tick()
181
+
182
+ // The tick really did invalidate the query — it re-evaluates and re-reads instance state —
183
+ // but the result is element-wise identical, so it keeps the previous array's identity. An
184
+ // idle room therefore doesn't re-render the cursor layer once per interval.
185
+ const revisited = manager.getVisibleCollaborators()
186
+ expect(getInstanceState).toHaveBeenCalledTimes(2)
187
+ expect(revisited).toBe(visible)
188
+ expect(manager.getVisibleCollaboratorsOnCurrentPage()).toBe(visibleOnPage)
189
+ })
190
+
146
191
  it('shows newly-joined collaborators that have not recorded any activity yet', () => {
147
192
  // A peer who has joined but not moved their pointer broadcasts the default
148
193
  // `lastActivityTimestamp` of 0. They should still be treated as active so
@@ -1,6 +1,6 @@
1
1
  import { EMPTY_ARRAY, atom, computed } from '@tldraw/state'
2
2
  import type { TLInstancePresence } from '@tldraw/tlschema'
3
- import { maxBy } from '@tldraw/utils'
3
+ import { areArraysShallowEqual, maxBy } from '@tldraw/utils'
4
4
  import type { Editor } from '../../Editor'
5
5
 
6
6
  /**
@@ -41,11 +41,16 @@ export class CollaboratorsManager {
41
41
  }))
42
42
  }
43
43
 
44
+ // These queries all derive fresh arrays with map/filter, so they compare results with
45
+ // shallow (element-identity) equality: a presence update that leaves a derived list's
46
+ // elements unchanged — e.g. a peer moving on another page — keeps the previous array's
47
+ // identity and doesn't invalidate downstream subscribers such as the cursor layer.
48
+
44
49
  /**
45
50
  * Returns a list of presence records for all peer collaborators.
46
51
  * This will return the latest presence record for each connected user.
47
52
  */
48
- @computed
53
+ @computed({ isEqual: areArraysShallowEqual })
49
54
  getCollaborators(): TLInstancePresence[] {
50
55
  const allPresenceRecords = this._getCollaboratorsQuery().get()
51
56
  if (!allPresenceRecords.length) return EMPTY_ARRAY
@@ -63,7 +68,7 @@ export class CollaboratorsManager {
63
68
  * Returns a list of presence records for all peer collaborators on the current page.
64
69
  * This will return the latest presence record for each connected user.
65
70
  */
66
- @computed
71
+ @computed({ isEqual: areArraysShallowEqual })
67
72
  getCollaboratorsOnCurrentPage(): TLInstancePresence[] {
68
73
  const currentPageId = this.editor.getCurrentPageId()
69
74
  return this.getCollaborators().filter((c) => c.currentPageId === currentPageId)
@@ -76,7 +81,7 @@ export class CollaboratorsManager {
76
81
  * highlighted users. Re-evaluates on the visibility clock, so callers don't need to
77
82
  * drive their own activity timer.
78
83
  */
79
- @computed
84
+ @computed({ isEqual: areArraysShallowEqual })
80
85
  getVisibleCollaborators(): TLInstancePresence[] {
81
86
  const { editor } = this
82
87
  const { collaboratorInactiveTimeoutMs, collaboratorIdleTimeoutMs } = editor.options
@@ -121,7 +126,7 @@ export class CollaboratorsManager {
121
126
  * Returns a list of presence records for peer collaborators who should currently be
122
127
  * shown in the UI, filtered to those on the current page.
123
128
  */
124
- @computed
129
+ @computed({ isEqual: areArraysShallowEqual })
125
130
  getVisibleCollaboratorsOnCurrentPage(): TLInstancePresence[] {
126
131
  const currentPageId = this.editor.getCurrentPageId()
127
132
  return this.getVisibleCollaborators().filter((c) => c.currentPageId === currentPageId)
@@ -669,6 +669,30 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
669
669
  */
670
670
  onBeforeCreate?(next: Shape): Shape | void
671
671
 
672
+ /**
673
+ * A callback called when a shape is reproduced from an existing shape, either by duplicating
674
+ * ({@link Editor.duplicateShapes}) or by pasting/putting content onto the page
675
+ * ({@link Editor.putContentOntoCurrentPage}). This provides a last chance to modify the copy
676
+ * before it's created — for example, to re-stamp attribution so the copy is credited to the
677
+ * current user rather than the original author. It is not called when content is put with
678
+ * `preserveIds` (e.g. {@link Editor.moveShapesToPage}), since the shape keeps its identity
679
+ * and no copy is made.
680
+ *
681
+ * @example
682
+ *
683
+ * ```ts
684
+ * onBeforeDuplicate = (source, duplicate) => {
685
+ * return { ...duplicate, props: { ...duplicate.props, editedBy: this.editor.getAttributionUserId() } }
686
+ * }
687
+ * ```
688
+ *
689
+ * @param source - The shape being copied from.
690
+ * @param duplicate - The new copy (with its own id), before it's created.
691
+ * @returns The next shape or void.
692
+ * @public
693
+ */
694
+ onBeforeDuplicate?(source: Shape, duplicate: Shape): Shape | void
695
+
672
696
  /**
673
697
  * A callback called just before a shape is updated. This method provides a last chance to modify
674
698
  * the updated shape.
@@ -37,6 +37,11 @@ export type TLExternalContentSource =
37
37
 
38
38
  /** @public */
39
39
  export interface TLBaseExternalContent {
40
+ /**
41
+ * The other content sources found on the clipboard alongside this content. For example, when
42
+ * pasting an image copied together with HTML and plain text, the `files` content will have
43
+ * text sources for the HTML and plain text parts.
44
+ */
40
45
  sources?: TLExternalContentSource[]
41
46
  point?: VecLike
42
47
  }
@@ -1,5 +1,6 @@
1
1
  import { ComponentType, RefAttributes, createContext, useContext } from 'react'
2
2
  import type { TLCanvasComponentProps } from '../components/default-components/DefaultCanvas'
3
+ import type { TLCursorProps } from '../components/default-components/DefaultCursor'
3
4
  import type { TLErrorFallbackComponent } from '../components/default-components/DefaultErrorFallback'
4
5
  import type { TLGridProps } from '../components/default-components/DefaultGrid'
5
6
  import type { TLShapeErrorFallbackComponent } from '../components/default-components/DefaultShapeErrorFallback'
@@ -9,6 +10,7 @@ import type { TLShapeWrapperProps } from '../components/default-components/Defau
9
10
  export interface TLEditorComponents {
10
11
  Background?: ComponentType | null
11
12
  Canvas?: ComponentType<TLCanvasComponentProps> | null
13
+ CollaboratorCursor?: ComponentType<TLCursorProps> | null
12
14
  Grid?: ComponentType<TLGridProps> | null
13
15
  InFrontOfTheCanvas?: ComponentType | null
14
16
  LoadingScreen?: ComponentType | null
@@ -36,6 +36,7 @@ const STATIC_CURSORS = [
36
36
  'default',
37
37
  'pointer',
38
38
  'cross',
39
+ 'comment',
39
40
  'move',
40
41
  'grab',
41
42
  'grabbing',
@@ -240,19 +240,20 @@ export function useDocumentEvents() {
240
240
 
241
241
  function handleTouchStart(e: TouchEvent) {
242
242
  if (container.contains(e.target as Node)) {
243
- // Center point of the touch area
244
- const touchXPosition = e.touches[0].pageX
243
+ // Center point of the touch area, measured from the edge of the window the touch
244
+ // has to reach to trigger the navigation
245
+ const touchXPosition = e.touches[0].clientX
245
246
  // Size of the touch area
246
247
  const touchXRadius = e.touches[0].radiusX || 0
247
248
 
248
- // We set a threshold (10px) on both sizes of the screen,
249
- // if the touch area overlaps with the screen edges
250
- // it's likely to trigger the navigation. We prevent the
251
- // touchstart event in that case.
252
- // todo: make this relative to the actual window, not the editor's screen bounds
249
+ // If the touch area overlaps with the screen edges it's likely to trigger the
250
+ // navigation. We prevent the touchstart event in that case. The gesture belongs to
251
+ // the window, so an editor inset from the window's edges — beside a sidebar, say —
252
+ // is not near an edge the system reacts to.
253
+ const windowWidth = editor.getContainerWindow().innerWidth
253
254
  if (
254
255
  touchXPosition - touchXRadius < 10 ||
255
- touchXPosition + touchXRadius > editor.getViewportScreenBounds().width - 10
256
+ touchXPosition + touchXRadius > windowWidth - 10
256
257
  ) {
257
258
  if ((e.target as HTMLElement)?.tagName === 'BUTTON') {
258
259
  // Force a click before bailing
@@ -1,6 +1,7 @@
1
1
  import { ReactNode, useMemo } from 'react'
2
2
  import { DefaultBackground } from '../components/default-components/DefaultBackground'
3
3
  import { DefaultCanvas } from '../components/default-components/DefaultCanvas'
4
+ import { DefaultCursor } from '../components/default-components/DefaultCursor'
4
5
  import { DefaultErrorFallback } from '../components/default-components/DefaultErrorFallback'
5
6
  import { DefaultGrid } from '../components/default-components/DefaultGrid'
6
7
  import { DefaultLoadingScreen } from '../components/default-components/DefaultLoadingScreen'
@@ -29,6 +30,7 @@ export function EditorComponentsProvider({
29
30
  (): Required<TLEditorComponents> => ({
30
31
  Background: DefaultBackground,
31
32
  Canvas: DefaultCanvas,
33
+ CollaboratorCursor: DefaultCursor,
32
34
  Grid: DefaultGrid,
33
35
  InFrontOfTheCanvas: null,
34
36
  LoadingScreen: DefaultLoadingScreen,