@tldraw/editor 5.3.0-internal.fba91ed55f6c → 5.3.0-next.0362a8b4a246

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 (73) hide show
  1. package/dist-cjs/index.d.ts +75 -0
  2. package/dist-cjs/index.js +7 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/components/LiveCollaborators.js +98 -0
  5. package/dist-cjs/lib/components/LiveCollaborators.js.map +7 -0
  6. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +4 -10
  7. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +3 -3
  8. package/dist-cjs/lib/components/default-components/DefaultCursor.js +59 -0
  9. package/dist-cjs/lib/components/default-components/DefaultCursor.js.map +7 -0
  10. package/dist-cjs/lib/editor/Editor.js +7 -2
  11. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  12. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  13. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  14. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  15. package/dist-cjs/lib/hooks/useCursor.js +1 -0
  16. package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
  17. package/dist-cjs/lib/hooks/useEditorComponents.js +2 -0
  18. package/dist-cjs/lib/hooks/useEditorComponents.js.map +2 -2
  19. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +25 -1
  20. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  21. package/dist-cjs/lib/hooks/useTransform.js +45 -0
  22. package/dist-cjs/lib/hooks/useTransform.js.map +7 -0
  23. package/dist-cjs/lib/utils/collaborators.js +27 -0
  24. package/dist-cjs/lib/utils/collaborators.js.map +7 -0
  25. package/dist-cjs/lib/utils/getHtmlLayerTransform.js +33 -0
  26. package/dist-cjs/lib/utils/getHtmlLayerTransform.js.map +7 -0
  27. package/dist-cjs/version.js +3 -3
  28. package/dist-cjs/version.js.map +1 -1
  29. package/dist-esm/index.d.mts +75 -0
  30. package/dist-esm/index.mjs +9 -1
  31. package/dist-esm/index.mjs.map +2 -2
  32. package/dist-esm/lib/components/LiveCollaborators.mjs +78 -0
  33. package/dist-esm/lib/components/LiveCollaborators.mjs.map +7 -0
  34. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -11
  35. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  36. package/dist-esm/lib/components/default-components/DefaultCursor.mjs +29 -0
  37. package/dist-esm/lib/components/default-components/DefaultCursor.mjs.map +7 -0
  38. package/dist-esm/lib/editor/Editor.mjs +7 -2
  39. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  40. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  41. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  42. package/dist-esm/lib/hooks/useCursor.mjs +1 -0
  43. package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
  44. package/dist-esm/lib/hooks/useEditorComponents.mjs +2 -0
  45. package/dist-esm/lib/hooks/useEditorComponents.mjs.map +2 -2
  46. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +25 -1
  47. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  48. package/dist-esm/lib/hooks/useTransform.mjs +25 -0
  49. package/dist-esm/lib/hooks/useTransform.mjs.map +7 -0
  50. package/dist-esm/lib/utils/collaborators.mjs +7 -0
  51. package/dist-esm/lib/utils/collaborators.mjs.map +7 -0
  52. package/dist-esm/lib/utils/getHtmlLayerTransform.mjs +13 -0
  53. package/dist-esm/lib/utils/getHtmlLayerTransform.mjs.map +7 -0
  54. package/dist-esm/version.mjs +3 -3
  55. package/dist-esm/version.mjs.map +1 -1
  56. package/editor.css +37 -4
  57. package/package.json +7 -7
  58. package/src/index.ts +6 -0
  59. package/src/lib/components/LiveCollaborators.tsx +119 -0
  60. package/src/lib/components/default-components/DefaultCanvas.tsx +5 -17
  61. package/src/lib/components/default-components/DefaultCursor.tsx +65 -0
  62. package/src/lib/editor/Editor.ts +14 -2
  63. package/src/lib/editor/shapes/ShapeUtil.ts +24 -0
  64. package/src/lib/editor/types/external-content.ts +5 -0
  65. package/src/lib/hooks/EditorComponentsContext.tsx +2 -0
  66. package/src/lib/hooks/useCursor.ts +1 -0
  67. package/src/lib/hooks/useEditorComponents.tsx +2 -0
  68. package/src/lib/hooks/usePassThroughWheelEvents.ts +44 -2
  69. package/src/lib/hooks/useTransform.ts +38 -0
  70. package/src/lib/utils/collaborators.test.ts +34 -0
  71. package/src/lib/utils/collaborators.ts +25 -0
  72. package/src/lib/utils/getHtmlLayerTransform.ts +22 -0
  73. package/src/version.ts +3 -3
