@tldraw/editor 5.3.0-next.75e2874c4b3f → 5.3.0-next.7654e7ac2a02

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 (98) hide show
  1. package/dist-cjs/index.d.ts +115 -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/managers/CollaboratorsManager/CollaboratorsManager.js +4 -4
  15. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  16. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  17. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  18. package/dist-cjs/lib/hooks/useCursor.js +1 -0
  19. package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
  20. package/dist-cjs/lib/hooks/useEditorComponents.js +2 -0
  21. package/dist-cjs/lib/hooks/useEditorComponents.js.map +2 -2
  22. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +25 -1
  23. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  24. package/dist-cjs/lib/hooks/useTransform.js +45 -0
  25. package/dist-cjs/lib/hooks/useTransform.js.map +7 -0
  26. package/dist-cjs/lib/license/LicenseManager.js +51 -2
  27. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  28. package/dist-cjs/lib/license/useLicenseManagerState.js +7 -0
  29. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  30. package/dist-cjs/lib/utils/collaborators.js +27 -0
  31. package/dist-cjs/lib/utils/collaborators.js.map +7 -0
  32. package/dist-cjs/lib/utils/getHtmlLayerTransform.js +33 -0
  33. package/dist-cjs/lib/utils/getHtmlLayerTransform.js.map +7 -0
  34. package/dist-cjs/version.js +3 -3
  35. package/dist-cjs/version.js.map +1 -1
  36. package/dist-esm/index.d.mts +115 -3
  37. package/dist-esm/index.mjs +19 -4
  38. package/dist-esm/index.mjs.map +2 -2
  39. package/dist-esm/lib/TldrawEditor.mjs +23 -18
  40. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  41. package/dist-esm/lib/components/EditorPortal.mjs +24 -0
  42. package/dist-esm/lib/components/EditorPortal.mjs.map +7 -0
  43. package/dist-esm/lib/components/LiveCollaborators.mjs +82 -0
  44. package/dist-esm/lib/components/LiveCollaborators.mjs.map +7 -0
  45. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -11
  46. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  47. package/dist-esm/lib/components/default-components/DefaultCursor.mjs +29 -0
  48. package/dist-esm/lib/components/default-components/DefaultCursor.mjs.map +7 -0
  49. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +5 -5
  50. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  51. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  52. package/dist-esm/lib/hooks/useCursor.mjs +1 -0
  53. package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
  54. package/dist-esm/lib/hooks/useEditorComponents.mjs +2 -0
  55. package/dist-esm/lib/hooks/useEditorComponents.mjs.map +2 -2
  56. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +25 -1
  57. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  58. package/dist-esm/lib/hooks/useTransform.mjs +25 -0
  59. package/dist-esm/lib/hooks/useTransform.mjs.map +7 -0
  60. package/dist-esm/lib/license/LicenseManager.mjs +52 -3
  61. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  62. package/dist-esm/lib/license/useLicenseManagerState.mjs +7 -0
  63. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  64. package/dist-esm/lib/utils/collaborators.mjs +7 -0
  65. package/dist-esm/lib/utils/collaborators.mjs.map +7 -0
  66. package/dist-esm/lib/utils/getHtmlLayerTransform.mjs +13 -0
  67. package/dist-esm/lib/utils/getHtmlLayerTransform.mjs.map +7 -0
  68. package/dist-esm/version.mjs +3 -3
  69. package/dist-esm/version.mjs.map +1 -1
  70. package/editor.css +43 -4
  71. package/package.json +7 -7
  72. package/src/index.ts +14 -2
  73. package/src/lib/TldrawEditor.tsx +21 -13
  74. package/src/lib/components/EditorPortal.tsx +77 -0
  75. package/src/lib/components/LiveCollaborators.tsx +133 -0
  76. package/src/lib/components/default-components/DefaultCanvas.tsx +5 -17
  77. package/src/lib/components/default-components/DefaultCursor.tsx +65 -0
  78. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +46 -1
  79. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +10 -5
  80. package/src/lib/editor/types/external-content.ts +5 -0
  81. package/src/lib/hooks/EditorComponentsContext.tsx +2 -0
  82. package/src/lib/hooks/useCursor.ts +1 -0
  83. package/src/lib/hooks/useEditorComponents.tsx +2 -0
  84. package/src/lib/hooks/usePassThroughWheelEvents.ts +44 -2
  85. package/src/lib/hooks/useTransform.ts +38 -0
  86. package/src/lib/license/LicenseManager.test.ts +146 -0
  87. package/src/lib/license/LicenseManager.ts +98 -2
  88. package/src/lib/license/useLicenseManagerState.ts +17 -1
  89. package/src/lib/test/EditorPortal.test.tsx +48 -0
  90. package/src/lib/utils/collaborators.test.ts +34 -0
  91. package/src/lib/utils/collaborators.ts +25 -0
  92. package/src/lib/utils/getHtmlLayerTransform.ts +22 -0
  93. package/src/version.ts +3 -3
  94. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js +0 -51
  95. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js.map +0 -7
  96. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs +0 -31
  97. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs.map +0 -7
  98. package/src/lib/hooks/usePassThroughMouseOverEvents.ts +0 -32
