@tldraw/editor 5.3.0-next.fc37bd96825b → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +138 -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/Editor.js +7 -2
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- 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/shapes/ShapeUtil.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 +138 -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/Editor.mjs +7 -2
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- 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/editor/shapes/ShapeUtil.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/Editor.ts +14 -2
- 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/shapes/ShapeUtil.ts +24 -0
- 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,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 {
|
|
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
|
+
})
|
package/src/lib/editor/Editor.ts
CHANGED
|
@@ -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 }) =>
|
|
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
|
-
|
|
10046
|
+
let newShape = { ...oldShape, id: newId }
|
|
10047
|
+
|
|
10048
|
+
// Give the shape util a chance to modify the copy, e.g. to re-stamp note
|
|
10049
|
+
// attribution to the current user so we don't forge the original author's identity.
|
|
10050
|
+
// When ids are preserved the shape keeps its identity (e.g. moveShapesToPage
|
|
10051
|
+
// relocating it), so it's not a duplicate and the hook must not run.
|
|
10052
|
+
if (!preserveIds) {
|
|
10053
|
+
newShape = this.getShapeUtil(newShape).onBeforeDuplicate?.(oldShape, newShape) ?? newShape
|
|
10054
|
+
}
|
|
10043
10055
|
|
|
10044
10056
|
if (rootShapeIds.includes(oldShape.id)) {
|
|
10045
10057
|
newShape.parentId = currentPageId
|
|
@@ -33,7 +33,7 @@ function createPresence(userId: TLUserId): TLInstancePresence {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function createEditor(presences: TLInstancePresence[] = []) {
|
|
36
|
-
const setInterval = vi.fn(() => 123)
|
|
36
|
+
const setInterval = vi.fn((_fn: () => void, _ms: number) => 123)
|
|
37
37
|
const getInstanceState = vi.fn(() => ({
|
|
38
38
|
followingUserId: null,
|
|
39
39
|
highlightedUserIds: [],
|
|
@@ -67,6 +67,7 @@ function createEditor(presences: TLInstancePresence[] = []) {
|
|
|
67
67
|
describe(CollaboratorsManager, () => {
|
|
68
68
|
afterEach(() => {
|
|
69
69
|
vi.clearAllMocks()
|
|
70
|
+
vi.useRealTimers()
|
|
70
71
|
})
|
|
71
72
|
|
|
72
73
|
it('starts the visibility clock on the first visible collaborators read', () => {
|
|
@@ -143,6 +144,50 @@ describe(CollaboratorsManager, () => {
|
|
|
143
144
|
expect(manager.getVisibleCollaborators()).toHaveLength(1)
|
|
144
145
|
})
|
|
145
146
|
|
|
147
|
+
it('keeps array identity when a presence update leaves the derived lists unchanged', () => {
|
|
148
|
+
const peerHere = createPresence(createUserId('peer-here'))
|
|
149
|
+
const peerElsewhere = createPresence(createUserId('peer-elsewhere'))
|
|
150
|
+
peerElsewhere.currentPageId = PageRecordType.createId('other-page')
|
|
151
|
+
const { editor } = createEditor([peerHere, peerElsewhere])
|
|
152
|
+
const manager = new CollaboratorsManager(editor)
|
|
153
|
+
|
|
154
|
+
const onPage = manager.getCollaboratorsOnCurrentPage()
|
|
155
|
+
const visibleOnPage = manager.getVisibleCollaboratorsOnCurrentPage()
|
|
156
|
+
expect(onPage).toEqual([peerHere])
|
|
157
|
+
|
|
158
|
+
// The off-page peer sends a presence update: the current-page lists are unaffected, so
|
|
159
|
+
// they keep the exact same array identity and don't invalidate downstream subscribers.
|
|
160
|
+
editor.store.put([{ ...peerElsewhere, chatMessage: 'hello from another page' }])
|
|
161
|
+
expect(manager.getCollaboratorsOnCurrentPage()).toBe(onPage)
|
|
162
|
+
expect(manager.getVisibleCollaboratorsOnCurrentPage()).toBe(visibleOnPage)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('keeps array identity across a visibility-clock tick that changes nothing', () => {
|
|
166
|
+
vi.useFakeTimers()
|
|
167
|
+
const peer = createPresence(createUserId('peer'))
|
|
168
|
+
const { editor, setInterval, getInstanceState } = createEditor([peer])
|
|
169
|
+
const manager = new CollaboratorsManager(editor)
|
|
170
|
+
|
|
171
|
+
const visible = manager.getVisibleCollaborators()
|
|
172
|
+
const visibleOnPage = manager.getVisibleCollaboratorsOnCurrentPage()
|
|
173
|
+
expect(visible).toEqual([peer])
|
|
174
|
+
expect(getInstanceState).toHaveBeenCalledTimes(1)
|
|
175
|
+
|
|
176
|
+
// The clock ticks on a fixed interval whether or not anything changed. Advance far enough
|
|
177
|
+
// to move the clock atom, but not far enough to change anyone's activity state.
|
|
178
|
+
const tick = setInterval.mock.calls[0][0]
|
|
179
|
+
vi.advanceTimersByTime(1000)
|
|
180
|
+
tick()
|
|
181
|
+
|
|
182
|
+
// The tick really did invalidate the query — it re-evaluates and re-reads instance state —
|
|
183
|
+
// but the result is element-wise identical, so it keeps the previous array's identity. An
|
|
184
|
+
// idle room therefore doesn't re-render the cursor layer once per interval.
|
|
185
|
+
const revisited = manager.getVisibleCollaborators()
|
|
186
|
+
expect(getInstanceState).toHaveBeenCalledTimes(2)
|
|
187
|
+
expect(revisited).toBe(visible)
|
|
188
|
+
expect(manager.getVisibleCollaboratorsOnCurrentPage()).toBe(visibleOnPage)
|
|
189
|
+
})
|
|
190
|
+
|
|
146
191
|
it('shows newly-joined collaborators that have not recorded any activity yet', () => {
|
|
147
192
|
// A peer who has joined but not moved their pointer broadcasts the default
|
|
148
193
|
// `lastActivityTimestamp` of 0. They should still be treated as active so
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EMPTY_ARRAY, atom, computed } from '@tldraw/state'
|
|
2
2
|
import type { TLInstancePresence } from '@tldraw/tlschema'
|
|
3
|
-
import { maxBy } from '@tldraw/utils'
|
|
3
|
+
import { areArraysShallowEqual, maxBy } from '@tldraw/utils'
|
|
4
4
|
import type { Editor } from '../../Editor'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -41,11 +41,16 @@ export class CollaboratorsManager {
|
|
|
41
41
|
}))
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// These queries all derive fresh arrays with map/filter, so they compare results with
|
|
45
|
+
// shallow (element-identity) equality: a presence update that leaves a derived list's
|
|
46
|
+
// elements unchanged — e.g. a peer moving on another page — keeps the previous array's
|
|
47
|
+
// identity and doesn't invalidate downstream subscribers such as the cursor layer.
|
|
48
|
+
|
|
44
49
|
/**
|
|
45
50
|
* Returns a list of presence records for all peer collaborators.
|
|
46
51
|
* This will return the latest presence record for each connected user.
|
|
47
52
|
*/
|
|
48
|
-
@computed
|
|
53
|
+
@computed({ isEqual: areArraysShallowEqual })
|
|
49
54
|
getCollaborators(): TLInstancePresence[] {
|
|
50
55
|
const allPresenceRecords = this._getCollaboratorsQuery().get()
|
|
51
56
|
if (!allPresenceRecords.length) return EMPTY_ARRAY
|
|
@@ -63,7 +68,7 @@ export class CollaboratorsManager {
|
|
|
63
68
|
* Returns a list of presence records for all peer collaborators on the current page.
|
|
64
69
|
* This will return the latest presence record for each connected user.
|
|
65
70
|
*/
|
|
66
|
-
@computed
|
|
71
|
+
@computed({ isEqual: areArraysShallowEqual })
|
|
67
72
|
getCollaboratorsOnCurrentPage(): TLInstancePresence[] {
|
|
68
73
|
const currentPageId = this.editor.getCurrentPageId()
|
|
69
74
|
return this.getCollaborators().filter((c) => c.currentPageId === currentPageId)
|
|
@@ -76,7 +81,7 @@ export class CollaboratorsManager {
|
|
|
76
81
|
* highlighted users. Re-evaluates on the visibility clock, so callers don't need to
|
|
77
82
|
* drive their own activity timer.
|
|
78
83
|
*/
|
|
79
|
-
@computed
|
|
84
|
+
@computed({ isEqual: areArraysShallowEqual })
|
|
80
85
|
getVisibleCollaborators(): TLInstancePresence[] {
|
|
81
86
|
const { editor } = this
|
|
82
87
|
const { collaboratorInactiveTimeoutMs, collaboratorIdleTimeoutMs } = editor.options
|
|
@@ -121,7 +126,7 @@ export class CollaboratorsManager {
|
|
|
121
126
|
* Returns a list of presence records for peer collaborators who should currently be
|
|
122
127
|
* shown in the UI, filtered to those on the current page.
|
|
123
128
|
*/
|
|
124
|
-
@computed
|
|
129
|
+
@computed({ isEqual: areArraysShallowEqual })
|
|
125
130
|
getVisibleCollaboratorsOnCurrentPage(): TLInstancePresence[] {
|
|
126
131
|
const currentPageId = this.editor.getCurrentPageId()
|
|
127
132
|
return this.getVisibleCollaborators().filter((c) => c.currentPageId === currentPageId)
|
|
@@ -669,6 +669,30 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
|
|
|
669
669
|
*/
|
|
670
670
|
onBeforeCreate?(next: Shape): Shape | void
|
|
671
671
|
|
|
672
|
+
/**
|
|
673
|
+
* A callback called when a shape is reproduced from an existing shape, either by duplicating
|
|
674
|
+
* ({@link Editor.duplicateShapes}) or by pasting/putting content onto the page
|
|
675
|
+
* ({@link Editor.putContentOntoCurrentPage}). This provides a last chance to modify the copy
|
|
676
|
+
* before it's created — for example, to re-stamp attribution so the copy is credited to the
|
|
677
|
+
* current user rather than the original author. It is not called when content is put with
|
|
678
|
+
* `preserveIds` (e.g. {@link Editor.moveShapesToPage}), since the shape keeps its identity
|
|
679
|
+
* and no copy is made.
|
|
680
|
+
*
|
|
681
|
+
* @example
|
|
682
|
+
*
|
|
683
|
+
* ```ts
|
|
684
|
+
* onBeforeDuplicate = (source, duplicate) => {
|
|
685
|
+
* return { ...duplicate, props: { ...duplicate.props, editedBy: this.editor.getAttributionUserId() } }
|
|
686
|
+
* }
|
|
687
|
+
* ```
|
|
688
|
+
*
|
|
689
|
+
* @param source - The shape being copied from.
|
|
690
|
+
* @param duplicate - The new copy (with its own id), before it's created.
|
|
691
|
+
* @returns The next shape or void.
|
|
692
|
+
* @public
|
|
693
|
+
*/
|
|
694
|
+
onBeforeDuplicate?(source: Shape, duplicate: Shape): Shape | void
|
|
695
|
+
|
|
672
696
|
/**
|
|
673
697
|
* A callback called just before a shape is updated. This method provides a last chance to modify
|
|
674
698
|
* the updated shape.
|
|
@@ -37,6 +37,11 @@ export type TLExternalContentSource =
|
|
|
37
37
|
|
|
38
38
|
/** @public */
|
|
39
39
|
export interface TLBaseExternalContent {
|
|
40
|
+
/**
|
|
41
|
+
* The other content sources found on the clipboard alongside this content. For example, when
|
|
42
|
+
* pasting an image copied together with HTML and plain text, the `files` content will have
|
|
43
|
+
* text sources for the HTML and plain text parts.
|
|
44
|
+
*/
|
|
40
45
|
sources?: TLExternalContentSource[]
|
|
41
46
|
point?: VecLike
|
|
42
47
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ComponentType, RefAttributes, createContext, useContext } from 'react'
|
|
2
2
|
import type { TLCanvasComponentProps } from '../components/default-components/DefaultCanvas'
|
|
3
|
+
import type { TLCursorProps } from '../components/default-components/DefaultCursor'
|
|
3
4
|
import type { TLErrorFallbackComponent } from '../components/default-components/DefaultErrorFallback'
|
|
4
5
|
import type { TLGridProps } from '../components/default-components/DefaultGrid'
|
|
5
6
|
import type { TLShapeErrorFallbackComponent } from '../components/default-components/DefaultShapeErrorFallback'
|
|
@@ -9,6 +10,7 @@ import type { TLShapeWrapperProps } from '../components/default-components/Defau
|
|
|
9
10
|
export interface TLEditorComponents {
|
|
10
11
|
Background?: ComponentType | null
|
|
11
12
|
Canvas?: ComponentType<TLCanvasComponentProps> | null
|
|
13
|
+
CollaboratorCursor?: ComponentType<TLCursorProps> | null
|
|
12
14
|
Grid?: ComponentType<TLGridProps> | null
|
|
13
15
|
InFrontOfTheCanvas?: ComponentType | null
|
|
14
16
|
LoadingScreen?: ComponentType | null
|
|
@@ -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,
|