@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
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { ReactNode, useCallback, useEffect, useRef, useState
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
1
|
+
import { memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
2
|
import {
|
|
4
3
|
createComment,
|
|
5
4
|
Editor,
|
|
5
|
+
EditorPortal,
|
|
6
6
|
TLComment,
|
|
7
7
|
TLCommentThread,
|
|
8
8
|
TLRichText,
|
|
9
|
+
TldrawUiButton,
|
|
9
10
|
TldrawUiDropdownMenuContent,
|
|
10
11
|
TldrawUiDropdownMenuGroup,
|
|
11
12
|
TldrawUiDropdownMenuItem,
|
|
@@ -13,15 +14,14 @@ import {
|
|
|
13
14
|
TldrawUiDropdownMenuTrigger,
|
|
14
15
|
TldrawUiIcon,
|
|
15
16
|
useContainer,
|
|
16
|
-
usePassThroughMouseOverEvents,
|
|
17
17
|
usePassThroughWheelEvents,
|
|
18
18
|
useTranslation,
|
|
19
|
+
useValue,
|
|
19
20
|
} from 'tldraw'
|
|
20
21
|
import { CommentCard, CommentCardProps } from '../ui/comment-card'
|
|
21
22
|
import { CommentComposer } from '../ui/comment-composer'
|
|
22
23
|
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
|
23
24
|
import { CommentThread } from '../ui/comment-thread'
|
|
24
|
-
import { TooltipButton } from '../ui/tooltip-button'
|
|
25
25
|
import { CommentBody } from './comment-body'
|
|
26
26
|
import {
|
|
27
27
|
clearCommentDraft,
|
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
deleteComment,
|
|
35
35
|
deleteThread,
|
|
36
36
|
editComment,
|
|
37
|
-
putRecordsInCommit,
|
|
38
37
|
reopenThread,
|
|
39
38
|
resolveThread,
|
|
40
39
|
} from './comment-mutations'
|
|
@@ -42,7 +41,14 @@ import { CommentReactionPicker, CommentReactions } from './comment-reactions'
|
|
|
42
41
|
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
|
43
42
|
import { type CommentingContext } from './context'
|
|
44
43
|
import { useThreadComments } from './hooks'
|
|
45
|
-
import {
|
|
44
|
+
import { useIsMobileCommenting, useMobilePlacement } from './mobile-placement'
|
|
45
|
+
import {
|
|
46
|
+
type CommentingComponents,
|
|
47
|
+
getCanModifyComment,
|
|
48
|
+
useCanComment,
|
|
49
|
+
useCanModifyComment,
|
|
50
|
+
useCommentingOptions,
|
|
51
|
+
} from './options'
|
|
46
52
|
import { openThreadId } from './state'
|
|
47
53
|
|
|
48
54
|
const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
|
|
@@ -55,6 +61,45 @@ export function useResolveName(resolveAuthor: CommentingContext['resolveAuthor']
|
|
|
55
61
|
return useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
/** How long the copy-link item reads "Link copied" before reverting. */
|
|
65
|
+
const LINK_COPIED_MS = 2000
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What a thread's `getThreadHref` should put on the clipboard. Hosts may hand back a relative href,
|
|
69
|
+
* which is meaningless once pasted elsewhere, so it's resolved against the current document first.
|
|
70
|
+
* An href the URL parser can't make sense of is copied as-is rather than dropped.
|
|
71
|
+
*
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
export function absoluteThreadLink(href: string, base = window.location.href): string {
|
|
75
|
+
try {
|
|
76
|
+
return new URL(href, base).toString()
|
|
77
|
+
} catch {
|
|
78
|
+
return href
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Copy a thread's link, with a flag that stays set briefly afterwards so the control can confirm.
|
|
84
|
+
* Only set once the write resolves, so a withheld clipboard doesn't claim a copy that didn't happen.
|
|
85
|
+
*/
|
|
86
|
+
function useCopyLink(href: string | undefined) {
|
|
87
|
+
const [copied, setCopied] = useState(false)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!copied) return
|
|
90
|
+
const timeout = window.setTimeout(() => setCopied(false), LINK_COPIED_MS)
|
|
91
|
+
return () => window.clearTimeout(timeout)
|
|
92
|
+
}, [copied])
|
|
93
|
+
const copy = useCallback(() => {
|
|
94
|
+
if (href === undefined) return
|
|
95
|
+
navigator.clipboard?.writeText(absoluteThreadLink(href)).then(
|
|
96
|
+
() => setCopied(true),
|
|
97
|
+
() => setCopied(false)
|
|
98
|
+
)
|
|
99
|
+
}, [href])
|
|
100
|
+
return [copied, copy] as const
|
|
101
|
+
}
|
|
102
|
+
|
|
58
103
|
export function toCardProps(
|
|
59
104
|
comment: TLComment,
|
|
60
105
|
props: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'>,
|
|
@@ -78,9 +123,8 @@ export function toCardProps(
|
|
|
78
123
|
}
|
|
79
124
|
}
|
|
80
125
|
|
|
81
|
-
/** A pin is this square (mirrors `--tlui-cmt-pin-size`).
|
|
82
|
-
*
|
|
83
|
-
* correcting for that. Keep in sync with the stylesheet. */
|
|
126
|
+
/** A pin is this square (mirrors `--tlui-cmt-pin-size`). The two marker kinds differ in size *and*
|
|
127
|
+
* anchor point, so lining their previews up means correcting for both. Keep in sync with the CSS. */
|
|
84
128
|
const PIN_SIZE = 28
|
|
85
129
|
|
|
86
130
|
/** A cluster/stack badge is this square (`--tlui-cmt-marker-size`) — pin-sized, so the marker
|
|
@@ -95,59 +139,59 @@ const CARD_TOP_Y = -28
|
|
|
95
139
|
const PREVIEW_GAP = 6
|
|
96
140
|
|
|
97
141
|
/**
|
|
98
|
-
* Where a marker's popover sits relative to the marker's anchor point.
|
|
99
|
-
*
|
|
100
|
-
* The hover preview places itself at these same origins, so the two views of a thread differ only
|
|
101
|
-
* by the header the popover has — which its own stylesheet then compensates for. Moving a popover
|
|
102
|
-
* here moves its preview with it.
|
|
142
|
+
* Where a marker's popover sits relative to the marker's anchor point. The hover preview uses the
|
|
143
|
+
* same origins, so moving a popover here moves its preview with it.
|
|
103
144
|
*
|
|
104
|
-
* Both marker kinds hang off their point the same way (`translate(0, -100%)`)
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* keeping the two previews' top cards on the same line.
|
|
145
|
+
* Both marker kinds hang off their point the same way (`translate(0, -100%)`), differing only in
|
|
146
|
+
* size — so each offset clears its own width plus the shared gap, and re-bases the shared card-top
|
|
147
|
+
* measure from its bottom anchor to its middle.
|
|
108
148
|
*/
|
|
109
149
|
export const POPOVER_OFFSET = {
|
|
110
150
|
thread: { x: PIN_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - PIN_SIZE / 2 },
|
|
111
151
|
list: { x: MARKER_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - MARKER_SIZE / 2 },
|
|
112
152
|
} as const
|
|
113
153
|
|
|
114
|
-
/** The open thread's popover container, portaled above the UI panels.
|
|
115
|
-
*
|
|
154
|
+
/** The open thread's popover container, portaled above the UI panels. A wheel over it passes
|
|
155
|
+
* through to the canvas (unless it scrolls its own content), like tldraw's panels. */
|
|
116
156
|
export function ThreadPopover({
|
|
117
|
-
|
|
118
|
-
style,
|
|
157
|
+
base,
|
|
119
158
|
children,
|
|
120
159
|
}: {
|
|
121
|
-
|
|
122
|
-
style: CSSProperties
|
|
160
|
+
base: { x: number; y: number }
|
|
123
161
|
children: ReactNode
|
|
124
162
|
}) {
|
|
125
163
|
const ref = useRef<HTMLDivElement>(null)
|
|
126
164
|
usePassThroughWheelEvents(ref)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
165
|
+
// On mobile the popover slides off its fixed offset to stay above the software keyboard and
|
|
166
|
+
// on-screen; desktop keeps the fixed offset (base returned unchanged).
|
|
167
|
+
const isMobile = useIsMobileCommenting()
|
|
168
|
+
const placed = useMobilePlacement(ref, base, isMobile)
|
|
169
|
+
return (
|
|
170
|
+
<EditorPortal>
|
|
171
|
+
{/* contextmenu also stops here: portals bubble React events to the canvas's context-menu
|
|
172
|
+
trigger (the layer mounts inside it), which would open the canvas menu over this panel. */}
|
|
173
|
+
<div
|
|
174
|
+
ref={ref}
|
|
175
|
+
className="tlui-cmt-canvas-popover"
|
|
176
|
+
style={{ left: placed.left, top: placed.top }}
|
|
177
|
+
onPointerDown={stop}
|
|
178
|
+
onContextMenu={stop}
|
|
179
|
+
>
|
|
180
|
+
{children}
|
|
181
|
+
</div>
|
|
182
|
+
</EditorPortal>
|
|
141
183
|
)
|
|
142
184
|
}
|
|
143
185
|
|
|
144
186
|
/**
|
|
145
187
|
* One thread's interactive view: its comments, the reply composer, edit-in-place on your own
|
|
146
|
-
* comments, and the resolve/delete actions.
|
|
147
|
-
*
|
|
148
|
-
*
|
|
188
|
+
* comments, and the resolve/delete actions. Read receipts are reported for every unread comment
|
|
189
|
+
* while mounted, so only mount it where the thread is actually being shown.
|
|
190
|
+
*
|
|
191
|
+
* Memoized because the popover position rides the pin's per-frame render point, so the pin
|
|
192
|
+
* re-renders on every camera frame while a thread is open.
|
|
149
193
|
*/
|
|
150
|
-
export function ThreadView({
|
|
194
|
+
export const ThreadView = memo(function ThreadView({
|
|
151
195
|
editor,
|
|
152
196
|
thread,
|
|
153
197
|
...props
|
|
@@ -157,19 +201,52 @@ export function ThreadView({
|
|
|
157
201
|
resolveAuthor,
|
|
158
202
|
onPostComment,
|
|
159
203
|
isCommentUnread,
|
|
160
|
-
|
|
204
|
+
onCommentsRead,
|
|
161
205
|
getMentionSuggestions,
|
|
162
206
|
renderMentionSuggestion,
|
|
207
|
+
getThreadHref,
|
|
163
208
|
} = props
|
|
164
209
|
const options = useCommentingOptions()
|
|
165
210
|
const comments = useThreadComments(editor, thread.id)
|
|
166
211
|
const msg = useTranslation()
|
|
167
212
|
const resolveName = useResolveName(resolveAuthor)
|
|
213
|
+
// Rebuilt only when the comments change, not on every render — each of which would otherwise
|
|
214
|
+
// re-allocate a date string and body element per comment.
|
|
215
|
+
const cards = useMemo(
|
|
216
|
+
() =>
|
|
217
|
+
comments.map((c) =>
|
|
218
|
+
toCardProps(c, { currentUserId, resolveAuthor }, options.components, resolveName)
|
|
219
|
+
),
|
|
220
|
+
[comments, currentUserId, resolveAuthor, options.components, resolveName]
|
|
221
|
+
)
|
|
168
222
|
const me = currentUserId ? resolveAuthor(currentUserId) : undefined
|
|
169
|
-
// Composing, editing, deleting, and resolving are all
|
|
170
|
-
//
|
|
171
|
-
// sign-in prompt, say) and the action affordances are hidden.
|
|
223
|
+
// Composing, editing, deleting, and resolving are all gated on the viewer's permission. Where it's
|
|
224
|
+
// withheld the composer gives way to the ComposerFallback slot and the affordances are hidden.
|
|
172
225
|
const canComment = useCanComment(currentUserId)
|
|
226
|
+
// Editing and deleting are further per-record: the author's to do by default, wider or narrower
|
|
227
|
+
// under a `canModifyComment` callback. Computed for the thread's comments in one pass, since
|
|
228
|
+
// `renderComment` is a callback rather than a component and can't call a hook per comment.
|
|
229
|
+
const commentPermissions = useValue(
|
|
230
|
+
'comment permissions',
|
|
231
|
+
() =>
|
|
232
|
+
new Map(
|
|
233
|
+
comments.map((comment) => [
|
|
234
|
+
comment.id,
|
|
235
|
+
{
|
|
236
|
+
edit: getCanModifyComment(editor, currentUserId, { action: 'edit-comment', comment }),
|
|
237
|
+
delete: getCanModifyComment(editor, currentUserId, {
|
|
238
|
+
action: 'delete-comment',
|
|
239
|
+
comment,
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
])
|
|
243
|
+
),
|
|
244
|
+
[editor, currentUserId, comments]
|
|
245
|
+
)
|
|
246
|
+
// Deleting a thread is its creator's by default (and server-enforced); `canModifyComment` is what
|
|
247
|
+
// widens that. Still a commenting write, so `canComment` stays the outer gate.
|
|
248
|
+
const canModifyThread = useCanModifyComment(currentUserId, { action: 'delete-thread', thread })
|
|
249
|
+
const canDeleteThread = canComment && canModifyThread
|
|
173
250
|
const ComposerFallback = options.components.ComposerFallback
|
|
174
251
|
// An unsent reply survives closing the thread (saved on every change, keyed by thread id) —
|
|
175
252
|
// the flip side of dismissing without a discard warning.
|
|
@@ -180,15 +257,12 @@ export function ThreadView({
|
|
|
180
257
|
const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
|
|
181
258
|
const canReply = canComment && !thread.resolved
|
|
182
259
|
const container = useContainer()
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// place, so the node captured on the way in is gone by the way out.
|
|
260
|
+
// Resolved when the composer closes rather than held as an element: the card unmounts while the
|
|
261
|
+
// composer stands in its place, so the node captured on the way in is gone by the way out.
|
|
186
262
|
const editReturnFocus = useRef<(() => HTMLElement | null) | null>(null)
|
|
187
263
|
|
|
188
|
-
// Tab from the canvas drops the caret in the reply box
|
|
189
|
-
//
|
|
190
|
-
// instead of the panel the click just opened. Capture phase, ahead of the editor's own handling.
|
|
191
|
-
// Fires once per open thread, so Tab inside the thread stays plain tab-through.
|
|
264
|
+
// Tab from the canvas drops the caret in the reply box: a pin click leaves focus on the editor
|
|
265
|
+
// container, so the first Tab would otherwise walk the app's UI. Capture phase, once per open thread.
|
|
192
266
|
const [focusReply, setFocusReply] = useState(false)
|
|
193
267
|
const tabTaken = useRef(false)
|
|
194
268
|
const swallowTabUp = useRef(false)
|
|
@@ -204,9 +278,8 @@ export function ThreadView({
|
|
|
204
278
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
205
279
|
if (e.key !== 'Tab' || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
206
280
|
if (e.defaultPrevented || tabTaken.current) return
|
|
207
|
-
// Focus rests on the container (or nothing
|
|
208
|
-
//
|
|
209
|
-
// and Tab belongs to whatever holds it.
|
|
281
|
+
// Focus rests on the container (or nothing) after a pin click. Anywhere else is deliberate, and
|
|
282
|
+
// Tab belongs to whatever holds it.
|
|
210
283
|
if (e.target !== container && e.target !== doc.body) return
|
|
211
284
|
tabTaken.current = true
|
|
212
285
|
swallowTabUp.current = true
|
|
@@ -214,9 +287,8 @@ export function ThreadView({
|
|
|
214
287
|
e.preventDefault()
|
|
215
288
|
e.stopPropagation()
|
|
216
289
|
}
|
|
217
|
-
// The select tool navigates shapes on Tab's *keyup*,
|
|
218
|
-
//
|
|
219
|
-
// selection. Swallow the release of the press we took, and only that one.
|
|
290
|
+
// The select tool navigates shapes on Tab's *keyup*, so a quick tap would both focus the reply and
|
|
291
|
+
// step the selection. Swallow the release of the press we took, and only that one.
|
|
220
292
|
const onKeyUp = (e: KeyboardEvent) => {
|
|
221
293
|
if (e.key !== 'Tab' || !swallowTabUp.current) return
|
|
222
294
|
swallowTabUp.current = false
|
|
@@ -231,12 +303,9 @@ export function ThreadView({
|
|
|
231
303
|
}
|
|
232
304
|
}, [canReply, container])
|
|
233
305
|
|
|
234
|
-
// Leaving the edit composer
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
// Tab-into-the-reply-box has usually been spent by then). Hand focus back to whatever opened the
|
|
238
|
-
// composer instead: the card's edit button, or the reply box when the edit came from arrow-up.
|
|
239
|
-
// The card's actions row reveals itself on `:focus-within`, so the button focus is visible.
|
|
306
|
+
// Leaving the edit composer unmounts it while it holds focus, dropping focus to the editor
|
|
307
|
+
// container — outside the thread, so a Tab from there walks the app's UI. Hand focus back to
|
|
308
|
+
// whatever opened the composer instead.
|
|
240
309
|
useEffect(() => {
|
|
241
310
|
if (editingId !== null) return
|
|
242
311
|
const resolve = editReturnFocus.current
|
|
@@ -245,32 +314,33 @@ export function ThreadView({
|
|
|
245
314
|
resolve?.()?.focus()
|
|
246
315
|
}, [editingId])
|
|
247
316
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// write flips isCommentUnread to false, so re-runs find nothing to report.
|
|
317
|
+
// Reported as one batch so the host can record the receipts in a single write. The write flips
|
|
318
|
+
// isCommentUnread to false, so re-runs find nothing to report.
|
|
251
319
|
useEffect(() => {
|
|
252
|
-
if (!isCommentUnread || !
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
320
|
+
if (!isCommentUnread || !onCommentsRead) return
|
|
321
|
+
const unreadIds = comments.filter((comment) => isCommentUnread(comment.id)).map((c) => c.id)
|
|
322
|
+
if (unreadIds.length > 0) {
|
|
323
|
+
onCommentsRead(unreadIds)
|
|
257
324
|
}
|
|
258
|
-
}, [comments, isCommentUnread,
|
|
325
|
+
}, [comments, isCommentUnread, onCommentsRead])
|
|
259
326
|
|
|
260
327
|
const postReply = () => {
|
|
261
328
|
if (isCommentEmpty(reply) || !currentUserId) return
|
|
262
|
-
commitCommentMutation(editor, () => {
|
|
329
|
+
const comment = commitCommentMutation(editor, ({ put }) => {
|
|
263
330
|
const comment = createComment({
|
|
264
331
|
threadId: thread.id,
|
|
265
332
|
pageId: thread.pageId,
|
|
266
333
|
authorId: currentUserId,
|
|
267
334
|
body: reply,
|
|
268
335
|
})
|
|
269
|
-
|
|
270
|
-
|
|
336
|
+
put([comment])
|
|
337
|
+
return comment
|
|
271
338
|
})
|
|
272
339
|
setReply(EMPTY_COMMENT)
|
|
273
340
|
clearCommentDraft(replyDraftSlot(thread.id))
|
|
341
|
+
// The host's callback is its own operation, not part of the post's history scope. It runs
|
|
342
|
+
// last so a throwing host can't strand the composer holding a draft of a posted reply.
|
|
343
|
+
onPostComment?.(comment)
|
|
274
344
|
}
|
|
275
345
|
|
|
276
346
|
const toggleResolve = () => {
|
|
@@ -279,17 +349,21 @@ export function ThreadView({
|
|
|
279
349
|
else resolveThread(editor, thread, currentUserId)
|
|
280
350
|
}
|
|
281
351
|
|
|
352
|
+
// No `currentUserId` check: unlike a resolve, a delete stamps nothing on the record, so who may
|
|
353
|
+
// make it is `canModifyComment`'s call alone (which withholds it from an unidentified viewer by
|
|
354
|
+
// default).
|
|
282
355
|
const removeThread = () => {
|
|
283
|
-
if (!currentUserId) return
|
|
284
356
|
deleteThread(editor, thread)
|
|
285
357
|
}
|
|
286
358
|
|
|
359
|
+
const ThreadActions = options.components.ThreadActions
|
|
360
|
+
// The host's URL for this thread. Its presence is what puts "copy link" in the header menu.
|
|
361
|
+
const threadHref = getThreadHref?.(thread.id)
|
|
362
|
+
const [linkCopied, copyThreadLink] = useCopyLink(threadHref)
|
|
363
|
+
|
|
287
364
|
const startEdit = (comment: TLComment, { fromMoreMenu = false } = {}) => {
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// composer stands in their place. Otherwise come back to whatever held focus: arrow-up-to-edit
|
|
291
|
-
// comes straight from the reply box, which stays put. The document body and the editor
|
|
292
|
-
// container are where focus falls when nothing holds it, so neither is somewhere to return to.
|
|
365
|
+
// Edit from the ⋯ menu comes back to that button, looked up again on the way out since the card
|
|
366
|
+
// unmounts. Otherwise return to whatever held focus, ignoring the body and the editor container.
|
|
293
367
|
const active = container.ownerDocument.activeElement
|
|
294
368
|
editReturnFocus.current = fromMoreMenu
|
|
295
369
|
? () => container.querySelector<HTMLElement>(`[data-cmt-more-for="${comment.id}"]`)
|
|
@@ -308,9 +382,10 @@ export function ThreadView({
|
|
|
308
382
|
}
|
|
309
383
|
|
|
310
384
|
// Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
|
|
311
|
-
// with
|
|
385
|
+
// with the affordances the viewer is allowed on it.
|
|
312
386
|
const renderComment = (card: CommentCardProps, index: number): ReactNode => {
|
|
313
387
|
const comment = comments[index]
|
|
388
|
+
const permissions = commentPermissions.get(comment.id)
|
|
314
389
|
if (editingId === comment.id) {
|
|
315
390
|
return (
|
|
316
391
|
<div
|
|
@@ -352,12 +427,13 @@ export function ThreadView({
|
|
|
352
427
|
actions={
|
|
353
428
|
canComment && (
|
|
354
429
|
<>
|
|
355
|
-
|
|
356
|
-
{comment.authorId === currentUserId && (
|
|
430
|
+
{(permissions?.edit || permissions?.delete) && (
|
|
357
431
|
<TldrawUiDropdownMenuRoot id={`comment-actions-${comment.id}`}>
|
|
358
432
|
<TldrawUiDropdownMenuTrigger>
|
|
359
|
-
<
|
|
433
|
+
<TldrawUiButton
|
|
434
|
+
type="icon"
|
|
360
435
|
tooltip={msg('comments.more-options')}
|
|
436
|
+
title={msg('comments.more-options')}
|
|
361
437
|
className="tlui-cmt-thread__action"
|
|
362
438
|
data-cmt-more-for={comment.id}
|
|
363
439
|
>
|
|
@@ -366,7 +442,7 @@ export function ThreadView({
|
|
|
366
442
|
label={msg('comments.more-options')}
|
|
367
443
|
small
|
|
368
444
|
/>
|
|
369
|
-
</
|
|
445
|
+
</TldrawUiButton>
|
|
370
446
|
</TldrawUiDropdownMenuTrigger>
|
|
371
447
|
<TldrawUiDropdownMenuContent
|
|
372
448
|
className="tlui-cmt-menu"
|
|
@@ -376,28 +452,34 @@ export function ThreadView({
|
|
|
376
452
|
sideOffset={4}
|
|
377
453
|
>
|
|
378
454
|
<TldrawUiDropdownMenuGroup>
|
|
379
|
-
|
|
380
|
-
<
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
455
|
+
{permissions?.edit && (
|
|
456
|
+
<TldrawUiDropdownMenuItem>
|
|
457
|
+
<button
|
|
458
|
+
type="button"
|
|
459
|
+
className="tlui-cmt-menu-item"
|
|
460
|
+
onClick={() => startEdit(comment, { fromMoreMenu: true })}
|
|
461
|
+
>
|
|
462
|
+
<span>{msg('comments.edit')}</span>
|
|
463
|
+
</button>
|
|
464
|
+
</TldrawUiDropdownMenuItem>
|
|
465
|
+
)}
|
|
466
|
+
{permissions?.delete && (
|
|
467
|
+
<TldrawUiDropdownMenuItem>
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
|
|
471
|
+
onClick={() => deleteComment(editor, comment)}
|
|
472
|
+
>
|
|
473
|
+
<span>{msg('action.delete')}</span>
|
|
474
|
+
</button>
|
|
475
|
+
</TldrawUiDropdownMenuItem>
|
|
476
|
+
)}
|
|
397
477
|
</TldrawUiDropdownMenuGroup>
|
|
398
478
|
</TldrawUiDropdownMenuContent>
|
|
399
479
|
</TldrawUiDropdownMenuRoot>
|
|
400
480
|
)}
|
|
481
|
+
{/* Last so react is always the rightmost pill. */}
|
|
482
|
+
<CommentReactionPicker comment={comment} currentUserId={currentUserId} />
|
|
401
483
|
</>
|
|
402
484
|
)
|
|
403
485
|
}
|
|
@@ -407,18 +489,22 @@ export function ThreadView({
|
|
|
407
489
|
|
|
408
490
|
// Resolve and delete are commenting writes: behind `canComment`, plus the `currentUserId` a
|
|
409
491
|
// resolve stamps into `resolved.by`.
|
|
492
|
+
const resolveLabel = msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')
|
|
410
493
|
const headerActions = (
|
|
411
494
|
<>
|
|
412
|
-
{/*
|
|
413
|
-
{
|
|
495
|
+
{/* Host verbs — assign, link a ticket — sit ahead of the built-in actions. */}
|
|
496
|
+
{ThreadActions && <ThreadActions thread={thread} comments={comments} />}
|
|
497
|
+
{(threadHref !== undefined || canDeleteThread) && (
|
|
414
498
|
<TldrawUiDropdownMenuRoot id={`comment-thread-actions-${thread.id}`}>
|
|
415
499
|
<TldrawUiDropdownMenuTrigger>
|
|
416
|
-
<
|
|
500
|
+
<TldrawUiButton
|
|
501
|
+
type="icon"
|
|
417
502
|
tooltip={msg('comments.more-options')}
|
|
503
|
+
title={msg('comments.more-options')}
|
|
418
504
|
className="tlui-cmt-thread__action"
|
|
419
505
|
>
|
|
420
506
|
<TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
|
|
421
|
-
</
|
|
507
|
+
</TldrawUiButton>
|
|
422
508
|
</TldrawUiDropdownMenuTrigger>
|
|
423
509
|
<TldrawUiDropdownMenuContent
|
|
424
510
|
className="tlui-cmt-menu"
|
|
@@ -428,39 +514,51 @@ export function ThreadView({
|
|
|
428
514
|
sideOffset={4}
|
|
429
515
|
>
|
|
430
516
|
<TldrawUiDropdownMenuGroup>
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
517
|
+
{/* A link is a read affordance: offered to anyone who can see the thread,
|
|
518
|
+
including a viewer who can't comment. `noClose` keeps the menu up so the
|
|
519
|
+
item can confirm the copy in place. */}
|
|
520
|
+
{threadHref !== undefined && (
|
|
521
|
+
<TldrawUiDropdownMenuItem noClose>
|
|
522
|
+
<button type="button" className="tlui-cmt-menu-item" onClick={copyThreadLink}>
|
|
523
|
+
<span>{msg(linkCopied ? 'comments.link-copied' : 'comments.copy-link')}</span>
|
|
524
|
+
</button>
|
|
525
|
+
</TldrawUiDropdownMenuItem>
|
|
526
|
+
)}
|
|
527
|
+
{canDeleteThread && (
|
|
528
|
+
<TldrawUiDropdownMenuItem>
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
|
|
532
|
+
onClick={removeThread}
|
|
533
|
+
>
|
|
534
|
+
<span>{msg('comments.delete')}</span>
|
|
535
|
+
</button>
|
|
536
|
+
</TldrawUiDropdownMenuItem>
|
|
537
|
+
)}
|
|
440
538
|
</TldrawUiDropdownMenuGroup>
|
|
441
539
|
</TldrawUiDropdownMenuContent>
|
|
442
540
|
</TldrawUiDropdownMenuRoot>
|
|
443
541
|
)}
|
|
444
542
|
{canComment && currentUserId && (
|
|
445
|
-
<
|
|
446
|
-
|
|
543
|
+
<TldrawUiButton
|
|
544
|
+
type="icon"
|
|
545
|
+
tooltip={resolveLabel}
|
|
546
|
+
title={resolveLabel}
|
|
447
547
|
className="tlui-cmt-thread__action"
|
|
448
548
|
onClick={toggleResolve}
|
|
449
549
|
>
|
|
450
|
-
<TldrawUiIcon
|
|
451
|
-
|
|
452
|
-
label={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
|
|
453
|
-
small
|
|
454
|
-
/>
|
|
455
|
-
</TooltipButton>
|
|
550
|
+
<TldrawUiIcon icon="check" label={resolveLabel} small />
|
|
551
|
+
</TldrawUiButton>
|
|
456
552
|
)}
|
|
457
|
-
<
|
|
553
|
+
<TldrawUiButton
|
|
554
|
+
type="icon"
|
|
458
555
|
tooltip={msg('comments.dismiss')}
|
|
556
|
+
title={msg('comments.dismiss')}
|
|
459
557
|
className="tlui-cmt-thread__action"
|
|
460
558
|
onClick={() => openThreadId.set(editor, null)}
|
|
461
559
|
>
|
|
462
560
|
<TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
|
|
463
|
-
</
|
|
561
|
+
</TldrawUiButton>
|
|
464
562
|
</>
|
|
465
563
|
)
|
|
466
564
|
|
|
@@ -469,7 +567,7 @@ export function ThreadView({
|
|
|
469
567
|
header={msg('comments.thread-title')}
|
|
470
568
|
headerActions={headerActions}
|
|
471
569
|
renderComment={renderComment}
|
|
472
|
-
comments={
|
|
570
|
+
comments={cards}
|
|
473
571
|
resolvedBanner={
|
|
474
572
|
thread.resolved
|
|
475
573
|
? msg('comments.resolved-by').replace(
|
|
@@ -491,10 +589,10 @@ export function ThreadView({
|
|
|
491
589
|
},
|
|
492
590
|
onSubmit: postReply,
|
|
493
591
|
// Up in the empty reply box edits the comment directly above it, chat-style —
|
|
494
|
-
// only when
|
|
592
|
+
// only when you're allowed to edit it (the same gate as the Edit link).
|
|
495
593
|
onArrowUpWhenEmpty: () => {
|
|
496
594
|
const last = comments[comments.length - 1]
|
|
497
|
-
if (last && last.
|
|
595
|
+
if (last && commentPermissions.get(last.id)?.edit) startEdit(last)
|
|
498
596
|
},
|
|
499
597
|
// No user, no author for the record — dead send button.
|
|
500
598
|
disabled: isCommentEmpty(reply) || !currentUserId,
|
|
@@ -513,4 +611,4 @@ export function ThreadView({
|
|
|
513
611
|
}
|
|
514
612
|
/>
|
|
515
613
|
)
|
|
516
|
-
}
|
|
614
|
+
})
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { mstEdges } from './mst'
|
|
2
2
|
import { cappedReplay } from './replay'
|
|
3
3
|
import { contract, finalize } from './schedule'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ClusterNode,
|
|
6
|
+
ClusterOptions,
|
|
7
|
+
ClusterTable,
|
|
8
|
+
LeafInput,
|
|
9
|
+
LeafScreenOffsets,
|
|
10
|
+
} from './types'
|
|
5
11
|
|
|
6
12
|
interface ResolvedClusterOptions {
|
|
7
13
|
Tc: number
|
|
@@ -16,7 +22,10 @@ interface ResolvedClusterOptions {
|
|
|
16
22
|
/** @internal */
|
|
17
23
|
export function computeClusterTable(
|
|
18
24
|
leaves: readonly LeafInput[],
|
|
19
|
-
options: ClusterOptions
|
|
25
|
+
options: ClusterOptions,
|
|
26
|
+
// Render offsets for markers that draw off their anchor (imprecise pins), keyed by leaf id.
|
|
27
|
+
// Omitted or empty, the run is identical — output and code paths — to an offset-unaware one.
|
|
28
|
+
screenOffsets?: LeafScreenOffsets
|
|
20
29
|
): ClusterTable {
|
|
21
30
|
const opts = resolveOptions(options)
|
|
22
31
|
const leafNodes = leaves.map(leafToNode)
|
|
@@ -26,7 +35,7 @@ export function computeClusterTable(
|
|
|
26
35
|
return { events: [], leaves: leafNodes }
|
|
27
36
|
}
|
|
28
37
|
|
|
29
|
-
const raw = cappedReplay(leaves, edges, { Tc: opts.Tc, Dmax: opts.Dmax })
|
|
38
|
+
const raw = cappedReplay(leaves, edges, { Tc: opts.Tc, Dmax: opts.Dmax }, screenOffsets)
|
|
30
39
|
const contracted = contract(raw, opts.eps)
|
|
31
40
|
const events = finalize(contracted, {
|
|
32
41
|
Tc: opts.Tc,
|