@@ -0,0 +1,119 @@
1
+ import { track, useQuickReactor } 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; per-cursor positioning writes `transform` directly (see `useTransform`), so pointer- and
19
+ * camera-frequency updates never re-render more than the moved cursor.
20
+ *
21
+ * @public @react
22
+ */
23
+ export const LiveCollaborators = track(function LiveCollaborators() {
24
+ const editor = useEditor()
25
+ const { CollaboratorCursor } = useEditorComponents()
26
+
27
+ // The inner layer carries the camera transform, so the cursor components position in page
28
+ // space exactly as canvas content does. Unlike the canvas's html layers this one unmounts
29
+ // while no collaborators are visible, so the transform is written at two moments: on every
30
+ // camera change (the reactor), and after every render (the layout effect, like useTransform
31
+ // does per cursor) — the reactor's last run hit a null ref while the layer was unmounted,
32
+ // and without the render-time write a reappearing layer would keep an identity transform
33
+ // until the next camera move.
34
+ const rHtmlLayer = useRef<HTMLDivElement | null>(null)
35
+ useLayoutEffect(() => {
36
+ const elm = rHtmlLayer.current
37
+ if (!elm) return
38
+ setStyleProperty(elm, 'transform', getHtmlLayerTransform(editor))
39
+ })
40
+ useQuickReactor(
41
+ 'position collaborators layer',
42
+ function positionCollaboratorsWhenCameraMoves() {
43
+ // Reads the camera even while the layer is unmounted, keeping the subscription alive.
44
+ const transform = getHtmlLayerTransform(editor)
45
+ setStyleProperty(rHtmlLayer.current, 'transform', transform)
46
+ },
47
+ [editor]
48
+ )
49
+
50
+ // Visibility (activity state, following, highlighting) is handled by the editor.
51
+ const collaborators = editor.getVisibleCollaboratorsOnCurrentPage()
52
+ if (collaborators.length === 0) return null
53
+ if (!CollaboratorCursor) return null
54
+
55
+ return (
56
+ <div className="tl-collaborators">
57
+ <svg className="tl-svg-context" aria-hidden="true">
58
+ <defs>
59
+ <CursorDef />
60
+ </defs>
61
+ </svg>
62
+ <div ref={rHtmlLayer} className="tl-html-layer">
63
+ {collaborators.map((presence) => (
64
+ <Collaborator key={presence.userId} latestPresence={presence} />
65
+ ))}
66
+ </div>
67
+ </div>
68
+ )
69
+ })
70
+
71
+ const Collaborator = track(function Collaborator({
72
+ latestPresence,
73
+ }: {
74
+ latestPresence: TLInstancePresence
75
+ }) {
76
+ const editor = useEditor()
77
+ const { CollaboratorCursor } = useEditorComponents()
78
+
79
+ const zoomLevel = editor.getZoomLevel()
80
+ const viewportPageBounds = editor.getViewportPageBounds()
81
+ const { userId, chatMessage, userName, cursor, color } = latestPresence
82
+
83
+ if (!cursor) return null
84
+
85
+ // Off-viewport collaborators show as the canvas-drawn hint arrows
86
+ // (CollaboratorHintOverlayUtil), which shares this predicate — only the cursor itself is DOM.
87
+ if (!isCursorInViewport(cursor, viewportPageBounds, zoomLevel)) return null
88
+ if (!CollaboratorCursor) return null
89
+ return (
90
+ <CollaboratorCursor
91
+ className="tl-collaborator__cursor"
92
+ userId={userId}
93
+ point={cursor}
94
+ color={color}
95
+ zoom={zoomLevel}
96
+ name={userName !== 'New User' ? userName : null}
97
+ chatMessage={chatMessage ?? ''}
98
+ />
99
+ )
100
+ })
101
+
102
+ function CursorDef() {
103
+ return (
104
+ <g id={useSharedSafeId('cursor')}>
105
+ <g fill="rgba(0,0,0,.2)" transform="translate(-11,-11)">
106
+ <path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
107
+ <path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
108
+ </g>
109
+ <g fill="white" transform="translate(-12,-12)">
110
+ <path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
111
+ <path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
112
+ </g>
113
+ <g fill="currentColor" transform="translate(-12,-12)">
114
+ <path d="m19.751 24.4155-1.844.774-3.1-7.374 1.841-.775z" />
115
+ <path d="m13 10.814v11.188l2.969-2.866.428-.139h4.768z" />
116
+ </g>
117
+ </g>
118
+ )
119
+ }
@@ -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
@@ -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',
@@ -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,
@@ -3,6 +3,48 @@ import { preventDefault } from '../utils/dom'
3
3
  import { useContainer } from './useContainer'
4
4
  import { useMaybeEditor } from './useEditor'
5
5
 
