@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,8 +1,8 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, type CSSProperties } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
2
|
import {
|
|
4
3
|
Editor,
|
|
5
4
|
EditorAtom,
|
|
5
|
+
EditorPortal,
|
|
6
6
|
TLComment,
|
|
7
7
|
TLCommentThread,
|
|
8
8
|
usePassThroughWheelEvents,
|
|
@@ -19,26 +19,21 @@ import { POPOVER_OFFSET, toCardProps, useResolveName } from './thread-view'
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Hover previews for every canvas marker — a single pin, a coincident stack, or a cluster badge.
|
|
22
|
-
* Hovering shows the thread(s) behind the marker as cards; the marker's own click
|
|
23
|
-
* it already did (open the thread, open the stack list, zoom to the cluster's split).
|
|
22
|
+
* Hovering shows the thread(s) behind the marker as cards; the marker's own click is unchanged.
|
|
24
23
|
*
|
|
25
|
-
* The panel is live, not a passive tooltip: the pointer can travel
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* carries an invisible bridge over that gap (see `.tlui-cmt-canvas-preview::before`) so the
|
|
29
|
-
* journey never crosses dead space and retracts the panel mid-move.
|
|
24
|
+
* The panel is live, not a passive tooltip: the pointer can travel into it and click a card to open
|
|
25
|
+
* that thread. Two pieces make that work — the close delay survives the trip across the gap, and
|
|
26
|
+
* the panel carries an invisible bridge over it (see `.tlui-cmt-canvas-preview::before`).
|
|
30
27
|
*
|
|
31
|
-
* Opening a thread from a card needs nothing more than setting `openThreadId`,
|
|
32
|
-
*
|
|
33
|
-
* it drops out of its badge and renders its own pin and popover.
|
|
28
|
+
* Opening a thread from a card needs nothing more than setting `openThreadId`, even for one folded
|
|
29
|
+
* inside a badge: `collectClusterLeaves` skips the open thread, so it drops out and renders itself.
|
|
34
30
|
*/
|
|
35
31
|
|
|
36
32
|
/** How long the pointer must rest on a marker before its preview appears. */
|
|
37
33
|
const PREVIEW_OPEN_DELAY_MS = 180
|
|
38
34
|
/**
|
|
39
|
-
* Grace period after the pointer leaves the marker *or* the panel
|
|
40
|
-
* between them
|
|
41
|
-
* moment of transit between the two elements' enter/leave events.
|
|
35
|
+
* Grace period after the pointer leaves the marker *or* the panel — long enough to cross the gap
|
|
36
|
+
* between them, which the bridge element covers geometrically.
|
|
42
37
|
*/
|
|
43
38
|
const PREVIEW_CLOSE_DELAY_MS = 220
|
|
44
39
|
/** Cards shown before the panel falls back to a "+N more" line. */
|
|
@@ -53,9 +48,8 @@ export interface ThreadPreviewCard {
|
|
|
53
48
|
/**
|
|
54
49
|
* The cards a marker's preview will show, and how many threads it will summarise as "+N more".
|
|
55
50
|
*
|
|
56
|
-
* A thread can exist before its opening comment does — a collaborator's, mid-sync
|
|
57
|
-
*
|
|
58
|
-
* don't count toward the overflow tally either, so "+2 more" always means two readable threads.
|
|
51
|
+
* A thread can exist before its opening comment does — a collaborator's, mid-sync. Those are
|
|
52
|
+
* dropped rather than rendered blank, and don't count toward the overflow tally.
|
|
59
53
|
*/
|
|
60
54
|
export function selectPreviewCards(
|
|
61
55
|
threads: readonly TLCommentThread[],
|
|
@@ -81,17 +75,15 @@ export type ThreadPreviewVariant = 'thread' | 'list'
|
|
|
81
75
|
|
|
82
76
|
/**
|
|
83
77
|
* Which marker's preview is showing, or null. One atom for the whole layer, so previews are
|
|
84
|
-
* mutually exclusive
|
|
85
|
-
*
|
|
78
|
+
* mutually exclusive: the close delay can leave an outgoing marker's timer pending when the next
|
|
79
|
+
* opens, and per-component state would briefly show both.
|
|
86
80
|
*/
|
|
87
81
|
const hoveredMarkerId = new EditorAtom<string | null>('commentHoveredMarkerId', () => null)
|
|
88
82
|
|
|
89
83
|
/**
|
|
90
84
|
* Hover state for one marker. Returns whether its preview should render, plus the pointer handlers
|
|
91
|
-
* to spread onto the marker element.
|
|
92
|
-
*
|
|
93
|
-
* `markerId` must be stable and unique per marker across the layer — prefix by kind, since a stack
|
|
94
|
-
* is keyed by its oldest member's thread id and would otherwise collide with that thread's own pin.
|
|
85
|
+
* to spread onto the marker element. `markerId` must be stable and unique per marker across the
|
|
86
|
+
* layer — prefix by kind, or a stack collides with its oldest member's own pin.
|
|
95
87
|
*/
|
|
96
88
|
export function useMarkerPreview(editor: Editor, markerId: string) {
|
|
97
89
|
const openTimer = useRef(0)
|
|
@@ -154,14 +146,12 @@ export function useMarkerPreview(editor: Editor, markerId: string) {
|
|
|
154
146
|
}
|
|
155
147
|
|
|
156
148
|
/**
|
|
157
|
-
* The hover panel: each thread's opening comment as a read-only card, capped with a "+N more"
|
|
158
|
-
*
|
|
159
|
-
* costs nothing at rest.
|
|
149
|
+
* The hover panel: each thread's opening comment as a read-only card, capped with a "+N more" line.
|
|
150
|
+
* Mounted only while hovering, so its store subscription costs nothing at rest.
|
|
160
151
|
*/
|
|
161
152
|
export function ThreadPreview({
|
|
162
153
|
editor,
|
|
163
154
|
threads,
|
|
164
|
-
container,
|
|
165
155
|
variant,
|
|
166
156
|
point,
|
|
167
157
|
onSelectThread,
|
|
@@ -172,7 +162,6 @@ export function ThreadPreview({
|
|
|
172
162
|
editor: Editor
|
|
173
163
|
/** The marker's threads, in the order they should read (oldest first). */
|
|
174
164
|
threads: readonly TLCommentThread[]
|
|
175
|
-
container: HTMLElement
|
|
176
165
|
/** What the marker's click opens, which this panel imitates. */
|
|
177
166
|
variant: ThreadPreviewVariant
|
|
178
167
|
/** The marker's anchor point in viewport space — the same origin its popover is placed from. */
|
|
@@ -231,68 +220,69 @@ export function ThreadPreview({
|
|
|
231
220
|
.filter(Boolean)
|
|
232
221
|
.join(' ')
|
|
233
222
|
|
|
234
|
-
return
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
{
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
>
|
|
279
|
-
<CommentCard
|
|
280
|
-
{...toCardProps(first, props, options.components, resolveName)}
|
|
281
|
-
footer={
|
|
282
|
-
replies ? <span className="tlui-cmt-card__replies">{replies}</span> : undefined
|
|
223
|
+
return (
|
|
224
|
+
<EditorPortal>
|
|
225
|
+
{/* The root is the hover region — it carries the bridge back to the marker — while the panel
|
|
226
|
+
inside it is the visible surface. Keeping them apart is what lets the surface light up on
|
|
227
|
+
its own hover without the bridge (which reaches back over the marker) lighting it up too. */}
|
|
228
|
+
<div
|
|
229
|
+
ref={ref}
|
|
230
|
+
className={`tlui-cmt-canvas-preview tlui-cmt-canvas-preview--${variant}`}
|
|
231
|
+
style={
|
|
232
|
+
{
|
|
233
|
+
left: point.x + offset.x,
|
|
234
|
+
top: point.y + offset.y,
|
|
235
|
+
// The stylesheet sizes the hover bridge against this, so the gap it spans follows
|
|
236
|
+
// the offset rather than being restated as a second magic number.
|
|
237
|
+
'--tlui-cmt-preview-offset': `${offset.x}px`,
|
|
238
|
+
} as CSSProperties
|
|
239
|
+
}
|
|
240
|
+
onPointerEnter={onPointerEnter}
|
|
241
|
+
onPointerLeave={onPointerLeave}
|
|
242
|
+
// The panel sits over the canvas; a press on it is not a canvas press.
|
|
243
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
244
|
+
>
|
|
245
|
+
<div className={panelClass}>
|
|
246
|
+
{cards.map(({ thread, first }) => {
|
|
247
|
+
// The preview shows only the opening comment, so its reply count is what tells the
|
|
248
|
+
// reader the thread continues past what they see.
|
|
249
|
+
const replies = replyCountLabel(msg, (countByThread.get(thread.id) ?? 1) - 1)
|
|
250
|
+
return (
|
|
251
|
+
<div
|
|
252
|
+
key={thread.id}
|
|
253
|
+
className={
|
|
254
|
+
isThread
|
|
255
|
+
? undefined
|
|
256
|
+
: onSelectThread
|
|
257
|
+
? 'tlui-cmt-preview-card tlui-cmt-preview-card--selectable'
|
|
258
|
+
: 'tlui-cmt-preview-card'
|
|
259
|
+
}
|
|
260
|
+
onClick={
|
|
261
|
+
onSelectThread
|
|
262
|
+
? (e) => {
|
|
263
|
+
e.stopPropagation()
|
|
264
|
+
onSelectThread(thread)
|
|
265
|
+
}
|
|
266
|
+
: undefined
|
|
283
267
|
}
|
|
284
|
-
|
|
268
|
+
>
|
|
269
|
+
<CommentCard
|
|
270
|
+
{...toCardProps(first, props, options.components, resolveName)}
|
|
271
|
+
footer={
|
|
272
|
+
replies ? <span className="tlui-cmt-card__replies">{replies}</span> : undefined
|
|
273
|
+
}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
)
|
|
277
|
+
})}
|
|
278
|
+
{overflow > 0 && (
|
|
279
|
+
<div className="tlui-cmt-preview-more">
|
|
280
|
+
{msg('comments.preview-more').replace('{count}', String(overflow))}
|
|
285
281
|
</div>
|
|
286
|
-
)
|
|
287
|
-
|
|
288
|
-
{overflow > 0 && (
|
|
289
|
-
<div className="tlui-cmt-preview-more">
|
|
290
|
-
{msg('comments.preview-more').replace('{count}', String(overflow))}
|
|
291
|
-
</div>
|
|
292
|
-
)}
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
293
284
|
</div>
|
|
294
|
-
</
|
|
295
|
-
container
|
|
285
|
+
</EditorPortal>
|
|
296
286
|
)
|
|
297
287
|
}
|
|
298
288
|
|
|
@@ -2,7 +2,6 @@ import { type MouseEvent as ReactMouseEvent, memo, useEffect, useRef } from 'rea
|
|
|
2
2
|
import {
|
|
3
3
|
Editor,
|
|
4
4
|
TLCommentThread,
|
|
5
|
-
useContainer,
|
|
6
5
|
usePassThroughWheelEvents,
|
|
7
6
|
useTranslation,
|
|
8
7
|
useValue,
|
|
@@ -16,7 +15,7 @@ import { useCommentingOptions } from './options'
|
|
|
16
15
|
import { pinStackKey } from './pin-stacking'
|
|
17
16
|
import { openStackId, openThreadId } from './state'
|
|
18
17
|
import { ThreadPreview, useMarkerPreview } from './thread-preview'
|
|
19
|
-
import { anchorPagePoint } from './thread-state'
|
|
18
|
+
import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
|
|
20
19
|
import {
|
|
21
20
|
POPOVER_OFFSET,
|
|
22
21
|
ThreadPopover,
|
|
@@ -27,9 +26,8 @@ import {
|
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* The pin for threads whose anchors resolve to the same page point — pins zooming can never
|
|
30
|
-
* separate, so
|
|
31
|
-
*
|
|
32
|
-
* single open-thread state, so expanding one collapses another).
|
|
29
|
+
* separate, so they share one count badge. Clicking it lists each thread as a card; clicking a card
|
|
30
|
+
* expands that thread in place, via the single open-thread state.
|
|
33
31
|
*/
|
|
34
32
|
export const ThreadStackPin = memo(function ThreadStackPin({
|
|
35
33
|
editor,
|
|
@@ -40,7 +38,6 @@ export const ThreadStackPin = memo(function ThreadStackPin({
|
|
|
40
38
|
/** The stack's threads, oldest first. All resolve to the same anchor point. */
|
|
41
39
|
threads: readonly TLCommentThread[]
|
|
42
40
|
}) {
|
|
43
|
-
const container = useContainer()
|
|
44
41
|
const msg = useTranslation()
|
|
45
42
|
const badgeRef = useRef<HTMLButtonElement>(null)
|
|
46
43
|
// The badge takes pointer events (to open on click), so wheel input over it would otherwise be
|
|
@@ -49,12 +46,9 @@ export const ThreadStackPin = memo(function ThreadStackPin({
|
|
|
49
46
|
// Hovering the badge previews its threads; clicking still opens them as the interactive list.
|
|
50
47
|
// The preview is what makes the badge legible before you commit to opening it.
|
|
51
48
|
const { previewShown, previewHandlers } = useMarkerPreview(editor, `stack:${threads[0].id}`)
|
|
52
|
-
// The list stays open while a member thread is expanded,
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// Keyed by the coincident page point, not a member id, so the open state survives losing a
|
|
56
|
-
// member (including the oldest): the survivors keep the same key. Falls back to a thread id
|
|
57
|
-
// only when the anchor can't resolve (off page), where the list isn't shown anyway.
|
|
49
|
+
// The list stays open while a member thread is expanded, so Escape steps back: expanded thread ->
|
|
50
|
+
// card list -> closed. Held in editor state because this pin remounts as its owning render path
|
|
51
|
+
// changes, and keyed by the coincident page point so the open state survives losing a member.
|
|
58
52
|
const stackId = useValue(
|
|
59
53
|
'stack id',
|
|
60
54
|
() => {
|
|
@@ -76,13 +70,14 @@ export const ThreadStackPin = memo(function ThreadStackPin({
|
|
|
76
70
|
() => {
|
|
77
71
|
const first = threads[0]
|
|
78
72
|
if (first.pageId !== editor.getCurrentPageId()) return null
|
|
79
|
-
// The badge hangs off its anchor point bottom-left
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// would offset the badge from where the cluster badge sits (cluster centroids average
|
|
83
|
-
// raw anchor points) and make it hop as pins flip between them.
|
|
73
|
+
// The badge hangs off its anchor point bottom-left like a pin, and applies the same imprecise-shape
|
|
74
|
+
// inset the pins it stands in for would (see ThreadPin) — otherwise the marker would snap from tucked
|
|
75
|
+
// inside the shape to the raw corner the moment a second comment turns a pin into a stack.
|
|
84
76
|
const pagePoint = anchorPagePoint(editor, first.anchor)
|
|
85
|
-
|
|
77
|
+
if (!pagePoint) return null
|
|
78
|
+
const viewportPoint = editor.pageToViewport(pagePoint)
|
|
79
|
+
const inset = impreciseShapePinInset(editor, first.anchor)
|
|
80
|
+
return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
|
|
86
81
|
},
|
|
87
82
|
[editor, threads]
|
|
88
83
|
)
|
|
@@ -134,12 +129,9 @@ export const ThreadStackPin = memo(function ThreadStackPin({
|
|
|
134
129
|
}
|
|
135
130
|
}
|
|
136
131
|
|
|
137
|
-
// The expanded thread gains a
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// THREAD_HEADER_BLOCK (36) plus the 8px margin above the expanded entry, less the 2px top the
|
|
141
|
-
// preview card sits its "You" down by. Only the first entry — lifting the list can't also hold a
|
|
142
|
-
// lower thread's neighbours in place.
|
|
132
|
+
// The expanded thread gains a header that pushes its first comment down from where the hover preview
|
|
133
|
+
// showed it. When that thread is the list's first, lift the whole list so its "You" holds position
|
|
134
|
+
// across hover -> open. Only the first entry — lifting can't also hold a lower thread's neighbours.
|
|
143
135
|
const liftForHeader = threads[0]?.id === openId ? 42 : 0
|
|
144
136
|
|
|
145
137
|
return (
|
|
@@ -167,7 +159,6 @@ export const ThreadStackPin = memo(function ThreadStackPin({
|
|
|
167
159
|
<ThreadPreview
|
|
168
160
|
editor={editor}
|
|
169
161
|
threads={threads}
|
|
170
|
-
container={container}
|
|
171
162
|
// Lines the preview up with the stack list itself, so opening it leaves the cards
|
|
172
163
|
// exactly where the preview had them.
|
|
173
164
|
variant="list"
|
|
@@ -185,10 +176,9 @@ export const ThreadStackPin = memo(function ThreadStackPin({
|
|
|
185
176
|
)}
|
|
186
177
|
{open && (
|
|
187
178
|
<ThreadPopover
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
top: point.y + POPOVER_OFFSET.list.y - liftForHeader,
|
|
179
|
+
base={{
|
|
180
|
+
x: point.x + POPOVER_OFFSET.list.x,
|
|
181
|
+
y: point.y + POPOVER_OFFSET.list.y - liftForHeader,
|
|
192
182
|
}}
|
|
193
183
|
>
|
|
194
184
|
<div className="tlui-cmt-stack-list">
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
commentTargetShapeAt,
|
|
19
19
|
impreciseShapePinInset,
|
|
20
20
|
IMPRECISE_PIN_INSET_PX,
|
|
21
|
+
isBoxInInflatedViewport,
|
|
22
|
+
isInInflatedViewport,
|
|
21
23
|
shapeAnchorAt,
|
|
22
24
|
} from './thread-state'
|
|
23
25
|
|
|
@@ -207,3 +209,52 @@ describe('commentCenterScreenOffset', () => {
|
|
|
207
209
|
expect(commentCenterScreenOffset(editor)).toBe(0)
|
|
208
210
|
})
|
|
209
211
|
})
|
|
212
|
+
|
|
213
|
+
describe('isInInflatedViewport', () => {
|
|
214
|
+
// The viewport is 1000x1000 (see beforeEach) and the margin is 120 screen px.
|
|
215
|
+
it('accepts a point on screen', () => {
|
|
216
|
+
expect(isInInflatedViewport(editor, { x: 500, y: 500 })).toBe(true)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('accepts a point just off screen, so a pan finds it already mounted', () => {
|
|
220
|
+
expect(isInInflatedViewport(editor, { x: -119, y: 500 })).toBe(true)
|
|
221
|
+
expect(isInInflatedViewport(editor, { x: 1119, y: 500 })).toBe(true)
|
|
222
|
+
expect(isInInflatedViewport(editor, { x: 500, y: -119 })).toBe(true)
|
|
223
|
+
expect(isInInflatedViewport(editor, { x: 500, y: 1119 })).toBe(true)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('rejects a point past the margin on any side', () => {
|
|
227
|
+
expect(isInInflatedViewport(editor, { x: -121, y: 500 })).toBe(false)
|
|
228
|
+
expect(isInInflatedViewport(editor, { x: 1121, y: 500 })).toBe(false)
|
|
229
|
+
expect(isInInflatedViewport(editor, { x: 500, y: -121 })).toBe(false)
|
|
230
|
+
expect(isInInflatedViewport(editor, { x: 500, y: 1121 })).toBe(false)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('isBoxInInflatedViewport', () => {
|
|
235
|
+
it('accepts a box wholly on screen', () => {
|
|
236
|
+
expect(isBoxInInflatedViewport(editor, { x: 100, y: 100, w: 200, h: 200 })).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('accepts a box straddling the edge, whose pin corner alone would be culled', () => {
|
|
240
|
+
// This is the case the point test gets wrong: the box's visible edge is on screen while
|
|
241
|
+
// its far corner — where a region thread's pin can sit — is thousands of px away.
|
|
242
|
+
expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 5100, h: 5100 })).toBe(true)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('accepts a box larger than the viewport on every side', () => {
|
|
246
|
+
expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 10000, h: 10000 })).toBe(true)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('rejects a box wholly past the margin', () => {
|
|
250
|
+
expect(isBoxInInflatedViewport(editor, { x: 1200, y: 100, w: 100, h: 100 })).toBe(false)
|
|
251
|
+
expect(isBoxInInflatedViewport(editor, { x: 100, y: -400, w: 100, h: 100 })).toBe(false)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('follows the camera rather than page coordinates', () => {
|
|
255
|
+
const box = { x: 2000, y: 0, w: 100, h: 100 }
|
|
256
|
+
expect(isBoxInInflatedViewport(editor, box)).toBe(false)
|
|
257
|
+
editor.setCamera({ x: -2000, y: 0, z: 1 })
|
|
258
|
+
expect(isBoxInInflatedViewport(editor, box)).toBe(true)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
@@ -17,6 +17,47 @@ import { POPOVER_OFFSET } from './thread-view'
|
|
|
17
17
|
* most of the marker sits within the shape, with a small overhang past the corner. */
|
|
18
18
|
export const IMPRECISE_PIN_INSET_PX = 20
|
|
19
19
|
|
|
20
|
+
/** Screen-pixel margin by which the viewport is inflated when culling canvas markers (thread pins
|
|
21
|
+
* and cluster badges), so a marker just off-screen is already mounted when a pan brings it in. */
|
|
22
|
+
const MARKER_CULL_MARGIN_PX = 120
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a viewport-space point sits within the viewport inflated by
|
|
26
|
+
* {@link MARKER_CULL_MARGIN_PX}. The cull test for screen-fixed markers: off-screen markers
|
|
27
|
+
* return null from their position signal and unmount instead of tracking every camera frame.
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export function isInInflatedViewport(editor: Editor, point: VecLike): boolean {
|
|
31
|
+
const viewport = editor.getViewportScreenBounds()
|
|
32
|
+
const margin = MARKER_CULL_MARGIN_PX
|
|
33
|
+
return (
|
|
34
|
+
point.x >= -margin &&
|
|
35
|
+
point.y >= -margin &&
|
|
36
|
+
point.x <= viewport.w + margin &&
|
|
37
|
+
point.y <= viewport.h + margin
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether any part of a page-space box overlaps the viewport inflated by
|
|
43
|
+
* {@link MARKER_CULL_MARGIN_PX}. The cull test for region-anchored threads, whose dashed box can
|
|
44
|
+
* be on screen while the pin corner itself is not.
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
export function isBoxInInflatedViewport(editor: Editor, box: BoxModel): boolean {
|
|
48
|
+
const viewport = editor.getViewportScreenBounds()
|
|
49
|
+
const margin = MARKER_CULL_MARGIN_PX
|
|
50
|
+
// Zoom is positive, so the page box's corners keep their order through the transform.
|
|
51
|
+
const min = editor.pageToViewport({ x: box.x, y: box.y })
|
|
52
|
+
const max = editor.pageToViewport({ x: box.x + box.w, y: box.y + box.h })
|
|
53
|
+
return (
|
|
54
|
+
max.x >= -margin &&
|
|
55
|
+
max.y >= -margin &&
|
|
56
|
+
min.x <= viewport.w + margin &&
|
|
57
|
+
min.y <= viewport.h + margin
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
20
61
|
/** Imprecise shape pins tuck inside the shape rather than hanging off its edge: the marker
|
|
21
62
|
* extends up-right of its anchor point, so step it toward the shape's centre. Screen px — the
|
|
22
63
|
* pin is screen-fixed while the shape scales with zoom. Null for anchors that need no inset. */
|
|
@@ -61,13 +102,11 @@ export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CO
|
|
|
61
102
|
}
|
|
62
103
|
|
|
63
104
|
/**
|
|
64
|
-
* Where a thread's pin sits on the page, for each anchor kind. Null hides the pin.
|
|
65
|
-
*
|
|
66
|
-
* normalized 0–1 spot, top-right by default) rather than the stored `x`/`y`.
|
|
105
|
+
* Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. Imprecise shape
|
|
106
|
+
* anchors use {@link CommentingOptions.impreciseShapeAnchor} rather than the stored `x`/`y`.
|
|
67
107
|
*
|
|
68
|
-
* A shape anchor's `x`/`y` are normalized within the shape's
|
|
69
|
-
*
|
|
70
|
-
* carries the pin around with it instead of leaving it behind in the bounding box.
|
|
108
|
+
* A shape anchor's `x`/`y` are normalized within the shape's bounds and resolved through its page
|
|
109
|
+
* transform, so the pin rides rotation instead of being left behind in the bounding box.
|
|
71
110
|
* @public
|
|
72
111
|
*/
|
|
73
112
|
export function anchorPagePoint(
|
|
@@ -97,15 +136,12 @@ export function anchorPagePoint(
|
|
|
97
136
|
/**
|
|
98
137
|
* The shape a comment placed at a page point should anchor to, or undefined for empty canvas.
|
|
99
138
|
*
|
|
100
|
-
* Uses the editor's hit-test margin, the same slack select and hover use
|
|
101
|
-
*
|
|
102
|
-
* geometry reports a positive distance for every point off the stroke, so a zero margin only
|
|
103
|
-
* matches a pixel-perfect click right on the line.
|
|
139
|
+
* Uses the editor's hit-test margin, the same slack select and hover use — without it, open-path
|
|
140
|
+
* shapes (arrows, lines, draw strokes) are unhittable in practice.
|
|
104
141
|
*
|
|
105
|
-
* `hitFrameInside` lets a click inside a frame's body anchor to the frame
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* frame), so only a frame's empty interior anchors the frame.
|
|
142
|
+
* `hitFrameInside` lets a click inside a frame's body anchor to the frame, which it otherwise
|
|
143
|
+
* wouldn't (the select-tool convention hits only the edge/label). A child shape under the pointer
|
|
144
|
+
* still wins, so only a frame's empty interior anchors the frame.
|
|
109
145
|
*
|
|
110
146
|
* @internal
|
|
111
147
|
*/
|
|
@@ -119,11 +155,9 @@ export function commentTargetShapeAt(editor: Editor, page: VecLike): TLShape | u
|
|
|
119
155
|
|
|
120
156
|
/**
|
|
121
157
|
* A shape anchor for a page point. `x`/`y` are the point's normalized (0–1) offset within the
|
|
122
|
-
* shape's own bounds
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* (top-right out of the box). Placement gestures get `precise` from the `shouldBePrecise`
|
|
126
|
-
* commenting option (always precise, by default).
|
|
158
|
+
* shape's own bounds, taken in the shape's own space, so a pin on a rotated shape records the spot
|
|
159
|
+
* it was dropped on. Remembered either way: when `precise` the pin sits at exactly `x`/`y`,
|
|
160
|
+
* otherwise at the consumer's imprecise default.
|
|
127
161
|
* @public
|
|
128
162
|
*/
|
|
129
163
|
export function shapeAnchorAt(
|
|
@@ -168,10 +202,9 @@ const THREAD_UI_EXTENT_PX = POPOVER_OFFSET.thread.x + 300
|
|
|
168
202
|
|
|
169
203
|
/**
|
|
170
204
|
* How far right of a centered-on pin the true viewport center should sit, in screen px. While the
|
|
171
|
-
* comments sidebar covers the viewport's right edge, centering a pin
|
|
172
|
-
* popover
|
|
173
|
-
*
|
|
174
|
-
* never past the viewport's left edge. Zero when the sidebar is closed or not on screen.
|
|
205
|
+
* comments sidebar covers the viewport's right edge, dead-centering a pin would put its thread
|
|
206
|
+
* popover under the sidebar — so centering aims at the middle of the uncovered area instead, never
|
|
207
|
+
* past the viewport's left edge. Zero when the sidebar is closed or not on screen.
|
|
175
208
|
* @internal
|
|
176
209
|
*/
|
|
177
210
|
export function commentCenterScreenOffset(editor: Editor): number {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { CommentAuthor } from '@tldraw/mentions'
|
|
2
|
+
import type { TLComment } from 'tldraw'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { absoluteThreadLink, toCardProps } from './thread-view'
|
|
5
|
+
|
|
6
|
+
const AUTHOR: CommentAuthor = { name: 'Ada' }
|
|
7
|
+
|
|
8
|
+
function comment(overrides: Partial<TLComment> = {}): TLComment {
|
|
9
|
+
return {
|
|
10
|
+
id: 'comment:1',
|
|
11
|
+
typeName: 'comment',
|
|
12
|
+
threadId: 'comment-thread:1',
|
|
13
|
+
pageId: 'page:one',
|
|
14
|
+
authorId: 'user:1',
|
|
15
|
+
body: { type: 'doc', content: [] },
|
|
16
|
+
createdAt: Date.parse('2026-07-30T12:00:00.000Z'),
|
|
17
|
+
editedAt: null,
|
|
18
|
+
meta: {},
|
|
19
|
+
...overrides,
|
|
20
|
+
} as unknown as TLComment
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const context = {
|
|
24
|
+
currentUserId: 'user:1',
|
|
25
|
+
resolveAuthor: (id: string) => (id === 'user:1' ? AUTHOR : undefined),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const resolveName = (id: string) => context.resolveAuthor(id)?.name
|
|
29
|
+
|
|
30
|
+
describe('toCardProps', () => {
|
|
31
|
+
it('marks your own comments and resolves the author', () => {
|
|
32
|
+
const card = toCardProps(comment(), context, {}, resolveName)
|
|
33
|
+
expect(card.you).toBe(true)
|
|
34
|
+
expect(card.author).toBe(AUTHOR)
|
|
35
|
+
expect(card.date).toBe('2026-07-30T12:00:00.000Z')
|
|
36
|
+
expect(card.edited).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('falls back to the unknown author when the id does not resolve', () => {
|
|
40
|
+
const card = toCardProps(comment({ authorId: 'user:gone' }), context, {}, resolveName)
|
|
41
|
+
expect(card.you).toBe(false)
|
|
42
|
+
expect(card.author).not.toBe(AUTHOR)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('absoluteThreadLink', () => {
|
|
47
|
+
const BASE = 'https://example.com/f/abc123?page=2'
|
|
48
|
+
|
|
49
|
+
// A host's `getThreadHref` is an href — the sidebar rows use it as one — so it's allowed to be
|
|
50
|
+
// relative. Copying that verbatim would put "/f/abc123?comment=…" on someone's clipboard.
|
|
51
|
+
it('resolves a root-relative href against the current document', () => {
|
|
52
|
+
expect(absoluteThreadLink('/f/abc123?comment=comment-thread%3A1', BASE)).toBe(
|
|
53
|
+
'https://example.com/f/abc123?comment=comment-thread%3A1'
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('leaves an already absolute href alone', () => {
|
|
58
|
+
const href = 'https://other.example/f/xyz?comment=comment-thread%3A1'
|
|
59
|
+
expect(absoluteThreadLink(href, BASE)).toBe(href)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('resolves a path-relative href', () => {
|
|
63
|
+
expect(absoluteThreadLink('?comment=comment-thread%3A1', BASE)).toBe(
|
|
64
|
+
'https://example.com/f/abc123?comment=comment-thread%3A1'
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Better a link that's odd than a copy button that silently does nothing.
|
|
69
|
+
it('copies an unparseable href as-is', () => {
|
|
70
|
+
expect(absoluteThreadLink('::not a url::', '::also not::')).toBe('::not a url::')
|
|
71
|
+
})
|
|
72
|
+
})
|