package/editor.css CHANGED
@@ -29,15 +29,20 @@
29
29
  * background, grid, shapes, overlays, and blocker are all children of .tl-canvas,
30
30
  * so their z-indices are relative to the canvas, not the container.
31
31
  *
32
- * watermark and canvas-in-front are siblings of .tl-canvas (outside it),
33
- * so they compete with the UI layer (.tlui-layout at z-index 300).
32
+ * collaborators, watermark, and canvas-in-front are siblings of .tl-canvas
33
+ * (outside it), so they compete with the UI layer (.tlui-layout at z-index 300).
34
34
  * canvas-in-front must be below the UI layer (300) to sit between
35
- * the canvas and the UI.
35
+ * the canvas and the UI; collaborator cursors sit above canvas content
36
+ * (and layers that anchor to it, like comment pins) but below the
37
+ * in-front content and the UI panels. The watermark (a licensing element)
38
+ * sits above that content so it can't be occluded, but still below the
39
+ * in-front content and the UI.
36
40
  */
37
41
  --tl-layer-canvas-hidden: -999999;
38
42
  --tl-layer-canvas-background: 100;
39
43
  --tl-layer-canvas-grid: 150;
40
- --tl-layer-watermark: 200;
44
+ --tl-layer-collaborators: 245;
45
+ --tl-layer-watermark: 248;
41
46
  --tl-layer-canvas-in-front: 250;
42
47
  --tl-layer-canvas-shapes: 300;
43
48
  --tl-layer-canvas-overlays: 500;
@@ -86,6 +91,10 @@
86
91
  url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: black;'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='1' dy='1' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(0 16 16)' filter='url(%23shadow)'><path d='m25 16h-6.01v-6h-2.98v6h-6.01v3h6.01v6h2.98v-6h6.01z' fill='white'/><path d='m23.9902 17.0103h-6v-6.01h-.98v6.01h-6v.98h6v6.01h.98v-6.01h6z' fill='%23231f1f'/></g></svg>")
87
92
  16 16,
88
93
  crosshair;
94
+ --tl-cursor-comment:
95
+ url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='1' dy='1' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g filter='url(%23shadow)'><path d='M5 19.5 L5 11 A8 8 0 0 1 13 3 A8 8 0 0 1 21 11 A8 8 0 0 1 13 19 Z' fill='white' stroke='black' stroke-width='1' stroke-linejoin='round'/></g></svg>")
96
+ 5 19,
97
+ crosshair;
89
98
  --tl-cursor-move:
90
99
  url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: black;'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='1' dy='1' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(0 16 16)' filter='url(%23shadow)'><path d='m19 14h1v1h-1zm1 6h-1v-1h1zm-5-5h-1v-1h1zm0 5h-1v-1h1zm2-10.987-7.985 7.988 5.222 5.221 2.763 2.763 7.984-7.985z' fill='white'/><g fill='black'><path d='m23.5664 16.9971-2.557-2.809v1.829h-4.009-4.001v-1.829l-2.571 2.809 2.572 2.808-.001-1.808h4.001 4.009l-.001 1.808z'/><path d='m17.9873 17h.013v-4.001l1.807.001-2.807-2.571-2.809 2.57h1.809v4.001h.008v4.002l-1.828-.001 2.807 2.577 2.805-2.576h-1.805z'/></g></g></svg>")
91
100
  16 16,
@@ -286,6 +295,12 @@
286
295
  outline: 1px solid var(--tl-color-low);
287
296
  }
288
297
 
298
+ /* The host for <EditorPortal>, last among the container's children. It's a position in the DOM
299
+ rather than a box: `display: contents` keeps what it holds positioned against the container. */
300
+ .tl-portal-host {
301
+ display: contents;
302
+ }
303
+
289
304
  input,
290
305
  *[contenteditable],
291
306
  *[contenteditable] * {
@@ -372,6 +387,30 @@ input,
372
387
  contain: layout style size;
373
388
  }
374
389
 
390
+ /* The collaborator cursor layer: a sibling of .tl-canvas, above all canvas content, below the
391
+ in-front layer (250) and the UI panels (300). Display-only — pointer events pass through. */
392
+ .tl-collaborators {
393
+ position: absolute;
394
+ inset: 0px;
395
+ z-index: var(--tl-layer-collaborators);
396
+ pointer-events: none;
397
+ overflow: clip;
398
+ contain: layout style size;
399
+ }
400
+
401
+ .tl-collaborators__item {
402
+ position: absolute;
403
+ top: 0px;
404
+ left: 0px;
405
+ overflow: visible;
406
+ pointer-events: none;
407
+ transform-origin: top left;
408
+ }
409
+
410
+ .tl-cursor {
411
+ overflow: visible;
412
+ }
413
+
375
414
  /* --------------- Overlay Stack --------------- */
