@tldraw/commenting 5.3.0-next.2fa9c61a8de6 → 5.3.0-next.7654e7ac2a02
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commenting.css +63 -71
- package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
- package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
- package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
- package/dist-cjs/canvas/canvas-events.js.map +7 -0
- package/dist-cjs/canvas/cluster-badge.js +121 -0
- package/dist-cjs/canvas/cluster-badge.js.map +7 -0
- package/dist-cjs/canvas/cluster-fade.js +85 -0
- package/dist-cjs/canvas/cluster-fade.js.map +7 -0
- package/dist-cjs/canvas/cluster-input.js +51 -1
- package/dist-cjs/canvas/cluster-input.js.map +2 -2
- package/dist-cjs/canvas/cluster-model.js +252 -0
- package/dist-cjs/canvas/cluster-model.js.map +7 -0
- package/dist-cjs/canvas/comment-mutations.js +57 -28
- package/dist-cjs/canvas/comment-mutations.js.map +2 -2
- package/dist-cjs/canvas/comment-reactions.js +4 -7
- package/dist-cjs/canvas/comment-reactions.js.map +2 -2
- package/dist-cjs/canvas/comment-render.js +4 -3
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-store.js.map +2 -2
- package/dist-cjs/canvas/comment-tool.js +0 -1
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +11 -13
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +77 -870
- package/dist-cjs/canvas/comments-overlay.js.map +3 -3
- package/dist-cjs/canvas/comments-sidebar.js +16 -11
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
- package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
- package/dist-cjs/canvas/context.js.map +1 -1
- package/dist-cjs/canvas/hooks.js +8 -2
- package/dist-cjs/canvas/hooks.js.map +2 -2
- package/dist-cjs/canvas/license.js.map +1 -1
- package/dist-cjs/canvas/mobile-placement.js +91 -0
- package/dist-cjs/canvas/mobile-placement.js.map +7 -0
- package/dist-cjs/canvas/options.js +39 -1
- package/dist-cjs/canvas/options.js.map +2 -2
- package/dist-cjs/canvas/pending-composer.js +134 -0
- package/dist-cjs/canvas/pending-composer.js.map +7 -0
- package/dist-cjs/canvas/pin-stacking.js +26 -1
- package/dist-cjs/canvas/pin-stacking.js.map +2 -2
- package/dist-cjs/canvas/region-box.js +113 -0
- package/dist-cjs/canvas/region-box.js.map +7 -0
- package/dist-cjs/canvas/state.js.map +2 -2
- package/dist-cjs/canvas/thread-pin.js +304 -0
- package/dist-cjs/canvas/thread-pin.js.map +7 -0
- package/dist-cjs/canvas/thread-preview.js +41 -49
- package/dist-cjs/canvas/thread-preview.js.map +2 -2
- package/dist-cjs/canvas/thread-stack.js +7 -7
- package/dist-cjs/canvas/thread-stack.js.map +2 -2
- package/dist-cjs/canvas/thread-state.js +15 -0
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +120 -67
- package/dist-cjs/canvas/thread-view.js.map +3 -3
- package/dist-cjs/clustering/computeClusterTable.js +2 -2
- package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
- package/dist-cjs/clustering/replay.js +89 -22
- package/dist-cjs/clustering/replay.js.map +3 -3
- package/dist-cjs/clustering/runtime.js +19 -12
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/clustering/types.js.map +1 -1
- package/dist-cjs/index.d.ts +323 -202
- package/dist-cjs/index.js +7 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +5 -3
- package/dist-cjs/ui/byline.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +2 -18
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +2 -15
- package/dist-cjs/ui/comment-pin.js.map +2 -2
- package/dist-cjs/ui/comments-list.js +11 -24
- package/dist-cjs/ui/comments-list.js.map +2 -2
- package/dist-cjs/ui/format-time.js +12 -0
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/icons.js +115 -0
- package/dist-cjs/ui/icons.js.map +7 -0
- package/dist-cjs/ui/reaction-picker.js +11 -13
- package/dist-cjs/ui/reaction-picker.js.map +2 -2
- package/dist-cjs/ui/reaction.js +3 -4
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/send-button.js +2 -9
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/visual-viewport.js +32 -0
- package/dist-cjs/ui/visual-viewport.js.map +7 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
- package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
- package/dist-esm/canvas/canvas-events.mjs +15 -0
- package/dist-esm/canvas/canvas-events.mjs.map +7 -0
- package/dist-esm/canvas/cluster-badge.mjs +106 -0
- package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
- package/dist-esm/canvas/cluster-fade.mjs +65 -0
- package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +52 -2
- package/dist-esm/canvas/cluster-input.mjs.map +2 -2
- package/dist-esm/canvas/cluster-model.mjs +236 -0
- package/dist-esm/canvas/cluster-model.mjs.map +7 -0
- package/dist-esm/canvas/comment-mutations.mjs +57 -28
- package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
- package/dist-esm/canvas/comment-reactions.mjs +5 -12
- package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
- package/dist-esm/canvas/comment-render.mjs +5 -4
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-store.mjs.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +0 -1
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +54 -878
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +18 -14
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
- package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
- package/dist-esm/canvas/hooks.mjs +9 -2
- package/dist-esm/canvas/hooks.mjs.map +2 -2
- package/dist-esm/canvas/license.mjs.map +1 -1
- package/dist-esm/canvas/mobile-placement.mjs +71 -0
- package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +39 -1
- package/dist-esm/canvas/options.mjs.map +2 -2
- package/dist-esm/canvas/pending-composer.mjs +126 -0
- package/dist-esm/canvas/pending-composer.mjs.map +7 -0
- package/dist-esm/canvas/pin-stacking.mjs +26 -1
- package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
- package/dist-esm/canvas/region-box.mjs +93 -0
- package/dist-esm/canvas/region-box.mjs.map +7 -0
- package/dist-esm/canvas/state.mjs.map +2 -2
- package/dist-esm/canvas/thread-pin.mjs +310 -0
- package/dist-esm/canvas/thread-pin.mjs.map +7 -0
- package/dist-esm/canvas/thread-preview.mjs +42 -49
- package/dist-esm/canvas/thread-preview.mjs.map +2 -2
- package/dist-esm/canvas/thread-stack.mjs +8 -9
- package/dist-esm/canvas/thread-stack.mjs.map +2 -2
- package/dist-esm/canvas/thread-state.mjs +15 -0
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +131 -72
- package/dist-esm/canvas/thread-view.mjs.map +3 -3
- package/dist-esm/clustering/computeClusterTable.mjs +2 -2
- package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
- package/dist-esm/clustering/replay.mjs +89 -22
- package/dist-esm/clustering/replay.mjs.map +3 -3
- package/dist-esm/clustering/runtime.mjs +19 -12
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +323 -202
- package/dist-esm/index.mjs +16 -3
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +6 -4
- package/dist-esm/ui/byline.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +2 -18
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +2 -15
- package/dist-esm/ui/comment-pin.mjs.map +2 -2
- package/dist-esm/ui/comments-list.mjs +10 -23
- package/dist-esm/ui/comments-list.mjs.map +2 -2
- package/dist-esm/ui/format-time.mjs +12 -0
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/icons.mjs +95 -0
- package/dist-esm/ui/icons.mjs.map +7 -0
- package/dist-esm/ui/reaction-picker.mjs +12 -13
- package/dist-esm/ui/reaction-picker.mjs.map +2 -2
- package/dist-esm/ui/reaction.mjs +3 -4
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/send-button.mjs +2 -9
- package/dist-esm/ui/send-button.mjs.map +2 -2
- package/dist-esm/ui/visual-viewport.mjs +12 -0
- package/dist-esm/ui/visual-viewport.mjs.map +7 -0
- package/package.json +5 -5
- package/src/canvas/anchor-lifecycle.test.ts +2 -2
- package/src/canvas/anchor-lifecycle.ts +25 -39
- package/src/canvas/canvas-events.ts +18 -0
- package/src/canvas/canvas.css +5 -11
- package/src/canvas/cluster-badge.tsx +133 -0
- package/src/canvas/cluster-fade.ts +102 -0
- package/src/canvas/cluster-input.test.ts +148 -14
- package/src/canvas/cluster-input.ts +92 -5
- package/src/canvas/cluster-model.ts +378 -0
- package/src/canvas/comment-mutations.test.ts +46 -4
- package/src/canvas/comment-mutations.ts +115 -104
- package/src/canvas/comment-reactions.test.ts +22 -0
- package/src/canvas/comment-reactions.tsx +33 -32
- package/src/canvas/comment-render.ts +8 -8
- package/src/canvas/comment-store.ts +12 -17
- package/src/canvas/comment-tool.tsx +8 -12
- package/src/canvas/comments-filter-menu.tsx +9 -16
- package/src/canvas/comments-overflow-menu.tsx +8 -3
- package/src/canvas/comments-overlay.tsx +154 -1344
- package/src/canvas/comments-sidebar.tsx +40 -19
- package/src/canvas/comments-visibility-toggle.tsx +6 -30
- package/src/canvas/context.ts +4 -3
- package/src/canvas/hooks.test.ts +94 -0
- package/src/canvas/hooks.ts +17 -3
- package/src/canvas/license.ts +1 -1
- package/src/canvas/mobile-placement.ts +115 -0
- package/src/canvas/options.test.ts +124 -2
- package/src/canvas/options.ts +192 -58
- package/src/canvas/pending-composer.tsx +157 -0
- package/src/canvas/pin-stacking.test.ts +111 -1
- package/src/canvas/pin-stacking.ts +46 -0
- package/src/canvas/region-box.tsx +124 -0
- package/src/canvas/state.ts +20 -28
- package/src/canvas/thread-pin.tsx +418 -0
- package/src/canvas/thread-preview.tsx +77 -87
- package/src/canvas/thread-stack.tsx +19 -29
- package/src/canvas/thread-state.test.ts +51 -0
- package/src/canvas/thread-state.ts +56 -23
- package/src/canvas/thread-view.test.ts +72 -0
- package/src/canvas/thread-view.tsx +231 -133
- package/src/clustering/computeClusterTable.ts +12 -3
- package/src/clustering/replay.test.ts +0 -7
- package/src/clustering/replay.ts +131 -32
- package/src/clustering/runtime.test.ts +50 -6
- package/src/clustering/runtime.ts +42 -39
- package/src/clustering/schedule.test.ts +0 -6
- package/src/clustering/screen-offsets.test.ts +171 -0
- package/src/clustering/types.ts +10 -0
- package/src/index.ts +15 -2
- package/src/ui/byline.tsx +16 -6
- package/src/ui/comment-composer.tsx +25 -69
- package/src/ui/comment-pin.tsx +3 -16
- package/src/ui/comments-list.tsx +40 -29
- package/src/ui/comments.css +58 -60
- package/src/ui/format-time.test.ts +69 -0
- package/src/ui/format-time.ts +20 -0
- package/src/ui/icons.tsx +116 -0
- package/src/ui/reaction-picker.tsx +9 -16
- package/src/ui/reaction.tsx +10 -6
- package/src/ui/send-button.tsx +3 -8
- package/src/ui/visual-viewport.test.ts +36 -0
- package/src/ui/visual-viewport.ts +27 -0
- package/dist-cjs/ui/tooltip-button.js.map +0 -7
- package/dist-esm/ui/tooltip-button.mjs +0 -12
- package/dist-esm/ui/tooltip-button.mjs.map +0 -7
- package/src/ui/tooltip-button.tsx +0 -20
|
@@ -55,3 +55,49 @@ export function computePinStacks(
|
|
|
55
55
|
}
|
|
56
56
|
return stacks
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Value equality for pin-stack maps, so the overlay can hold the map's identity across a recompute
|
|
61
|
+
* that changed no grouping. Membership only: two equal maps can describe stacks at different page
|
|
62
|
+
* points, so anything keyed on a stack's *point* must read the anchors itself, as
|
|
63
|
+
* {@link isOpenStackKeyLive} does.
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
export function pinStacksEqual(
|
|
67
|
+
a: ReadonlyMap<string, readonly string[]>,
|
|
68
|
+
b: ReadonlyMap<string, readonly string[]>
|
|
69
|
+
): boolean {
|
|
70
|
+
if (a === b) return true
|
|
71
|
+
if (a.size !== b.size) return false
|
|
72
|
+
for (const [id, group] of b) {
|
|
73
|
+
const prevGroup = a.get(id)
|
|
74
|
+
if (!prevGroup) return false
|
|
75
|
+
if (prevGroup === group) continue
|
|
76
|
+
if (prevGroup.length !== group.length) return false
|
|
77
|
+
for (let i = 0; i < group.length; i++) {
|
|
78
|
+
if (prevGroup[i] !== group[i]) return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether an open-stack key still names a stack on the canvas — false once that stack is emptied,
|
|
86
|
+
* moved, or off the page, at which point the caller must clear the key. Reads the anchors, so a
|
|
87
|
+
* reactive caller re-evaluates on a move that {@link pinStacksEqual} can't see.
|
|
88
|
+
* @internal
|
|
89
|
+
*/
|
|
90
|
+
export function isOpenStackKeyLive(
|
|
91
|
+
editor: Editor,
|
|
92
|
+
key: string,
|
|
93
|
+
stacks: ReadonlyMap<string, readonly string[]>,
|
|
94
|
+
threadsById: ReadonlyMap<string, TLCommentThread>
|
|
95
|
+
): boolean {
|
|
96
|
+
for (const id of stacks.keys()) {
|
|
97
|
+
const thread = threadsById.get(id)
|
|
98
|
+
if (!thread) continue
|
|
99
|
+
const point = anchorPagePoint(editor, thread.anchor)
|
|
100
|
+
if (point && pinStackKey(point) === key) return true
|
|
101
|
+
}
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { type PointerEvent as ReactPointerEvent, useRef } from 'react'
|
|
2
|
+
import { type BoxModel, Editor, useValue, VecLike } from 'tldraw'
|
|
3
|
+
import { regionDraft } from './state'
|
|
4
|
+
|
|
5
|
+
/** A region's dashed box. Purely visual — a region moves by its pin and resizes from its corner
|
|
6
|
+
* handles, so the box itself takes no pointer events. Positioned in viewport space, as a sibling
|
|
7
|
+
* of the pins in the canvas layer. */
|
|
8
|
+
export function RegionBox({ editor, box }: { editor: Editor; box: BoxModel }) {
|
|
9
|
+
const rect = useValue(
|
|
10
|
+
'region rect',
|
|
11
|
+
() => {
|
|
12
|
+
// Position from the page→viewport top-left; screen size scales with zoom, page size doesn't.
|
|
13
|
+
const topLeft = editor.pageToViewport({ x: box.x, y: box.y })
|
|
14
|
+
const zoom = editor.getZoomLevel()
|
|
15
|
+
return { left: topLeft.x, top: topLeft.y, width: box.w * zoom, height: box.h * zoom }
|
|
16
|
+
},
|
|
17
|
+
[editor, box.x, box.y, box.w, box.h]
|
|
18
|
+
)
|
|
19
|
+
return <div className="tlui-cmt-canvas-region" style={rect} />
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The live region being dragged out by the comment tool, or nothing when not dragging. */
|
|
23
|
+
export function RegionDraftBox({ editor }: { editor: Editor }) {
|
|
24
|
+
const box = useValue('region draft', () => regionDraft.get(editor), [editor])
|
|
25
|
+
if (!box) return null
|
|
26
|
+
return <RegionBox editor={editor} box={box} />
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 is *not*
|
|
30
|
+
// controlled by that handle — the resize math reads the spot rather than special-casing corners.
|
|
31
|
+
export interface RegionHandle {
|
|
32
|
+
x: number
|
|
33
|
+
y: number
|
|
34
|
+
cursor: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The four corners, each resizing both axes.
|
|
38
|
+
export const REGION_CORNERS: readonly RegionHandle[] = [
|
|
39
|
+
{ x: 0, y: 0, cursor: 'nwse-resize' },
|
|
40
|
+
{ x: 1, y: 0, cursor: 'nesw-resize' },
|
|
41
|
+
{ x: 0, y: 1, cursor: 'nesw-resize' },
|
|
42
|
+
{ x: 1, y: 1, cursor: 'nwse-resize' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
// Screen-space slack around a region's bounds within which its box and handles stay revealed, so
|
|
46
|
+
// the handles (which sit on the edge) are comfortably reachable.
|
|
47
|
+
export const REGION_HANDLE_MARGIN_PX = 12
|
|
48
|
+
|
|
49
|
+
/** Resize `box` by dragging `handle` to `cursor` (page coords). Each controlled axis spans from the
|
|
50
|
+
* handle's fixed opposite edge to the cursor (normalized, so dragging past it flips); an axis the
|
|
51
|
+
* handle doesn't control (a midpoint, at 0.5) keeps its original position and size. */
|
|
52
|
+
function resizeRegion(box: BoxModel, handle: RegionHandle, cursor: VecLike): BoxModel {
|
|
53
|
+
const controlsX = handle.x !== 0.5
|
|
54
|
+
const controlsY = handle.y !== 0.5
|
|
55
|
+
const fixedX = box.x + (1 - handle.x) * box.w
|
|
56
|
+
const fixedY = box.y + (1 - handle.y) * box.h
|
|
57
|
+
return {
|
|
58
|
+
x: controlsX ? Math.min(fixedX, cursor.x) : box.x,
|
|
59
|
+
y: controlsY ? Math.min(fixedY, cursor.y) : box.y,
|
|
60
|
+
w: controlsX ? Math.abs(cursor.x - fixedX) : box.w,
|
|
61
|
+
h: controlsY ? Math.abs(cursor.y - fixedY) : box.h,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Draggable handles that resize a region — corners (both axes) or edges (one axis), per the resize
|
|
66
|
+
* option. Previews live, commits on release. */
|
|
67
|
+
export function RegionResizeHandles({
|
|
68
|
+
editor,
|
|
69
|
+
box,
|
|
70
|
+
handles,
|
|
71
|
+
onPreview,
|
|
72
|
+
onCommit,
|
|
73
|
+
}: {
|
|
74
|
+
editor: Editor
|
|
75
|
+
box: BoxModel
|
|
76
|
+
handles: readonly RegionHandle[]
|
|
77
|
+
onPreview(bounds: BoxModel | null): void
|
|
78
|
+
onCommit(bounds: BoxModel): void
|
|
79
|
+
}) {
|
|
80
|
+
// The box at pointer-down, captured so the box prop reflowing under the live preview doesn't move
|
|
81
|
+
// the fixed edges mid-drag.
|
|
82
|
+
const boxRef = useRef<BoxModel | null>(null)
|
|
83
|
+
const points = useValue(
|
|
84
|
+
'region handle points',
|
|
85
|
+
() =>
|
|
86
|
+
handles.map((h) => {
|
|
87
|
+
const p = editor.pageToViewport({ x: box.x + h.x * box.w, y: box.y + h.y * box.h })
|
|
88
|
+
return { ...h, key: `${h.x}-${h.y}`, left: p.x, top: p.y }
|
|
89
|
+
}),
|
|
90
|
+
[editor, box.x, box.y, box.w, box.h, handles]
|
|
91
|
+
)
|
|
92
|
+
const startResize = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
93
|
+
e.stopPropagation()
|
|
94
|
+
boxRef.current = box
|
|
95
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
96
|
+
}
|
|
97
|
+
const resizedTo = (h: RegionHandle, e: ReactPointerEvent<HTMLDivElement>): BoxModel =>
|
|
98
|
+
resizeRegion(boxRef.current!, h, editor.screenToPage({ x: e.clientX, y: e.clientY }))
|
|
99
|
+
const onResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
100
|
+
if (boxRef.current) onPreview(resizedTo(h, e))
|
|
101
|
+
}
|
|
102
|
+
const endResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
103
|
+
if (!boxRef.current) return
|
|
104
|
+
const bounds = resizedTo(h, e)
|
|
105
|
+
boxRef.current = null
|
|
106
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
107
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
108
|
+
onCommit(bounds)
|
|
109
|
+
}
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
{points.map((h) => (
|
|
113
|
+
<div
|
|
114
|
+
key={h.key}
|
|
115
|
+
className="tlui-cmt-canvas-region-handle"
|
|
116
|
+
style={{ left: h.left, top: h.top, cursor: h.cursor }}
|
|
117
|
+
onPointerDown={startResize}
|
|
118
|
+
onPointerMove={onResize(h)}
|
|
119
|
+
onPointerUp={endResize(h)}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
</>
|
|
123
|
+
)
|
|
124
|
+
}
|
package/src/canvas/state.ts
CHANGED
|
@@ -3,20 +3,19 @@ import type { PendingComment } from './comment-tool'
|
|
|
3
3
|
import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Transient commenting UI state, scoped per editor via {@link EditorAtom}
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* and the comment tool (`this.editor`).
|
|
6
|
+
* Transient commenting UI state, scoped per editor via {@link EditorAtom} so two editors on one
|
|
7
|
+
* page don't share open-thread, visibility, and filter state. Reachable from both React
|
|
8
|
+
* (`useEditor()`) and the comment tool (`this.editor`).
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
11
|
/** The id of the one open thread (only one popover is open at a time), or null when all closed.
|
|
13
12
|
* @public */
|
|
14
13
|
export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
|
|
15
14
|
|
|
16
|
-
/** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
/** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread id),
|
|
16
|
+
* or null. Editor state rather than component state: the stack pin remounts when its owning render
|
|
17
|
+
* path changes, and the open list must survive that.
|
|
18
|
+
* @internal */
|
|
20
19
|
export const openStackId = new EditorAtom<string | null>('openStackId', () => null)
|
|
21
20
|
|
|
22
21
|
/** The comment currently being placed (composer open, not yet posted), or null.
|
|
@@ -32,13 +31,12 @@ export const pendingComment = new EditorAtom<PendingComment | null>('pendingComm
|
|
|
32
31
|
export const revealThreadRequest = new EditorAtom<string | null>('revealThreadRequest', () => null)
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
|
-
* Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it
|
|
36
|
-
*
|
|
34
|
+
* Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it to
|
|
35
|
+
* jump to a thread from outside the canvas — a notification, a deep link, your own list.
|
|
37
36
|
*
|
|
38
|
-
* The request is served by `CanvasComments`, so it works before the records have arrived: the
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* means nothing happens if `CanvasComments` isn't mounted.
|
|
37
|
+
* The request is served by `CanvasComments`, so it works before the records have arrived: the layer
|
|
38
|
+
* waits for them, switches pages, unhides pins, and zooms far enough to split the thread out of any
|
|
39
|
+
* cluster. That also means nothing happens if `CanvasComments` isn't mounted.
|
|
42
40
|
*
|
|
43
41
|
* To open a thread you already hold and skip the wait, see {@link focusThread}.
|
|
44
42
|
*
|
|
@@ -55,12 +53,10 @@ export function revealThread(editor: Editor, threadOrCommentId: string): void {
|
|
|
55
53
|
|
|
56
54
|
/**
|
|
57
55
|
* The id passed to the most recent {@link revealThread} call that `CanvasComments` hasn't served
|
|
58
|
-
* yet, or null
|
|
59
|
-
* unmounts, since nothing is left to serve it.
|
|
56
|
+
* yet, or null. A request also clears when `CanvasComments` unmounts.
|
|
60
57
|
*
|
|
61
|
-
* This is a plain, untracked read
|
|
62
|
-
*
|
|
63
|
-
* render it closed over.
|
|
58
|
+
* This is a plain, untracked read — in React, use {@link useRevealThreadPending}, unless you need
|
|
59
|
+
* the value as of *now* rather than as of the render you closed over.
|
|
64
60
|
*
|
|
65
61
|
* @public
|
|
66
62
|
*/
|
|
@@ -71,11 +67,9 @@ export function getRevealThreadPending(editor: Editor): string | null {
|
|
|
71
67
|
/**
|
|
72
68
|
* Reactive React hook for {@link getRevealThreadPending}.
|
|
73
69
|
*
|
|
74
|
-
* Use it to notice a reveal that never lands —
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* {@link getRevealThreadPending} when the grace period elapses, since the request can clear inside
|
|
78
|
-
* it without this hook's value having caught up yet.
|
|
70
|
+
* Use it to notice a reveal that never lands — usually a deep link to a deleted comment. Give it a
|
|
71
|
+
* grace period first, since a request also sits here while its records sync in, and re-check with
|
|
72
|
+
* {@link getRevealThreadPending} when it elapses.
|
|
79
73
|
*
|
|
80
74
|
* @public
|
|
81
75
|
*/
|
|
@@ -96,10 +90,8 @@ export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () =>
|
|
|
96
90
|
export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
|
|
97
91
|
|
|
98
92
|
/**
|
|
99
|
-
* Whether the comments sidebar (the thread list) is open. Driven by an explicit control
|
|
100
|
-
*
|
|
101
|
-
* from placing them. The comment tool additionally closes it on enter, keeping placement
|
|
102
|
-
* canvas-focused.
|
|
93
|
+
* Whether the comments sidebar (the thread list) is open. Driven by an explicit control rather than
|
|
94
|
+
* by which tool is active, so browsing threads is separate from placing them.
|
|
103
95
|
* @public
|
|
104
96
|
*/
|
|
105
97
|
export const commentsSidebarOpen = new EditorAtom<boolean>('commentsSidebarOpen', () => false)
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { Avatar } from '@tldraw/mentions'
|
|
2
|
+
import {
|
|
3
|
+
memo,
|
|
4
|
+
type PointerEvent as ReactPointerEvent,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import {
|
|
11
|
+
type BoxModel,
|
|
12
|
+
Editor,
|
|
13
|
+
TLCommentThread,
|
|
14
|
+
useContainer,
|
|
15
|
+
usePassThroughWheelEvents,
|
|
16
|
+
useTranslation,
|
|
17
|
+
useValue,
|
|
18
|
+
} from 'tldraw'
|
|
19
|
+
import { CommentPin } from '../ui/comment-pin'
|
|
20
|
+
import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
|
|
21
|
+
import { commitCommentMutation } from './comment-mutations'
|
|
22
|
+
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
|
23
|
+
import { type CommentingContext } from './context'
|
|
24
|
+
import { useThreadComments } from './hooks'
|
|
25
|
+
import { getCommentingOptions, useCanComment, useCommentingOptions } from './options'
|
|
26
|
+
import {
|
|
27
|
+
REGION_CORNERS,
|
|
28
|
+
REGION_HANDLE_MARGIN_PX,
|
|
29
|
+
RegionBox,
|
|
30
|
+
RegionResizeHandles,
|
|
31
|
+
} from './region-box'
|
|
32
|
+
import { openThreadId } from './state'
|
|
33
|
+
import { ThreadPreview, useMarkerPreview } from './thread-preview'
|
|
34
|
+
import {
|
|
35
|
+
anchorPagePoint,
|
|
36
|
+
commentTargetShapeAt,
|
|
37
|
+
impreciseShapePinInset,
|
|
38
|
+
isBoxInInflatedViewport,
|
|
39
|
+
isInInflatedViewport,
|
|
40
|
+
REGION_PIN_CORNER,
|
|
41
|
+
regionAnchorPinCorner,
|
|
42
|
+
regionPinPoint,
|
|
43
|
+
shapeAnchorAt,
|
|
44
|
+
} from './thread-state'
|
|
45
|
+
import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
|
|
46
|
+
|
|
47
|
+
/** The opened popover has a header row the hover preview lacks, so it opens this much higher — the
|
|
48
|
+
* first comment then lands where the preview's sat. Re-measure if the header height or the preview
|
|
49
|
+
* panel padding changes. */
|
|
50
|
+
const THREAD_HEADER_BLOCK = 36
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A single thread's pin: the marker at its anchor, the thread popover when it's open, the hover
|
|
54
|
+
* preview, and — for a region anchor — the dashed box and its resize handles. Dragging the marker
|
|
55
|
+
* re-anchors the thread.
|
|
56
|
+
*
|
|
57
|
+
* Memoized so a pin skips re-rendering when the layer re-renders for unrelated reasons; camera
|
|
58
|
+
* tracking still works, since the pin subscribes to its own viewport position via signals.
|
|
59
|
+
*/
|
|
60
|
+
export const ThreadPin = memo(function ThreadPin({
|
|
61
|
+
editor,
|
|
62
|
+
thread,
|
|
63
|
+
...props
|
|
64
|
+
}: CommentingContext & {
|
|
65
|
+
editor: Editor
|
|
66
|
+
thread: TLCommentThread
|
|
67
|
+
}) {
|
|
68
|
+
const { resolveAuthor } = props
|
|
69
|
+
const options = useCommentingOptions()
|
|
70
|
+
const canComment = useCanComment(props.currentUserId)
|
|
71
|
+
const container = useContainer()
|
|
72
|
+
const msg = useTranslation()
|
|
73
|
+
const comments = useThreadComments(editor, thread.id)
|
|
74
|
+
// Only one thread's popover is open at a time — shared across pins via the atom.
|
|
75
|
+
const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
|
|
76
|
+
editor,
|
|
77
|
+
thread.id,
|
|
78
|
+
])
|
|
79
|
+
// While dragging the marker, its page point overrides the anchor's; committed on drop.
|
|
80
|
+
const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
|
|
81
|
+
const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
|
|
82
|
+
// Hovering the marker previews the thread's opening comment, on the delay every marker uses.
|
|
83
|
+
const { previewShown, previewHandlers } = useMarkerPreview(editor, `pin:${thread.id}`)
|
|
84
|
+
const previewThreads = useMemo(() => [thread], [thread])
|
|
85
|
+
// The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
|
|
86
|
+
// Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
|
|
87
|
+
// handle never loses the affordance — the box stays `pointer-events: none`.
|
|
88
|
+
const pointerInRegion = useValue(
|
|
89
|
+
'pointer in region',
|
|
90
|
+
() => {
|
|
91
|
+
if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
|
|
92
|
+
return false
|
|
93
|
+
const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
|
|
94
|
+
const p = editor.inputs.getCurrentPagePoint()
|
|
95
|
+
const a = thread.anchor
|
|
96
|
+
return p.x >= a.x - m && p.x <= a.x + a.w + m && p.y >= a.y - m && p.y <= a.y + a.h + m
|
|
97
|
+
},
|
|
98
|
+
[editor, thread.anchor, thread.pageId]
|
|
99
|
+
)
|
|
100
|
+
// A region's box and handles are revealed while open, mid-resize, or while the pointer is
|
|
101
|
+
// within the region.
|
|
102
|
+
const revealed = open || resizeBounds != null || pointerInRegion
|
|
103
|
+
// A region thread's pin corner is its own (the corner its creating drag released on), with
|
|
104
|
+
// the default as the fallback for older records.
|
|
105
|
+
const pinCorner =
|
|
106
|
+
thread.anchor.type === 'region' ? regionAnchorPinCorner(thread.anchor) : REGION_PIN_CORNER
|
|
107
|
+
// A region resizes from its corners — every corner but the pin's own, which the pin owns.
|
|
108
|
+
const resizeHandles = useMemo(
|
|
109
|
+
() => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
|
|
110
|
+
[pinCorner]
|
|
111
|
+
)
|
|
112
|
+
const dragRef = useRef<{
|
|
113
|
+
startX: number
|
|
114
|
+
startY: number
|
|
115
|
+
moved: boolean
|
|
116
|
+
// The anchor's page-space offset from the grab point, so a drag translates the pin by the
|
|
117
|
+
// cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
|
|
118
|
+
offsetX: number
|
|
119
|
+
offsetY: number
|
|
120
|
+
} | null>(null)
|
|
121
|
+
const markerRef = useRef<HTMLButtonElement>(null)
|
|
122
|
+
// Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
|
|
123
|
+
// see the note on the layer.
|
|
124
|
+
usePassThroughWheelEvents(markerRef)
|
|
125
|
+
|
|
126
|
+
// The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
|
|
127
|
+
// mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
|
|
128
|
+
// clear the hint here or it stays on the shape indefinitely.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
return () => {
|
|
131
|
+
if (dragRef.current) editor.setHintingShapes([])
|
|
132
|
+
}
|
|
133
|
+
}, [editor])
|
|
134
|
+
|
|
135
|
+
// Clicking outside the open popover closes the thread. Capture phase + a class check rather than
|
|
136
|
+
// stopPropagation, since the popover portals elsewhere in the DOM. The marker is excluded so its own
|
|
137
|
+
// click-to-toggle handles it instead of this closing and the toggle reopening.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!open) return
|
|
140
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
141
|
+
const target = e.target as HTMLElement | null
|
|
142
|
+
if (!target) return
|
|
143
|
+
if (target.closest('.tlui-cmt-canvas-popover')) return
|
|
144
|
+
const marker = markerRef.current
|
|
145
|
+
if (marker && marker.contains(target)) return
|
|
146
|
+
// A press on a region's resize handle edits this thread — don't dismiss it.
|
|
147
|
+
if (target.closest('.tlui-cmt-canvas-region-handle')) return
|
|
148
|
+
// A click inside a menu/popover layered above us (the sidebar's filter or overflow
|
|
149
|
+
// dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
|
|
150
|
+
// layer; defer to its own dismissal instead of closing the thread out from under it.
|
|
151
|
+
if (
|
|
152
|
+
target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
openThreadId.set(editor, null)
|
|
156
|
+
}
|
|
157
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
158
|
+
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
|
159
|
+
}, [open, editor])
|
|
160
|
+
|
|
161
|
+
// The pin must not unmount mid-interaction: an open thread's popover hangs off it, and a drag
|
|
162
|
+
// or resize holds pointer capture on it — so those states are exempt from the viewport cull.
|
|
163
|
+
const exemptFromCull = open || dragPagePoint != null || resizeBounds != null
|
|
164
|
+
const point = useValue(
|
|
165
|
+
'pin point',
|
|
166
|
+
() => {
|
|
167
|
+
if (thread.pageId !== editor.getCurrentPageId()) return null
|
|
168
|
+
const pagePoint = anchorPagePoint(editor, thread.anchor)
|
|
169
|
+
if (!pagePoint) return null
|
|
170
|
+
const viewportPoint = editor.pageToViewport(pagePoint)
|
|
171
|
+
const inset = impreciseShapePinInset(editor, thread.anchor)
|
|
172
|
+
const point = inset
|
|
173
|
+
? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y }
|
|
174
|
+
: viewportPoint
|
|
175
|
+
// Off-screen pins (plus a pre-mount margin) unmount rather than re-render every camera frame. A
|
|
176
|
+
// region thread stays mounted while any part of its box is on screen, since the box renders here too.
|
|
177
|
+
if (!exemptFromCull) {
|
|
178
|
+
const visible =
|
|
179
|
+
thread.anchor.type === 'region'
|
|
180
|
+
? isBoxInInflatedViewport(editor, thread.anchor)
|
|
181
|
+
: isInInflatedViewport(editor, point)
|
|
182
|
+
if (!visible) return null
|
|
183
|
+
}
|
|
184
|
+
return point
|
|
185
|
+
},
|
|
186
|
+
[editor, thread.anchor, thread.pageId, exemptFromCull]
|
|
187
|
+
)
|
|
188
|
+
if (!point) return null
|
|
189
|
+
|
|
190
|
+
const PinContent = options.components.PinContent
|
|
191
|
+
const threadAuthor = resolveAuthor(thread.createdBy)
|
|
192
|
+
const pinContent = PinContent ? (
|
|
193
|
+
<PinContent thread={thread} comments={comments} />
|
|
194
|
+
) : (
|
|
195
|
+
<Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
|
|
196
|
+
)
|
|
197
|
+
const pinLabel = msg(
|
|
198
|
+
thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
|
|
199
|
+
).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
|
|
200
|
+
|
|
201
|
+
// Drag the marker to move the thread: position is overridden locally while dragging, then re-anchored
|
|
202
|
+
// on drop. A region translates keeping its size; a barely-moved pointer is a click.
|
|
203
|
+
const isRegion = thread.anchor.type === 'region'
|
|
204
|
+
// The marker is a button (so it's keyboard-reachable), so the drag handlers are typed to it.
|
|
205
|
+
const startDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
206
|
+
// A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
|
|
207
|
+
// hand it to the canvas untouched.
|
|
208
|
+
if (isCanvasPanGesture(editor, e)) {
|
|
209
|
+
forwardPointerEventToCanvas(container, e)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
e.stopPropagation()
|
|
213
|
+
const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
214
|
+
const anchorPage = anchorPagePoint(editor, thread.anchor)
|
|
215
|
+
// The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
|
|
216
|
+
// is inset from its anchor point — without this the pin jumps by the inset on drag start.
|
|
217
|
+
const inset = impreciseShapePinInset(editor, thread.anchor)
|
|
218
|
+
if (anchorPage && inset) {
|
|
219
|
+
const zoom = editor.getZoomLevel()
|
|
220
|
+
anchorPage.x += inset.x / zoom
|
|
221
|
+
anchorPage.y += inset.y / zoom
|
|
222
|
+
}
|
|
223
|
+
dragRef.current = {
|
|
224
|
+
startX: e.clientX,
|
|
225
|
+
startY: e.clientY,
|
|
226
|
+
moved: false,
|
|
227
|
+
offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
|
|
228
|
+
offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
|
|
229
|
+
}
|
|
230
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
231
|
+
}
|
|
232
|
+
const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
233
|
+
const drag = dragRef.current
|
|
234
|
+
if (!drag) return
|
|
235
|
+
// Moving a pin re-anchors the thread record — a commenting write. Without the permission the
|
|
236
|
+
// press stays a click (`moved` never sets, so release toggles the popover and never commits).
|
|
237
|
+
if (!canComment) return
|
|
238
|
+
if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
|
|
239
|
+
drag.moved = true
|
|
240
|
+
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
241
|
+
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
242
|
+
setDragPagePoint(pagePoint)
|
|
243
|
+
// Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
|
|
244
|
+
// with. Regions translate rather than re-anchor, so they never hint.
|
|
245
|
+
if (!isRegion) {
|
|
246
|
+
const hit = commentTargetShapeAt(editor, pagePoint)
|
|
247
|
+
editor.setHintingShapes(hit ? [hit.id] : [])
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
|
|
251
|
+
// no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
|
|
252
|
+
const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
253
|
+
const drag = dragRef.current
|
|
254
|
+
dragRef.current = null
|
|
255
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
256
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
257
|
+
}
|
|
258
|
+
if (!drag) return
|
|
259
|
+
setDragPagePoint(null)
|
|
260
|
+
editor.setHintingShapes([])
|
|
261
|
+
}
|
|
262
|
+
const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
263
|
+
const drag = dragRef.current
|
|
264
|
+
dragRef.current = null
|
|
265
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
266
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
267
|
+
}
|
|
268
|
+
if (!drag) return
|
|
269
|
+
editor.setHintingShapes([])
|
|
270
|
+
if (!drag.moved) {
|
|
271
|
+
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
275
|
+
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
276
|
+
setDragPagePoint(null)
|
|
277
|
+
let anchor: TLCommentThread['anchor']
|
|
278
|
+
if (thread.anchor.type === 'region') {
|
|
279
|
+
// Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
|
|
280
|
+
anchor = {
|
|
281
|
+
...thread.anchor,
|
|
282
|
+
x: pagePoint.x - pinCorner.x * thread.anchor.w,
|
|
283
|
+
y: pagePoint.y - pinCorner.y * thread.anchor.h,
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
const hit = commentTargetShapeAt(editor, pagePoint)
|
|
287
|
+
anchor = hit
|
|
288
|
+
? shapeAnchorAt(
|
|
289
|
+
editor,
|
|
290
|
+
hit.id,
|
|
291
|
+
pagePoint,
|
|
292
|
+
getCommentingOptions(editor).shouldBePrecise(editor, {
|
|
293
|
+
shapeId: hit.id,
|
|
294
|
+
point: pagePoint,
|
|
295
|
+
altKey: e.altKey,
|
|
296
|
+
})
|
|
297
|
+
)
|
|
298
|
+
: { type: 'point', x: pagePoint.x, y: pagePoint.y }
|
|
299
|
+
}
|
|
300
|
+
commitCommentMutation(editor, ({ put }) => put([{ ...thread, anchor }]), 'drag')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
|
|
304
|
+
// move to the drag point; otherwise it sits at the stored anchor's viewport point.
|
|
305
|
+
const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
|
|
306
|
+
const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
|
|
307
|
+
// A region's pin centres on its corner — overlapping the box — rather than hanging off it.
|
|
308
|
+
// The marker anchors bottom-left, so step half its 34px size left and down (screen px).
|
|
309
|
+
const renderPoint = isRegion
|
|
310
|
+
? { x: renderPointBase.x - 17, y: renderPointBase.y + 17 }
|
|
311
|
+
: renderPointBase
|
|
312
|
+
|
|
313
|
+
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
|
314
|
+
// corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
|
|
315
|
+
const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
|
|
316
|
+
const movedRegion =
|
|
317
|
+
regionAnchor && dragPagePoint
|
|
318
|
+
? {
|
|
319
|
+
...regionAnchor,
|
|
320
|
+
x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
|
|
321
|
+
y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
|
|
322
|
+
}
|
|
323
|
+
: regionAnchor
|
|
324
|
+
const regionBoxBounds = resizeBounds ?? movedRegion
|
|
325
|
+
const commitResize = (bounds: BoxModel) => {
|
|
326
|
+
setResizeBounds(null)
|
|
327
|
+
if (!canComment) return
|
|
328
|
+
// Same commit path as a pin drag, so the configured `dragHistory` governs both — going
|
|
329
|
+
// straight to `editor.run` here would make region resizes silently ignore the option.
|
|
330
|
+
commitCommentMutation(
|
|
331
|
+
editor,
|
|
332
|
+
// Spread the existing anchor first so the region's pin corner survives a resize.
|
|
333
|
+
({ put }) => put([{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
|
|
334
|
+
'drag'
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<>
|
|
340
|
+
{regionBoxBounds && (dragPagePoint || revealed) && (
|
|
341
|
+
<RegionBox editor={editor} box={regionBoxBounds} />
|
|
342
|
+
)}
|
|
343
|
+
{regionBoxBounds && revealed && !dragPagePoint && canComment && (
|
|
344
|
+
<RegionResizeHandles
|
|
345
|
+
editor={editor}
|
|
346
|
+
box={regionBoxBounds}
|
|
347
|
+
handles={resizeHandles}
|
|
348
|
+
onPreview={setResizeBounds}
|
|
349
|
+
onCommit={commitResize}
|
|
350
|
+
/>
|
|
351
|
+
)}
|
|
352
|
+
<div
|
|
353
|
+
className={[
|
|
354
|
+
'tlui-cmt-canvas-pin',
|
|
355
|
+
open && 'tlui-cmt-canvas-pin--open',
|
|
356
|
+
dragPagePoint && 'tlui-cmt-canvas-pin--dragging',
|
|
357
|
+
]
|
|
358
|
+
.filter(Boolean)
|
|
359
|
+
.join(' ')}
|
|
360
|
+
style={{ left: renderPoint.x, top: renderPoint.y }}
|
|
361
|
+
>
|
|
362
|
+
<button
|
|
363
|
+
ref={markerRef}
|
|
364
|
+
type="button"
|
|
365
|
+
className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
|
|
366
|
+
aria-label={pinLabel}
|
|
367
|
+
aria-expanded={open}
|
|
368
|
+
onPointerDown={startDrag}
|
|
369
|
+
onPointerMove={onDrag}
|
|
370
|
+
onPointerUp={endDrag}
|
|
371
|
+
onPointerCancel={cancelDrag}
|
|
372
|
+
// Pointer activation is already handled by endDrag (which distinguishes a click
|
|
373
|
+
// from a drag), so only take keyboard-synthesised clicks here — those report
|
|
374
|
+
// `detail === 0` — or the thread would toggle twice per mouse click.
|
|
375
|
+
onClick={(e) => {
|
|
376
|
+
if (e.detail !== 0) return
|
|
377
|
+
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
378
|
+
}}
|
|
379
|
+
onPointerEnter={previewHandlers.onPointerEnter}
|
|
380
|
+
onPointerLeave={previewHandlers.onPointerLeave}
|
|
381
|
+
// Focus stands in for hover, so tabbing to a marker gets the same preview.
|
|
382
|
+
onFocus={previewHandlers.onPointerEnter}
|
|
383
|
+
onBlur={previewHandlers.onPointerLeave}
|
|
384
|
+
>
|
|
385
|
+
<CommentPin resolved={thread.resolved != null} open={open}>
|
|
386
|
+
{pinContent}
|
|
387
|
+
</CommentPin>
|
|
388
|
+
</button>
|
|
389
|
+
{/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
|
|
390
|
+
the pin itself stays in the canvas-in-front layer, beneath the UI. */}
|
|
391
|
+
{open && (
|
|
392
|
+
<ThreadPopover
|
|
393
|
+
base={{
|
|
394
|
+
x: renderPoint.x + POPOVER_OFFSET.thread.x,
|
|
395
|
+
y: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
<ThreadView editor={editor} thread={thread} {...props} />
|
|
399
|
+
</ThreadPopover>
|
|
400
|
+
)}
|
|
401
|
+
{/* Not while dragging: the pin is being moved, not read, and a panel trailing the
|
|
402
|
+
cursor would obscure the drop target. */}
|
|
403
|
+
{previewShown && !dragPagePoint && (
|
|
404
|
+
<ThreadPreview
|
|
405
|
+
editor={editor}
|
|
406
|
+
threads={previewThreads}
|
|
407
|
+
variant="thread"
|
|
408
|
+
point={renderPoint}
|
|
409
|
+
onSelectThread={() => openThreadId.set(editor, thread.id)}
|
|
410
|
+
{...previewHandlers}
|
|
411
|
+
currentUserId={props.currentUserId}
|
|
412
|
+
resolveAuthor={resolveAuthor}
|
|
413
|
+
/>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
</>
|
|
417
|
+
)
|
|
418
|
+
})
|