6
+ function isScrollableOverflow(overflow: string) {
7
+ return overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay'
8
+ }
9
+
10
+ // An element only consumes a wheel event when it's a real scroll container: its content overflows
11
+ // AND its computed overflow is scrollable. Checking scrollHeight/scrollWidth alone is not enough —
12
+ // an `overflow: visible` element whose content merely overflows (e.g. a comment pin whose hover
13
+ // `transform: scale()` inflates scrollHeight past clientHeight) never scrolls, so the wheel must
14
+ // still pass through to the canvas.
15
+ function isScrollable(elm: HTMLElement) {
16
+ // Cheap first: if nothing overflows, it can't scroll — skip the getComputedStyle read. This runs
17
+ // per ancestor on every wheel event, so avoid resolving styles for the common (non-overflowing) case.
18
+ const overflowsY = elm.scrollHeight > elm.clientHeight
19
+ const overflowsX = elm.scrollWidth > elm.clientWidth
20
+ if (!overflowsY && !overflowsX) return false
21
+
22
+ const style = getComputedStyle(elm)
23
+ return (
24
+ (overflowsY && isScrollableOverflow(style.overflowY)) ||
25
+ (overflowsX && isScrollableOverflow(style.overflowX))
26
+ )
27
+ }
28
+
29
+ // Walk from the wheeled element up to (and including) the pass-through root, looking for a scroll
30
+ // container. If one is found, the wheel belongs to it — don't redispatch it to the canvas.
31
+ function hasScrollableElement(target: EventTarget | null, root: HTMLElement) {
32
+ if (!(target instanceof Node)) {
33
+ return isScrollable(root)
34
+ }
35
+
36
+ let elm: Element | null = target instanceof Element ? target : target.parentElement
37
+ while (elm) {
38
+ if (elm instanceof HTMLElement && isScrollable(elm)) {
39
+ return true
40
+ }
41
+ if (elm === root) return false
42
+ elm = elm.parentElement
43
+ }
44
+
45
+ return false
46
+ }
47
+
6
48
  /** @public */
