@tldraw/editor 5.3.0-next.4123e97459a9 → 5.3.0-next.5cb71bb7e8fe
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.
- package/dist-cjs/index.d.ts +115 -3
- package/dist-cjs/index.js +13 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +24 -19
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/components/EditorPortal.js +44 -0
- package/dist-cjs/lib/components/EditorPortal.js.map +7 -0
- package/dist-cjs/lib/components/LiveCollaborators.js +102 -0
- package/dist-cjs/lib/components/LiveCollaborators.js.map +7 -0
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js +4 -10
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +3 -3
- package/dist-cjs/lib/components/default-components/DefaultCursor.js +59 -0
- package/dist-cjs/lib/components/default-components/DefaultCursor.js.map +7 -0
- package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +4 -4
- package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
- package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
- package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
- package/dist-cjs/lib/hooks/useCursor.js +1 -0
- package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
- package/dist-cjs/lib/hooks/useEditorComponents.js +2 -0
- package/dist-cjs/lib/hooks/useEditorComponents.js.map +2 -2
- package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +49 -18
- package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +3 -3
- package/dist-cjs/lib/hooks/useTransform.js +45 -0
- package/dist-cjs/lib/hooks/useTransform.js.map +7 -0
- package/dist-cjs/lib/license/LicenseManager.js +51 -2
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/license/useLicenseManagerState.js +7 -0
- package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
- package/dist-cjs/lib/utils/collaborators.js +27 -0
- package/dist-cjs/lib/utils/collaborators.js.map +7 -0
- package/dist-cjs/lib/utils/getHtmlLayerTransform.js +33 -0
- package/dist-cjs/lib/utils/getHtmlLayerTransform.js.map +7 -0
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +115 -3
- package/dist-esm/index.mjs +19 -4
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +23 -18
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/components/EditorPortal.mjs +24 -0
- package/dist-esm/lib/components/EditorPortal.mjs.map +7 -0
- package/dist-esm/lib/components/LiveCollaborators.mjs +82 -0
- package/dist-esm/lib/components/LiveCollaborators.mjs.map +7 -0
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -11
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultCursor.mjs +29 -0
- package/dist-esm/lib/components/default-components/DefaultCursor.mjs.map +7 -0
- package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +5 -5
- package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
- package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
- package/dist-esm/lib/hooks/useCursor.mjs +1 -0
- package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
- package/dist-esm/lib/hooks/useEditorComponents.mjs +2 -0
- package/dist-esm/lib/hooks/useEditorComponents.mjs.map +2 -2
- package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +50 -19
- package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +3 -3
- package/dist-esm/lib/hooks/useTransform.mjs +25 -0
- package/dist-esm/lib/hooks/useTransform.mjs.map +7 -0
- package/dist-esm/lib/license/LicenseManager.mjs +52 -3
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/license/useLicenseManagerState.mjs +7 -0
- package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
- package/dist-esm/lib/utils/collaborators.mjs +7 -0
- package/dist-esm/lib/utils/collaborators.mjs.map +7 -0
- package/dist-esm/lib/utils/getHtmlLayerTransform.mjs +13 -0
- package/dist-esm/lib/utils/getHtmlLayerTransform.mjs.map +7 -0
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +43 -4
- package/package.json +7 -7
- package/src/index.ts +14 -2
- package/src/lib/TldrawEditor.tsx +21 -13
- package/src/lib/components/EditorPortal.tsx +77 -0
- package/src/lib/components/LiveCollaborators.tsx +133 -0
- package/src/lib/components/default-components/DefaultCanvas.tsx +5 -17
- package/src/lib/components/default-components/DefaultCursor.tsx +65 -0
- package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +46 -1
- package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +10 -5
- package/src/lib/editor/types/external-content.ts +5 -0
- package/src/lib/hooks/EditorComponentsContext.tsx +2 -0
- package/src/lib/hooks/useCursor.ts +1 -0
- package/src/lib/hooks/useEditorComponents.tsx +2 -0
- package/src/lib/hooks/usePassThroughWheelEvents.ts +83 -24
- package/src/lib/hooks/useTransform.ts +38 -0
- package/src/lib/license/LicenseManager.test.ts +146 -0
- package/src/lib/license/LicenseManager.ts +98 -2
- package/src/lib/license/useLicenseManagerState.ts +17 -1
- package/src/lib/test/EditorPortal.test.tsx +48 -0
- package/src/lib/test/usePassThroughWheelEvents.test.tsx +77 -0
- package/src/lib/utils/collaborators.test.ts +34 -0
- package/src/lib/utils/collaborators.ts +25 -0
- package/src/lib/utils/getHtmlLayerTransform.ts +22 -0
- package/src/version.ts +3 -3
- package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js +0 -51
- package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js.map +0 -7
- package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs +0 -31
- package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs.map +0 -7
- package/src/lib/hooks/usePassThroughMouseOverEvents.ts +0 -32
|
@@ -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 {
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
@@ -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,
|
|
@@ -1,7 +1,50 @@
|
|
|
1
|
-
import { RefObject, useEffect } from 'react'
|
|
1
|
+
import { RefObject, useEffect, useRef } from 'react'
|
|
2
2
|
import { preventDefault } from '../utils/dom'
|
|
3
3
|
import { useContainer } from './useContainer'
|
|
4
4
|
import { useMaybeEditor } from './useEditor'
|
|
5
|
+
import { useEvent } from './useEvent'
|
|
6
|
+
|
|
7
|
+
function isScrollableOverflow(overflow: string) {
|
|
8
|
+
return overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// An element only consumes a wheel event when it's a real scroll container: its content overflows
|
|
12
|
+
// AND its computed overflow is scrollable. Checking scrollHeight/scrollWidth alone is not enough —
|
|
13
|
+
// an `overflow: visible` element whose content merely overflows (e.g. a comment pin whose hover
|
|
14
|
+
// `transform: scale()` inflates scrollHeight past clientHeight) never scrolls, so the wheel must
|
|
15
|
+
// still pass through to the canvas.
|
|
16
|
+
function isScrollable(elm: HTMLElement) {
|
|
17
|
+
// Cheap first: if nothing overflows, it can't scroll — skip the getComputedStyle read. This runs
|
|
18
|
+
// per ancestor on every wheel event, so avoid resolving styles for the common (non-overflowing) case.
|
|
19
|
+
const overflowsY = elm.scrollHeight > elm.clientHeight
|
|
20
|
+
const overflowsX = elm.scrollWidth > elm.clientWidth
|
|
21
|
+
if (!overflowsY && !overflowsX) return false
|
|
22
|
+
|
|
23
|
+
const style = getComputedStyle(elm)
|
|
24
|
+
return (
|
|
25
|
+
(overflowsY && isScrollableOverflow(style.overflowY)) ||
|
|
26
|
+
(overflowsX && isScrollableOverflow(style.overflowX))
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Walk from the wheeled element up to (and including) the pass-through root, looking for a scroll
|
|
31
|
+
// container. If one is found, the wheel belongs to it — don't redispatch it to the canvas.
|
|
32
|
+
function hasScrollableElement(target: EventTarget | null, root: HTMLElement) {
|
|
33
|
+
if (!(target instanceof Node)) {
|
|
34
|
+
return isScrollable(root)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let elm: Element | null = target instanceof Element ? target : target.parentElement
|
|
38
|
+
while (elm) {
|
|
39
|
+
if (elm instanceof HTMLElement && isScrollable(elm)) {
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
if (elm === root) return false
|
|
43
|
+
elm = elm.parentElement
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
5
48
|
|
|
6
49
|
/** @public */
|
|
7
50
|
export function usePassThroughWheelEvents(ref: RefObject<HTMLElement | null>) {
|
|
@@ -9,33 +52,49 @@ export function usePassThroughWheelEvents(ref: RefObject<HTMLElement | null>) {
|
|
|
9
52
|
const container = useContainer()
|
|
10
53
|
const editor = useMaybeEditor()
|
|
11
54
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
preventDefault(e)
|
|
26
|
-
const cvs = container.querySelector('.tl-canvas')
|
|
27
|
-
if (!cvs) return
|
|
28
|
-
const newEvent = new WheelEvent('wheel', e as any)
|
|
29
|
-
;(newEvent as any).isSpecialRedispatchedEvent = true
|
|
30
|
-
cvs.dispatchEvent(newEvent)
|
|
55
|
+
const onWheel = useEvent((e: WheelEvent) => {
|
|
56
|
+
// Only pass through wheel events if the editor is focused
|
|
57
|
+
if (!editor?.getInstanceState().isFocused) return
|
|
58
|
+
|
|
59
|
+
if ((e as any).isSpecialRedispatchedEvent) return
|
|
60
|
+
|
|
61
|
+
// If the wheel is over a scrollable element, let it scroll instead of redispatching.
|
|
62
|
+
const elm = ref.current
|
|
63
|
+
if (elm && hasScrollableElement(e.target, elm)) {
|
|
64
|
+
return
|
|
31
65
|
}
|
|
32
66
|
|
|
67
|
+
preventDefault(e)
|
|
68
|
+
const cvs = container.querySelector('.tl-canvas')
|
|
69
|
+
if (!cvs) return
|
|
70
|
+
const newEvent = new WheelEvent('wheel', e as any)
|
|
71
|
+
;(newEvent as any).isSpecialRedispatchedEvent = true
|
|
72
|
+
cvs.dispatchEvent(newEvent)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// The element the listener is currently on, which isn't necessarily `ref.current`: a `RefObject`
|
|
76
|
+
// gives no notification when its element changes.
|
|
77
|
+
const attached = useRef<HTMLElement | null>(null)
|
|
78
|
+
|
|
79
|
+
// Deliberately no dependency array — this re-checks the ref after every render of the component
|
|
80
|
+
// that owns it, and re-attaches when the element has been swapped. A component that keeps the
|
|
81
|
+
// hook mounted while unmounting and remounting the element it points at (a comment pin culled
|
|
82
|
+
// while its anchor is off screen, say) would otherwise leave the listener on the discarded node
|
|
83
|
+
// and silently lose pass-through once the element comes back.
|
|
84
|
+
useEffect(() => {
|
|
33
85
|
const elm = ref.current
|
|
34
|
-
if (
|
|
86
|
+
if (elm === attached.current) return
|
|
87
|
+
attached.current?.removeEventListener('wheel', onWheel)
|
|
88
|
+
attached.current = elm
|
|
89
|
+
elm?.addEventListener('wheel', onWheel, { passive: false })
|
|
90
|
+
})
|
|
35
91
|
|
|
36
|
-
|
|
92
|
+
// Teardown is its own effect so the re-attach check above can skip returning a cleanup that
|
|
93
|
+
// would tear the listener down and rebuild it on every render.
|
|
94
|
+
useEffect(() => {
|
|
37
95
|
return () => {
|
|
38
|
-
|
|
96
|
+
attached.current?.removeEventListener('wheel', onWheel)
|
|
97
|
+
attached.current = null
|
|
39
98
|
}
|
|
40
|
-
}, [
|
|
99
|
+
}, [onWheel])
|
|
41
100
|
}
|
|
@@ -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
|
+
}
|