376
415
 
377
416
  /* -------------- Selection foreground -------------- */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/editor",
3
3
  "description": "tldraw infinite canvas SDK (editor).",
4
- "version": "5.3.0-next.75e2874c4b3f",
4
+ "version": "5.3.0-next.7654e7ac2a02",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -49,12 +49,12 @@
49
49
  "@tiptap/core": "^3.12.1",
50
50
  "@tiptap/pm": "^3.12.1",
51
51
  "@tiptap/react": "^3.12.1",
52
- "@tldraw/state": "5.3.0-next.75e2874c4b3f",
53
- "@tldraw/state-react": "5.3.0-next.75e2874c4b3f",
54
- "@tldraw/store": "5.3.0-next.75e2874c4b3f",
55
- "@tldraw/tlschema": "5.3.0-next.75e2874c4b3f",
56
- "@tldraw/utils": "5.3.0-next.75e2874c4b3f",
57
- "@tldraw/validate": "5.3.0-next.75e2874c4b3f",
52
+ "@tldraw/state": "5.3.0-next.7654e7ac2a02",
53
+ "@tldraw/state-react": "5.3.0-next.7654e7ac2a02",
54
+ "@tldraw/store": "5.3.0-next.7654e7ac2a02",
55
+ "@tldraw/tlschema": "5.3.0-next.7654e7ac2a02",
56
+ "@tldraw/utils": "5.3.0-next.7654e7ac2a02",
57
+ "@tldraw/validate": "5.3.0-next.7654e7ac2a02",
58
58
  "classnames": "^2.5.1",
59
59
  "eventemitter3": "^4.0.7",
60
60
  "idb": "^7.1.1",
package/src/index.ts CHANGED
@@ -18,6 +18,10 @@ export {
18
18
  DefaultCanvas,
19
19
  type TLCanvasComponentProps,
20
20
  } from './lib/components/default-components/DefaultCanvas'
21
+ export {
22
+ DefaultCursor,
23
+ type TLCursorProps,
24
+ } from './lib/components/default-components/DefaultCursor'
21
25
  export {
22
26
  DefaultErrorFallback,
23
27
  type TLErrorFallbackComponent,
@@ -30,6 +34,11 @@ export {
30
34
  } from './lib/components/default-components/DefaultShapeWrapper'
31
35
  export { DefaultSpinner } from './lib/components/default-components/DefaultSpinner'
32
36
  export { DefaultSvgDefs } from './lib/components/default-components/DefaultSvgDefs'
37
+ export {
38
+ EditorPortal,
39
+ useEditorPortalHost,
40
+ type EditorPortalProps,
41
+ } from './lib/components/EditorPortal'
33
42
  export {
34
43
  ErrorBoundary,
35
44
  OptionalErrorBoundary,
@@ -306,7 +315,6 @@ export { useShallowArrayIdentity, useShallowObjectIdentity } from './lib/hooks/u
306
315
  export { useIsCropping } from './lib/hooks/useIsCropping'
307
316
  export { useIsEditing } from './lib/hooks/useIsEditing'
308
317
  export { useLocalStore } from './lib/hooks/useLocalStore'
309
- export { usePassThroughMouseOverEvents } from './lib/hooks/usePassThroughMouseOverEvents'
310
318
  export { usePassThroughWheelEvents } from './lib/hooks/usePassThroughWheelEvents'
311
319
  export { usePeerIds } from './lib/hooks/usePeerIds'
312
320
  export { usePresence } from './lib/hooks/usePresence'
@@ -318,18 +326,22 @@ export {
318
326
  useUniqueSafeId,
319
327
  type SafeId,
320
328
  } from './lib/hooks/useSafeId'
329
+ export { useTransform } from './lib/hooks/useTransform'
330
+ export { isCursorInViewport } from './lib/utils/collaborators'
321
331
  export { useTLSchemaFromUtils, useTLStore } from './lib/hooks/useTLStore'
322
332
  export { useViewportHeight } from './lib/hooks/useViewportHeight'
323
333
  export {
324
334
  LicenseManager,
325
335
  type InvalidLicenseKeyResult,
326
336
  type InvalidLicenseReason,
337
+ type LicenseFeatureName,
327
338
  type LicenseFromKeyResult,
328
339
  type LicenseInfo,
329
340
  type LicenseState,
330
341
  type ValidLicenseKeyResult,
331
342
  } from './lib/license/LicenseManager'
332
- export { LICENSE_TIMEOUT } from './lib/license/LicenseProvider'
343
+ export { LICENSE_TIMEOUT, useLicenseContext } from './lib/license/LicenseProvider'
344
+ export { useLicenseFeatureFlag } from './lib/license/useLicenseManagerState'
333
345
  export {
334
346
  defaultTldrawOptions,
335
347
  type TLClipboardPasteRawInfo,
@@ -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
+ })
@@ -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)