7
49
  export function usePassThroughWheelEvents(ref: RefObject<HTMLElement | null>) {
8
50
  if (!ref) throw Error('usePassThroughWheelEvents must be passed a ref')
@@ -16,9 +58,9 @@ export function usePassThroughWheelEvents(ref: RefObject<HTMLElement | null>) {
16
58
 
17
59
  if ((e as any).isSpecialRedispatchedEvent) return
18
60
 
19
- // if the element is scrollable, don't redispatch the event
61
+ // If the wheel is over a scrollable element, let it scroll instead of redispatching.
20
62
  const elm = ref.current
21
- if (elm && elm.scrollHeight > elm.clientHeight) {
63
+ if (elm && hasScrollableElement(e.target, elm)) {
22
64
  return
23
65
  }
24
66
 
@@ -0,0 +1,38 @@
1
+ import { useLayoutEffect } from 'react'
2
+ import { VecLike } from '../primitives/Vec'
3
+
4
+ /**
5
+ * Position an element by writing its `transform` directly, outside React's render output — the
6
+ * cheap path for elements that move at pointer/presence frequency.
7
+ * @public
8
+ */
9
+ export function useTransform(
10
+ ref: React.RefObject<HTMLElement | SVGElement | null>,
11
+ x?: number,
12
+ y?: number,
13
+ scale?: number,
14
+ rotate?: number,
15
+ additionalOffset?: VecLike
16
+ ) {
17
+ // Read the offset into primitives so the effect can depend on them, not the object identity.
18
+ const additionalOffsetX = additionalOffset?.x
19
+ const additionalOffsetY = additionalOffset?.y
20
+
21
+ useLayoutEffect(() => {
22
+ const elm = ref.current
23
+ if (!elm) return
24
+ if (x === undefined) return
25
+
26
+ let trans = `translate(${x}px, ${y}px)`
27
+ if (scale !== undefined) {
28
+ trans += ` scale(${scale})`
29
+ }
30
+ if (rotate !== undefined) {
31
+ trans += ` rotate(${rotate}rad)`
32
+ }
33
+ if (additionalOffsetX !== undefined && additionalOffsetY !== undefined) {
34
+ trans += ` translate(${additionalOffsetX}px, ${additionalOffsetY}px)`
35
+ }
36
+ elm.style.transform = trans
37
+ }, [additionalOffsetX, additionalOffsetY, ref, rotate, scale, x, y])
38
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isCursorInViewport } from './collaborators'
3
+
4
+ // The viewport (page coords) all cases test against; zoom 1 so the 12/16px margins map 1:1.
5
+ const viewport = { minX: 0, minY: 0, maxX: 100, maxY: 100 }
6
+
7
+ describe('isCursorInViewport', () => {
8
+ it('is true for a cursor well inside the viewport', () => {
9
+ expect(isCursorInViewport({ x: 50, y: 50 }, viewport, 1)).toBe(true)
10
+ })
11
+
12
+ it('keeps a cursor just past the left/top edges (12/16px margin)', () => {
13
+ expect(isCursorInViewport({ x: -11, y: 50 }, viewport, 1)).toBe(true) // within 12 of left
14
+ expect(isCursorInViewport({ x: -13, y: 50 }, viewport, 1)).toBe(false) // past it
15
+ expect(isCursorInViewport({ x: 50, y: -15 }, viewport, 1)).toBe(true) // within 16 of top
16
+ expect(isCursorInViewport({ x: 50, y: -17 }, viewport, 1)).toBe(false) // past it
17
+ })
18
+
19
+ it('culls a cursor beyond the right/bottom edges', () => {
20
+ // Asymmetric with the left/top edges: the max checks subtract the margin rather than add
21
+ // it, so the cull starts *inside* the far edge (maxX - 12, maxY - 16) — a known quirk
22
+ // carried over from the pre-canvas implementation.
23
+ expect(isCursorInViewport({ x: 87, y: 50 }, viewport, 1)).toBe(true) // < maxX - 12 (88)
24
+ expect(isCursorInViewport({ x: 89, y: 50 }, viewport, 1)).toBe(false) // > 88, culled
25
+ expect(isCursorInViewport({ x: 50, y: 83 }, viewport, 1)).toBe(true) // < maxY - 16 (84)
26
+ expect(isCursorInViewport({ x: 50, y: 85 }, viewport, 1)).toBe(false) // > 84, culled
27
+ })
28
+
29
+ it('scales the margins by zoom', () => {
30
+ // At zoom 2 the left margin is 12/2 = 6px, so -7 is now culled where -11 survived at zoom 1.
31
+ expect(isCursorInViewport({ x: -5, y: 50 }, viewport, 2)).toBe(true)
32
+ expect(isCursorInViewport({ x: -7, y: 50 }, viewport, 2)).toBe(false)
33
+ })
34
+ })
@@ -0,0 +1,25 @@
1
+ import { VecLike } from '../primitives/Vec'
2
+
3
+ /**
4
+ * Whether a collaborator's cursor (page coords) is inside the viewport, with a small margin for the
5
+ * cursor glyph so it isn't culled the instant its hotspot crosses the edge.
6
+ *
7
+ * Single source of truth for the cursor/hint split: the DOM cursor layer renders a collaborator
8
+ * when this is true, and `CollaboratorHintOverlayUtil` draws the viewport-edge hint when it's
9
+ * false. Sharing the predicate keeps those two exhaustive and mutually exclusive — a margin change
10
+ * can't open a dead zone (neither renders) or a double-render (both).
11
+ *
12
+ * @public
13
+ */
14
+ export function isCursorInViewport(
15
+ cursor: VecLike,
16
+ viewport: { minX: number; minY: number; maxX: number; maxY: number },
17
+ zoom: number
18
+ ): boolean {
19
+ return !(
20
+ cursor.x < viewport.minX - 12 / zoom ||
21
+ cursor.y < viewport.minY - 16 / zoom ||
22
+ cursor.x > viewport.maxX - 12 / zoom ||
23
+ cursor.y > viewport.maxY - 16 / zoom
24
+ )
25
+ }
@@ -0,0 +1,22 @@
1
+ import { modulate } from '@tldraw/utils'
2
+ import type { Editor } from '../editor/Editor'
3
+ import { toDomPrecision } from '../primitives/utils'
4
+
5
+ /**
6
+ * The CSS `transform` that puts a 1px-sized html layer into page space under the current camera.
7
+ * Shared by every camera-tracked DOM layer (the canvas's shape layer, the collaborator cursor
8
+ * layer) so they line up exactly and can't drift apart.
9
+ *
10
+ * The offset compensates for the layer's 1px width/height when zoomed, so it registers precisely
11
+ * with the other layers.
12
+ *
13
+ * @internal
14
+ */
15
+ export function getHtmlLayerTransform(editor: Editor): string {
16
+ const { x, y, z } = editor.getCamera()
17
+ const offset =
18
+ z >= 1 ? modulate(z, [1, 8], [0.125, 0.5], true) : modulate(z, [0.1, 1], [-2, 0.125], true)
19
+ return `scale(${toDomPrecision(z)}) translate(${toDomPrecision(x + offset)}px,${toDomPrecision(
20
+ y + offset
21
+ )}px)`
22
+ }
package/src/version.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '5.3.0-internal.fba91ed55f6c'
4
+ export const version = '5.3.0-next.0362a8b4a246'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-07-17T10:57:42.512Z',
8
- patch: '2026-07-17T10:57:42.512Z',
7
+ minor: '2026-08-03T11:11:39.168Z',
8
+ patch: '2026-08-03T11:11:39.168Z',
9
9
  }