@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.04044ed9e96d
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/README.md +0 -2
- package/commenting.css +1152 -350
- package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
- package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
- package/dist-cjs/{ui/avatar.js → canvas/canvas-events.js} +14 -22
- 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 +53 -3
- 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-body.js +1 -1
- package/dist-cjs/canvas/comment-body.js.map +2 -2
- package/dist-cjs/canvas/comment-drafts.js +54 -0
- package/dist-cjs/canvas/comment-drafts.js.map +7 -0
- package/dist-cjs/canvas/comment-mutations.js +152 -0
- package/dist-cjs/canvas/comment-mutations.js.map +7 -0
- package/dist-cjs/canvas/comment-reactions.js +160 -0
- package/dist-cjs/canvas/comment-reactions.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +9 -6
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-store.js +19 -9
- package/dist-cjs/canvas/comment-store.js.map +2 -2
- package/dist-cjs/canvas/comment-tool.js +63 -14
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +17 -37
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +25 -22
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +151 -989
- package/dist-cjs/canvas/comments-overlay.js.map +3 -3
- package/dist-cjs/canvas/comments-sidebar.js +49 -41
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +45 -0
- package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
- package/dist-cjs/canvas/context.js +17 -0
- package/dist-cjs/canvas/context.js.map +7 -0
- package/dist-cjs/canvas/hooks.js +28 -3
- 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 +57 -2
- 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 +81 -0
- package/dist-cjs/canvas/pin-stacking.js.map +7 -0
- package/dist-cjs/canvas/region-box.js +113 -0
- package/dist-cjs/canvas/region-box.js.map +7 -0
- package/dist-cjs/canvas/sidebar-filters.js +2 -2
- package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
- package/dist-cjs/canvas/state.js +28 -14
- 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 +177 -0
- package/dist-cjs/canvas/thread-preview.js.map +7 -0
- package/dist-cjs/canvas/thread-stack.js +199 -0
- package/dist-cjs/canvas/thread-stack.js.map +7 -0
- package/dist-cjs/canvas/thread-state.js +82 -21
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +450 -0
- package/dist-cjs/canvas/thread-view.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +6 -6
- 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 +126 -7
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/clustering/types.js.map +1 -1
- package/dist-cjs/index.d.ts +904 -353
- package/dist-cjs/index.js +52 -28
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +10 -6
- package/dist-cjs/ui/byline.js.map +2 -2
- package/dist-cjs/ui/comment-card.js +15 -8
- package/dist-cjs/ui/comment-card.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +124 -39
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +11 -2
- package/dist-cjs/ui/comment-pin.js.map +2 -2
- package/dist-cjs/ui/comment-thread.js +9 -7
- package/dist-cjs/ui/comment-thread.js.map +2 -2
- package/dist-cjs/ui/comments-list.js +39 -40
- package/dist-cjs/ui/comments-list.js.map +2 -2
- package/dist-cjs/ui/count-badge.js +3 -2
- package/dist-cjs/ui/count-badge.js.map +2 -2
- package/dist-cjs/ui/emoji-picker.js +54 -0
- package/dist-cjs/ui/emoji-picker.js.map +7 -0
- package/dist-cjs/ui/empty-state.js +3 -3
- package/dist-cjs/ui/empty-state.js.map +2 -2
- package/dist-cjs/ui/format-time.js +16 -1
- 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 +71 -0
- package/dist-cjs/ui/reaction-picker.js.map +7 -0
- package/dist-cjs/ui/reaction.js +58 -6
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/reactions.js +20 -7
- package/dist-cjs/ui/reactions.js.map +2 -2
- package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
- package/dist-cjs/ui/reply-count.js.map +7 -0
- package/dist-cjs/ui/send-button.js +13 -1
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/{comment-text.js → visual-viewport.js} +12 -9
- package/dist-cjs/ui/visual-viewport.js.map +7 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
- 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 +54 -4
- 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-body.mjs +1 -1
- package/dist-esm/canvas/comment-body.mjs.map +2 -2
- package/dist-esm/canvas/comment-drafts.mjs +34 -0
- package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
- package/dist-esm/canvas/comment-mutations.mjs +132 -0
- package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
- package/dist-esm/canvas/comment-reactions.mjs +145 -0
- package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +10 -7
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-store.mjs +19 -9
- package/dist-esm/canvas/comment-store.mjs.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +66 -17
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +17 -36
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +27 -22
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +157 -1010
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +53 -46
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +25 -0
- package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
- package/dist-esm/canvas/context.mjs +1 -0
- package/dist-esm/canvas/context.mjs.map +7 -0
- package/dist-esm/canvas/hooks.mjs +35 -5
- 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 +59 -3
- 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 +61 -0
- package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
- package/dist-esm/canvas/region-box.mjs +93 -0
- package/dist-esm/canvas/region-box.mjs.map +7 -0
- package/dist-esm/canvas/sidebar-filters.mjs +2 -2
- package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
- package/dist-esm/canvas/state.mjs +29 -19
- 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 +163 -0
- package/dist-esm/canvas/thread-preview.mjs.map +7 -0
- package/dist-esm/canvas/thread-stack.mjs +189 -0
- package/dist-esm/canvas/thread-stack.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +86 -22
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +461 -0
- package/dist-esm/canvas/thread-view.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +6 -6
- 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 +126 -7
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +904 -353
- package/dist-esm/index.mjs +102 -43
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +11 -7
- package/dist-esm/ui/byline.mjs.map +2 -2
- package/dist-esm/ui/comment-card.mjs +15 -8
- package/dist-esm/ui/comment-card.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +135 -39
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +11 -2
- package/dist-esm/ui/comment-pin.mjs.map +2 -2
- package/dist-esm/ui/comment-thread.mjs +9 -7
- package/dist-esm/ui/comment-thread.mjs.map +2 -2
- package/dist-esm/ui/comments-list.mjs +51 -50
- package/dist-esm/ui/comments-list.mjs.map +2 -2
- package/dist-esm/ui/count-badge.mjs +3 -2
- package/dist-esm/ui/count-badge.mjs.map +2 -2
- package/dist-esm/ui/emoji-picker.mjs +34 -0
- package/dist-esm/ui/emoji-picker.mjs.map +7 -0
- package/dist-esm/ui/empty-state.mjs +3 -3
- package/dist-esm/ui/empty-state.mjs.map +2 -2
- package/dist-esm/ui/format-time.mjs +16 -1
- 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 +59 -0
- package/dist-esm/ui/reaction-picker.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +59 -7
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/reactions.mjs +21 -8
- package/dist-esm/ui/reactions.mjs.map +2 -2
- package/dist-esm/ui/reply-count.mjs +9 -0
- package/dist-esm/ui/reply-count.mjs.map +7 -0
- package/dist-esm/ui/send-button.mjs +13 -1
- 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 +6 -6
- package/src/canvas/anchor-lifecycle.test.ts +290 -0
- package/src/canvas/anchor-lifecycle.ts +191 -0
- package/src/canvas/canvas-events.ts +18 -0
- package/src/canvas/canvas.css +464 -55
- package/src/canvas/cluster-badge.tsx +133 -0
- package/src/canvas/cluster-fade.ts +102 -0
- package/src/canvas/cluster-input.test.ts +178 -36
- package/src/canvas/cluster-input.ts +95 -9
- package/src/canvas/cluster-model.ts +378 -0
- package/src/canvas/comment-body.tsx +1 -1
- package/src/canvas/comment-drafts.test.ts +55 -0
- package/src/canvas/comment-drafts.ts +46 -0
- package/src/canvas/comment-mutations.test.ts +452 -0
- package/src/canvas/comment-mutations.ts +299 -0
- package/src/canvas/comment-reactions.test.ts +154 -0
- package/src/canvas/comment-reactions.tsx +246 -0
- package/src/canvas/comment-render.test.ts +1 -1
- package/src/canvas/comment-render.ts +17 -13
- package/src/canvas/comment-store.ts +68 -33
- package/src/canvas/comment-tool.test.ts +162 -0
- package/src/canvas/comment-tool.tsx +103 -25
- package/src/canvas/comments-filter-menu.tsx +20 -36
- package/src/canvas/comments-overflow-menu.tsx +17 -19
- package/src/canvas/comments-overlay.tsx +252 -1369
- package/src/canvas/comments-sidebar.test.ts +45 -0
- package/src/canvas/comments-sidebar.tsx +112 -73
- package/src/canvas/comments-visibility-toggle.tsx +25 -0
- package/src/canvas/context.ts +48 -0
- package/src/canvas/hooks.test.ts +94 -0
- package/src/canvas/hooks.ts +66 -11
- package/src/canvas/license.ts +1 -1
- package/src/canvas/mobile-placement.ts +115 -0
- package/src/canvas/options.test.ts +176 -4
- package/src/canvas/options.ts +276 -18
- package/src/canvas/pending-composer.tsx +157 -0
- package/src/canvas/pin-stacking.test.ts +224 -0
- package/src/canvas/pin-stacking.ts +103 -0
- package/src/canvas/region-box.tsx +124 -0
- package/src/canvas/sidebar-filters.ts +4 -3
- package/src/canvas/state.ts +88 -39
- package/src/canvas/thread-pin.tsx +418 -0
- package/src/canvas/thread-preview.test.ts +91 -0
- package/src/canvas/thread-preview.tsx +294 -0
- package/src/canvas/thread-stack.tsx +242 -0
- package/src/canvas/thread-state.test.ts +260 -0
- package/src/canvas/thread-state.ts +171 -37
- package/src/canvas/thread-view.test.ts +72 -0
- package/src/canvas/thread-view.tsx +614 -0
- package/src/clustering/computeClusterTable.test.ts +24 -23
- package/src/clustering/computeClusterTable.ts +17 -8
- package/src/clustering/replay.test.ts +0 -7
- package/src/clustering/replay.ts +131 -32
- package/src/clustering/runtime.test.ts +267 -13
- package/src/clustering/runtime.ts +176 -19
- package/src/clustering/schedule.test.ts +0 -6
- package/src/clustering/screen-offsets.test.ts +171 -0
- package/src/clustering/types.ts +19 -9
- package/src/index.ts +91 -42
- package/src/ui/byline.tsx +21 -9
- package/src/ui/comment-card.tsx +20 -10
- package/src/ui/comment-composer.tsx +178 -56
- package/src/ui/comment-pin.tsx +13 -2
- package/src/ui/comment-thread.tsx +13 -6
- package/src/ui/comments-list.test.ts +31 -0
- package/src/ui/comments-list.tsx +82 -47
- package/src/ui/comments.css +577 -294
- package/src/ui/count-badge.tsx +9 -2
- package/src/ui/emoji-picker.test.ts +25 -0
- package/src/ui/emoji-picker.tsx +76 -0
- package/src/ui/empty-state.tsx +3 -3
- package/src/ui/format-time.test.ts +69 -0
- package/src/ui/format-time.ts +27 -2
- package/src/ui/icons.tsx +116 -0
- package/src/ui/reaction-picker.tsx +102 -0
- package/src/ui/reaction.tsx +125 -6
- package/src/ui/reactions.tsx +69 -10
- package/src/ui/reply-count.ts +16 -0
- package/src/ui/send-button.tsx +13 -3
- package/src/ui/visual-viewport.test.ts +36 -0
- package/src/ui/visual-viewport.ts +27 -0
- package/dist-cjs/canvas/region-options.js +0 -49
- package/dist-cjs/canvas/region-options.js.map +0 -7
- package/dist-cjs/ui/avatar.js.map +0 -7
- package/dist-cjs/ui/comment-mention.js +0 -42
- package/dist-cjs/ui/comment-mention.js.map +0 -7
- package/dist-cjs/ui/comment-text.js.map +0 -7
- package/dist-cjs/ui/mention-list.js +0 -70
- package/dist-cjs/ui/mention-list.js.map +0 -7
- package/dist-cjs/ui/mention-suggestion.js +0 -206
- package/dist-cjs/ui/mention-suggestion.js.map +0 -7
- package/dist-cjs/ui/mention.js.map +0 -7
- package/dist-cjs/ui/render-markdown.js +0 -63
- package/dist-cjs/ui/render-markdown.js.map +0 -7
- package/dist-esm/canvas/region-options.mjs +0 -29
- package/dist-esm/canvas/region-options.mjs.map +0 -7
- package/dist-esm/ui/avatar.mjs +0 -23
- package/dist-esm/ui/avatar.mjs.map +0 -7
- package/dist-esm/ui/comment-mention.mjs +0 -22
- package/dist-esm/ui/comment-mention.mjs.map +0 -7
- package/dist-esm/ui/comment-text.mjs +0 -9
- package/dist-esm/ui/comment-text.mjs.map +0 -7
- package/dist-esm/ui/mention-list.mjs +0 -50
- package/dist-esm/ui/mention-list.mjs.map +0 -7
- package/dist-esm/ui/mention-suggestion.mjs +0 -186
- package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
- package/dist-esm/ui/mention.mjs +0 -11
- package/dist-esm/ui/mention.mjs.map +0 -7
- package/dist-esm/ui/render-markdown.mjs +0 -45
- package/dist-esm/ui/render-markdown.mjs.map +0 -7
- package/src/canvas/region-options.ts +0 -57
- package/src/ui/avatar.tsx +0 -31
- package/src/ui/comment-mention.ts +0 -47
- package/src/ui/comment-text.tsx +0 -12
- package/src/ui/mention-list.tsx +0 -106
- package/src/ui/mention-suggestion.test.ts +0 -18
- package/src/ui/mention-suggestion.tsx +0 -285
- package/src/ui/mention.tsx +0 -9
- package/src/ui/render-markdown.tsx +0 -72
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CommentListItemProps } from '../ui/comments-list'
|
|
3
|
+
import { sortSidebarRows } from './comments-sidebar'
|
|
4
|
+
|
|
5
|
+
function row(id: string, lastActivity: number, resolved = false) {
|
|
6
|
+
return {
|
|
7
|
+
item: {
|
|
8
|
+
id,
|
|
9
|
+
resolved,
|
|
10
|
+
author: { id: 'user:1', name: 'A' },
|
|
11
|
+
preview: '',
|
|
12
|
+
date: '',
|
|
13
|
+
} as unknown as CommentListItemProps,
|
|
14
|
+
lastActivity,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('sortSidebarRows', () => {
|
|
19
|
+
it('orders threads by their most recent comment, not their first', () => {
|
|
20
|
+
// t1 was started first but was just replied to; t2 has been quiet since it was created.
|
|
21
|
+
const sorted = sortSidebarRows([row('t1', 300), row('t2', 200)])
|
|
22
|
+
expect(sorted.map((r) => r.item.id)).toEqual(['t1', 't2'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('sinks resolved threads below unresolved ones however recent they are', () => {
|
|
26
|
+
const sorted = sortSidebarRows([row('t1', 100), row('t2', 999, true), row('t3', 200)])
|
|
27
|
+
expect(sorted.map((r) => r.item.id)).toEqual(['t3', 't1', 't2'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('orders resolved threads among themselves by recency too', () => {
|
|
31
|
+
const sorted = sortSidebarRows([row('t1', 100, true), row('t2', 300, true)])
|
|
32
|
+
expect(sorted.map((r) => r.item.id)).toEqual(['t2', 't1'])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('breaks activity ties by id so the list does not reshuffle', () => {
|
|
36
|
+
const sorted = sortSidebarRows([row('t3', 5), row('t1', 5), row('t2', 5)])
|
|
37
|
+
expect(sorted.map((r) => r.item.id)).toEqual(['t1', 't2', 't3'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not mutate the input', () => {
|
|
41
|
+
const input = [row('t1', 100), row('t2', 200)]
|
|
42
|
+
sortSidebarRows(input)
|
|
43
|
+
expect(input.map((r) => r.item.id)).toEqual(['t1', 't2'])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -1,72 +1,61 @@
|
|
|
1
|
-
import { ReactNode,
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
1
|
+
import { ReactNode, useCallback } from 'react'
|
|
3
2
|
import {
|
|
3
|
+
EditorPortal,
|
|
4
4
|
TLComment,
|
|
5
|
-
|
|
6
|
-
useContainer,
|
|
5
|
+
TLCommentThread,
|
|
7
6
|
useEditor,
|
|
8
|
-
usePassThroughMouseOverEvents,
|
|
9
7
|
useTranslation,
|
|
10
8
|
useValue,
|
|
11
9
|
} from 'tldraw'
|
|
12
|
-
import { CommentListItemProps, CommentsList } from '../ui/comments-list'
|
|
13
|
-
import {
|
|
10
|
+
import { CommentListItemProps, CommentListItemRenderProps, CommentsList } from '../ui/comments-list'
|
|
11
|
+
import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
|
14
12
|
import { CommentsFilterMenu } from './comments-filter-menu'
|
|
15
|
-
import {
|
|
13
|
+
import { CommentsVisibilityToggle } from './comments-visibility-toggle'
|
|
14
|
+
import { type CommentingContext } from './context'
|
|
16
15
|
import { useComments, useCommentThreads } from './hooks'
|
|
17
16
|
import { useCommentingEnabled } from './license'
|
|
18
17
|
import { useCommentingOptions } from './options'
|
|
19
18
|
import { richTextToPlaintext } from './rich-text'
|
|
20
|
-
import { openThreadId, sidebarFilters } from './state'
|
|
19
|
+
import { commentsSidebarOpen, openThreadId, sidebarFilters } from './state'
|
|
21
20
|
import { focusThread } from './thread-state'
|
|
22
21
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
isCommentUnread
|
|
34
|
-
|
|
35
|
-
tools?: string[]
|
|
22
|
+
/**
|
|
23
|
+
* The host wiring for {@link CanvasCommentsSidebar}: the {@link CommentingContext} fields it reads,
|
|
24
|
+
* plus the panel's own slots. A non-null `currentUserId` enables the "only your threads" filter, and
|
|
25
|
+
* an `isCommentUnread` the "only unread" one. `CanvasComments` takes the same fields, so a host
|
|
26
|
+
* mounting both can spread one object into each.
|
|
27
|
+
*
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
export interface CanvasCommentsSidebarProps extends Pick<
|
|
31
|
+
CommentingContext,
|
|
32
|
+
'currentUserId' | 'resolveAuthor' | 'isCommentUnread' | 'getThreadHref'
|
|
33
|
+
> {
|
|
36
34
|
/** Header above the list. */
|
|
37
35
|
header?: ReactNode
|
|
38
36
|
/** Shown when the page has no threads. */
|
|
39
37
|
empty?: ReactNode
|
|
40
|
-
/** Where imprecise shape pins sit, so navigation centres on the same spot. Default top-right. */
|
|
41
|
-
impreciseShapeAnchor?: { x: number; y: number }
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
/**
|
|
45
|
-
* A comments list panel for the current page, shown while
|
|
46
|
-
* thread brings its pin into view and opens it. Batteries-included
|
|
47
|
-
* `CanvasComments`); `CommentsList` is exported for a differently-placed
|
|
41
|
+
* A comments list panel for the current page, shown while {@link commentsSidebarOpen} is set (e.g.
|
|
42
|
+
* toggled by a button). Clicking a thread brings its pin into view and opens it. Batteries-included
|
|
43
|
+
* over the store (a sibling to `CanvasComments`); `CommentsList` is exported for a differently-placed
|
|
44
|
+
* or always-on list.
|
|
48
45
|
* @public @react
|
|
49
46
|
*/
|
|
50
47
|
export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
51
|
-
const {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
isCommentUnread,
|
|
55
|
-
tools,
|
|
56
|
-
header,
|
|
57
|
-
empty,
|
|
58
|
-
impreciseShapeAnchor,
|
|
59
|
-
} = props
|
|
48
|
+
const { resolveAuthor, currentUserId, isCommentUnread, header, empty, getThreadHref } = props
|
|
49
|
+
// Name-only view of the resolver, for the plaintext previews (which resolve @-mentions).
|
|
50
|
+
const resolveName = useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
|
|
60
51
|
const editor = useEditor()
|
|
61
52
|
const options = useCommentingOptions()
|
|
62
|
-
const sidebarTools = tools ?? ['comment']
|
|
63
|
-
const container = useContainer()
|
|
64
53
|
const commentingEnabled = useCommentingEnabled()
|
|
65
54
|
const msg = useTranslation()
|
|
66
55
|
const threads = useCommentThreads(editor)
|
|
67
56
|
const comments = useComments(editor)
|
|
68
57
|
const currentPageId = useValue('page id', () => editor.getCurrentPageId(), [editor])
|
|
69
|
-
const
|
|
58
|
+
const open = useValue('sidebar open', () => commentsSidebarOpen.get(editor), [editor])
|
|
70
59
|
const openId = useValue('open thread', () => openThreadId.get(editor), [editor])
|
|
71
60
|
const filters = useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
|
72
61
|
const pageNames = useValue(
|
|
@@ -75,7 +64,7 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
75
64
|
[editor]
|
|
76
65
|
)
|
|
77
66
|
|
|
78
|
-
if (!commentingEnabled || !
|
|
67
|
+
if (!commentingEnabled || !open) return null
|
|
79
68
|
|
|
80
69
|
// Group comments by thread (they arrive oldest-first, so [0] is each thread's first comment).
|
|
81
70
|
const byThread = new Map<string, TLComment[]>()
|
|
@@ -91,13 +80,12 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
91
80
|
(thread) => !filters.onlyCurrentPage || thread.pageId === currentPageId
|
|
92
81
|
)
|
|
93
82
|
|
|
94
|
-
const
|
|
83
|
+
const rows: SidebarRow[] = pageThreads
|
|
95
84
|
.filter((thread) => filters.showResolved || thread.resolved == null)
|
|
96
85
|
// "Only mine" is ignored without a known user — otherwise a persisted onlyMine=true would
|
|
97
86
|
// empty the list for a signed-out viewer, with the (hidden) toggle giving no way to clear it.
|
|
98
87
|
.filter(
|
|
99
|
-
(thread) =>
|
|
100
|
-
!filters.onlyMine || currentUserId === undefined || thread.createdBy === currentUserId
|
|
88
|
+
(thread) => !filters.onlyMine || currentUserId === null || thread.createdBy === currentUserId
|
|
101
89
|
)
|
|
102
90
|
// "Only unread" is likewise ignored without a read-status source.
|
|
103
91
|
.filter(
|
|
@@ -109,6 +97,8 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
109
97
|
.map((thread) => {
|
|
110
98
|
const threadComments = byThread.get(thread.id) ?? []
|
|
111
99
|
const first = threadComments[0]
|
|
100
|
+
// Comments arrive oldest-first, so the last one is the thread's most recent activity.
|
|
101
|
+
const last = threadComments[threadComments.length - 1]
|
|
112
102
|
let preview: ReactNode = ''
|
|
113
103
|
// The `ThreadPreview` component slot overrides the built-in plaintext default.
|
|
114
104
|
const ThreadPreview = options.components.ThreadPreview
|
|
@@ -120,41 +110,65 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
120
110
|
)
|
|
121
111
|
}
|
|
122
112
|
return {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
113
|
+
item: {
|
|
114
|
+
id: thread.id,
|
|
115
|
+
author: resolveAuthor(thread.createdBy) ?? UNKNOWN_COMMENT_AUTHOR,
|
|
116
|
+
preview,
|
|
117
|
+
date: new Date((first ?? thread).createdAt).toISOString(),
|
|
118
|
+
resolved: thread.resolved != null,
|
|
119
|
+
// The page label only earns its place when it adds information: multiple pages
|
|
120
|
+
// exist, and the thread is somewhere other than where you already are.
|
|
121
|
+
page:
|
|
122
|
+
pageNames.size > 1 && thread.pageId !== currentPageId
|
|
123
|
+
? pageNames.get(thread.pageId)
|
|
124
|
+
: undefined,
|
|
125
|
+
count: threadComments.length,
|
|
126
|
+
selected: openId === thread.id,
|
|
127
|
+
href: getThreadHref?.(thread.id),
|
|
128
|
+
},
|
|
129
|
+
lastActivity: (last ?? thread).createdAt,
|
|
131
130
|
}
|
|
132
131
|
})
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!!a.resolved !== !!b.resolved) return a.resolved ? 1 : -1
|
|
136
|
-
return b.date.localeCompare(a.date)
|
|
137
|
-
})
|
|
132
|
+
|
|
133
|
+
const items = sortSidebarRows(rows).map((row) => row.item)
|
|
138
134
|
|
|
139
135
|
const focus = (id: string) => {
|
|
140
136
|
const thread = threads.find((t) => t.id === id)
|
|
141
|
-
|
|
142
|
-
// spot the pin renders at when the anchor is configured via `CommentTool.configure`.
|
|
143
|
-
if (thread) focusThread(editor, thread, impreciseShapeAnchor ?? options.impreciseShapeAnchor)
|
|
137
|
+
if (thread) focusThread(editor, thread)
|
|
144
138
|
}
|
|
145
139
|
|
|
140
|
+
// The `ThreadRow` component slot overrides the built-in row. It gets the thread record alongside
|
|
141
|
+
// the summarized row, so host state on `thread.meta` is in reach without a second lookup. Indexed
|
|
142
|
+
// once rather than scanned per row — this runs for every row on every render of the panel.
|
|
143
|
+
const ThreadRow = options.components.ThreadRow
|
|
144
|
+
const renderItem = ThreadRow
|
|
145
|
+
? (() => {
|
|
146
|
+
// Keyed by plain string: a row's id comes back as one, not as a branded thread id.
|
|
147
|
+
const threadsById = new Map<string, TLCommentThread>(
|
|
148
|
+
threads.map((thread) => [thread.id, thread])
|
|
149
|
+
)
|
|
150
|
+
return (rowProps: CommentListItemRenderProps) => {
|
|
151
|
+
const thread = threadsById.get(rowProps.id)
|
|
152
|
+
// Can't happen — the items are built from these threads — but a row with no thread has
|
|
153
|
+
// nothing to render, and the slot's contract says the record is there.
|
|
154
|
+
if (!thread) return null
|
|
155
|
+
return <ThreadRow {...rowProps} thread={thread} />
|
|
156
|
+
}
|
|
157
|
+
})()
|
|
158
|
+
: undefined
|
|
159
|
+
|
|
146
160
|
return (
|
|
147
|
-
<SidebarPanel
|
|
161
|
+
<SidebarPanel>
|
|
148
162
|
<CommentsList
|
|
149
163
|
items={items}
|
|
150
164
|
header={header ?? msg('comments.title')}
|
|
151
165
|
headerAction={
|
|
152
|
-
<div className="cmt-list__header-actions">
|
|
166
|
+
<div className="tlui-cmt-list__header-actions">
|
|
153
167
|
<CommentsFilterMenu
|
|
154
|
-
canFilterByAuthor={currentUserId !==
|
|
168
|
+
canFilterByAuthor={currentUserId !== null}
|
|
155
169
|
canFilterByUnread={isCommentUnread !== undefined}
|
|
156
170
|
/>
|
|
157
|
-
<
|
|
171
|
+
<CommentsVisibilityToggle />
|
|
158
172
|
</div>
|
|
159
173
|
}
|
|
160
174
|
empty={
|
|
@@ -164,21 +178,46 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
164
178
|
}
|
|
165
179
|
resolvedLabel={msg('comments.resolved')}
|
|
166
180
|
onSelect={focus}
|
|
181
|
+
renderItem={renderItem}
|
|
167
182
|
/>
|
|
168
183
|
</SidebarPanel>
|
|
169
184
|
)
|
|
170
185
|
}
|
|
171
186
|
|
|
187
|
+
/** A list row paired with the sort key that isn't part of what the row displays. @public */
|
|
188
|
+
export interface SidebarRow {
|
|
189
|
+
item: CommentListItemProps
|
|
190
|
+
/** When the thread's most recent comment was posted — what the list orders by. */
|
|
191
|
+
lastActivity: number
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Order the list: unresolved threads first, then by most recent activity, id as a stable tiebreak.
|
|
196
|
+
* Recency is the thread's *latest* comment, not its first, so a thread someone just replied to rises
|
|
197
|
+
* to the top instead of staying wherever it was started. (The row still shows the thread's opening
|
|
198
|
+
* comment and its date — that's what identifies the thread; only the ordering follows the replies.)
|
|
199
|
+
*
|
|
200
|
+
* Exported so a hand-built list can match the sidebar's ordering instead of re-deriving it.
|
|
201
|
+
*
|
|
202
|
+
* @public
|
|
203
|
+
*/
|
|
204
|
+
export function sortSidebarRows(rows: readonly SidebarRow[]): readonly SidebarRow[] {
|
|
205
|
+
return [...rows].sort(
|
|
206
|
+
(a, b) =>
|
|
207
|
+
Number(!!a.item.resolved) - Number(!!b.item.resolved) ||
|
|
208
|
+
b.lastActivity - a.lastActivity ||
|
|
209
|
+
(a.item.id < b.item.id ? -1 : 1)
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
172
213
|
/** The sidebar surface, portaled into the container. It scrolls its own list, so — unlike tldraw's
|
|
173
|
-
* wheel-transparent panels — a wheel over it doesn't pan the canvas.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
</div>,
|
|
182
|
-
container
|
|
214
|
+
* wheel-transparent panels — a wheel over it doesn't pan the canvas. */
|
|
215
|
+
function SidebarPanel({ children }: { children: ReactNode }) {
|
|
216
|
+
return (
|
|
217
|
+
<EditorPortal>
|
|
218
|
+
<div className="tlui-cmt-canvas-sidebar" onContextMenu={(e) => e.stopPropagation()}>
|
|
219
|
+
{children}
|
|
220
|
+
</div>
|
|
221
|
+
</EditorPortal>
|
|
183
222
|
)
|
|
184
223
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { TldrawUiButton, useEditor, useTranslation, useValue } from 'tldraw'
|
|
2
|
+
import { EyeClosedIcon, EyeOpenIcon } from '../ui/icons'
|
|
3
|
+
import { commentsHidden, toggleCommentsHidden } from './state'
|
|
4
|
+
|
|
5
|
+
/** The sidebar header's show/hide toggle for comment pins — an eye that closes while comments
|
|
6
|
+
* are hidden. The same state as the Shift+C shortcut.
|
|
7
|
+
* @public @react */
|
|
8
|
+
export function CommentsVisibilityToggle() {
|
|
9
|
+
const editor = useEditor()
|
|
10
|
+
const msg = useTranslation()
|
|
11
|
+
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
|
12
|
+
const label = hidden ? msg('comments.show') : msg('comments.hide')
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<TldrawUiButton
|
|
16
|
+
type="icon"
|
|
17
|
+
tooltip={label}
|
|
18
|
+
title={label}
|
|
19
|
+
className="tlui-cmt-header-btn"
|
|
20
|
+
onClick={() => toggleCommentsHidden(editor)}
|
|
21
|
+
>
|
|
22
|
+
{hidden ? <EyeClosedIcon /> : <EyeOpenIcon />}
|
|
23
|
+
</TldrawUiButton>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type CommentAuthor, type MentionMember } from '@tldraw/mentions'
|
|
2
|
+
import { type ReactNode } from 'react'
|
|
3
|
+
import { type TLComment, type TLCommentId, type TLCommentThreadId } from 'tldraw'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The live, host-supplied half of commenting: who the viewer is, how author ids become names, read
|
|
7
|
+
* status, and the mention roster. (The static half is {@link CommentingOptions}, configured once on
|
|
8
|
+
* the tool.)
|
|
9
|
+
*
|
|
10
|
+
* Every commenting surface reads the same set, so a host mounting more than one — `CanvasComments`
|
|
11
|
+
* and `CanvasCommentsSidebar`, say — can build it once and spread it into each rather than
|
|
12
|
+
* repeating the props:
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* const commenting: CommentingContext = { currentUserId, resolveAuthor, isCommentUnread }
|
|
16
|
+
*
|
|
17
|
+
* <CanvasComments {...commenting} />
|
|
18
|
+
* <CanvasCommentsSidebar {...commenting} />
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export interface CommentingContext {
|
|
24
|
+
/** The signed-in user's id, or null for a read-only viewer. Only a signed-in user composes. */
|
|
25
|
+
currentUserId: string | null
|
|
26
|
+
/** Map an author id to their display info, or `undefined` when the id can't be resolved. */
|
|
27
|
+
resolveAuthor(id: string): CommentAuthor | undefined
|
|
28
|
+
/** Called after any comment (a new thread's first comment, or a reply) is posted. */
|
|
29
|
+
onPostComment?(comment: TLComment): void
|
|
30
|
+
/** Whether a comment is unread for the current user (return true for unread). */
|
|
31
|
+
isCommentUnread?(commentId: TLCommentId): boolean
|
|
32
|
+
/**
|
|
33
|
+
* Called with every unread comment shown to the user in an open thread popover, batched per
|
|
34
|
+
* report, so hosts can record read receipts without a write per comment. Needs
|
|
35
|
+
* `isCommentUnread` to know what's unread.
|
|
36
|
+
*/
|
|
37
|
+
onCommentsRead?(commentIds: TLCommentId[]): void
|
|
38
|
+
/** Resolve the members matching an `@`-query in the composers (sync or async). */
|
|
39
|
+
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
|
|
40
|
+
/** Override a mention-picker row's content. */
|
|
41
|
+
renderMentionSuggestion?(member: MentionMember): ReactNode
|
|
42
|
+
/**
|
|
43
|
+
* The host's URL for a thread, so surfaces can link to it — e.g. the sidebar's rows render as
|
|
44
|
+
* anchors when this is present, letting ctrl/cmd-click and middle-click open the thread in a
|
|
45
|
+
* new tab (a plain click still selects the thread in place; the href isn't followed).
|
|
46
|
+
*/
|
|
47
|
+
getThreadHref?(threadId: TLCommentThreadId): string | undefined
|
|
48
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
commentSchemaRecords,
|
|
3
|
+
createComment,
|
|
4
|
+
createCommentThread,
|
|
5
|
+
createTLSchema,
|
|
6
|
+
createTLStore,
|
|
7
|
+
defaultBindingUtils,
|
|
8
|
+
defaultShapeUtils,
|
|
9
|
+
defaultTools,
|
|
10
|
+
Editor,
|
|
11
|
+
toRichText,
|
|
12
|
+
} from 'tldraw'
|
|
13
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
14
|
+
import { putCommentRecords } from './comment-mutations'
|
|
15
|
+
import { getCommentsByThread } from './hooks'
|
|
16
|
+
|
|
17
|
+
let editors: Editor[] = []
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const editor of editors) editor.dispose()
|
|
21
|
+
editors = []
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function makeEditor(): Editor {
|
|
25
|
+
const editor = new Editor({
|
|
26
|
+
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
|
27
|
+
shapeUtils: defaultShapeUtils,
|
|
28
|
+
bindingUtils: defaultBindingUtils,
|
|
29
|
+
tools: defaultTools,
|
|
30
|
+
getContainer: () => document.body,
|
|
31
|
+
})
|
|
32
|
+
editors.push(editor)
|
|
33
|
+
return editor
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeThreadWithComment(editor: Editor, text: string) {
|
|
37
|
+
const pageId = editor.getCurrentPageId()
|
|
38
|
+
const thread = createCommentThread({
|
|
39
|
+
pageId,
|
|
40
|
+
anchor: { type: 'point', x: 0, y: 0 },
|
|
41
|
+
createdBy: 'user1',
|
|
42
|
+
})
|
|
43
|
+
const comment = createComment({
|
|
44
|
+
threadId: thread.id,
|
|
45
|
+
pageId,
|
|
46
|
+
authorId: 'user1',
|
|
47
|
+
body: toRichText(text),
|
|
48
|
+
})
|
|
49
|
+
return { thread, comment }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('getCommentsByThread', () => {
|
|
53
|
+
it('groups live comments by thread, oldest first', () => {
|
|
54
|
+
const editor = makeEditor()
|
|
55
|
+
const { thread, comment } = makeThreadWithComment(editor, 'first')
|
|
56
|
+
const reply = createComment({
|
|
57
|
+
threadId: thread.id,
|
|
58
|
+
pageId: editor.getCurrentPageId(),
|
|
59
|
+
authorId: 'user2',
|
|
60
|
+
body: toRichText('second'),
|
|
61
|
+
now: comment.createdAt + 1,
|
|
62
|
+
})
|
|
63
|
+
// Inserted newest-first to prove the grouping sorts by createdAt rather than store order.
|
|
64
|
+
putCommentRecords(editor, [thread, reply, comment])
|
|
65
|
+
expect(getCommentsByThread(editor).get().get(thread.id)).toEqual([comment, reply])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("reuses a thread's array when another thread's comments change", () => {
|
|
69
|
+
const editor = makeEditor()
|
|
70
|
+
const a = makeThreadWithComment(editor, 'thread a')
|
|
71
|
+
const b = makeThreadWithComment(editor, 'thread b')
|
|
72
|
+
putCommentRecords(editor, [a.thread, a.comment, b.thread, b.comment])
|
|
73
|
+
|
|
74
|
+
const byThread = getCommentsByThread(editor)
|
|
75
|
+
const before = byThread.get()
|
|
76
|
+
|
|
77
|
+
const replyToB = createComment({
|
|
78
|
+
threadId: b.thread.id,
|
|
79
|
+
pageId: editor.getCurrentPageId(),
|
|
80
|
+
authorId: 'user2',
|
|
81
|
+
body: toRichText('reply'),
|
|
82
|
+
now: b.comment.createdAt + 1,
|
|
83
|
+
})
|
|
84
|
+
putCommentRecords(editor, [replyToB])
|
|
85
|
+
const after = byThread.get()
|
|
86
|
+
|
|
87
|
+
expect(after).not.toBe(before)
|
|
88
|
+
// The unchanged thread keeps its exact array, so its subscribers don't re-render...
|
|
89
|
+
expect(after.get(a.thread.id)).toBe(before.get(a.thread.id))
|
|
90
|
+
// ...while the changed thread gets a fresh one with the reply in place.
|
|
91
|
+
expect(after.get(b.thread.id)).not.toBe(before.get(b.thread.id))
|
|
92
|
+
expect(after.get(b.thread.id)).toEqual([b.comment, replyToB])
|
|
93
|
+
})
|
|
94
|
+
})
|
package/src/canvas/hooks.ts
CHANGED
|
@@ -1,28 +1,83 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
Computed,
|
|
4
|
+
Editor,
|
|
5
|
+
isUninitialized,
|
|
6
|
+
TLComment,
|
|
7
|
+
TLCommentThread,
|
|
8
|
+
TLCommentThreadId,
|
|
9
|
+
useValue,
|
|
10
|
+
WeakCache,
|
|
11
|
+
} from 'tldraw'
|
|
12
|
+
import { getLiveComments, getLiveCommentThreads } from './comment-store'
|
|
3
13
|
|
|
4
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The comment threads that should render (pins, sidebar), reactively — the live set described by
|
|
16
|
+
* {@link getLiveCommentThreads}. Use `getCommentThreads` for the unfiltered set, including
|
|
17
|
+
* soft-deleted threads awaiting the server's prune.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
5
21
|
export function useCommentThreads(editor: Editor): TLCommentThread[] {
|
|
6
|
-
return useValue('comment threads', () =>
|
|
22
|
+
return useValue('comment threads', () => getLiveCommentThreads(editor), [editor])
|
|
7
23
|
}
|
|
8
24
|
|
|
9
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Every live comment grouped by thread, oldest first within each thread. One computed shared by
|
|
27
|
+
* every mounted thread: each `useThreadComments` would otherwise scan and sort the whole comment
|
|
28
|
+
* set, so N open threads did N full passes on every comment mutation. Cached per editor so the
|
|
29
|
+
* grouping survives re-renders and is rebuilt only when the comment set actually changes.
|
|
30
|
+
*/
|
|
31
|
+
const commentsByThread = new WeakCache<Editor, Computed<Map<TLCommentThreadId, TLComment[]>>>()
|
|
32
|
+
|
|
33
|
+
/** Exported for tests; use {@link useThreadComments} to consume. @internal */
|
|
34
|
+
export function getCommentsByThread(editor: Editor) {
|
|
35
|
+
return commentsByThread.get(editor, () =>
|
|
36
|
+
computed('comments by thread', (prev) => {
|
|
37
|
+
const byThread = new Map<TLCommentThreadId, TLComment[]>()
|
|
38
|
+
for (const comment of getLiveComments(editor)) {
|
|
39
|
+
const existing = byThread.get(comment.threadId)
|
|
40
|
+
if (existing) existing.push(comment)
|
|
41
|
+
else byThread.set(comment.threadId, [comment])
|
|
42
|
+
}
|
|
43
|
+
for (const [threadId, comments] of byThread) {
|
|
44
|
+
comments.sort((a, b) => a.createdAt - b.createdAt)
|
|
45
|
+
// Reuse the previous per-thread array when that thread's comment list is unchanged
|
|
46
|
+
// (record identities are stable while unchanged), so `useThreadComments` subscribers
|
|
47
|
+
// on other threads don't re-render when one thread gains a reply.
|
|
48
|
+
if (isUninitialized(prev)) continue
|
|
49
|
+
const prevComments = prev.get(threadId)
|
|
50
|
+
if (
|
|
51
|
+
prevComments &&
|
|
52
|
+
prevComments.length === comments.length &&
|
|
53
|
+
comments.every((comment, i) => prevComments[i] === comment)
|
|
54
|
+
) {
|
|
55
|
+
byThread.set(threadId, prevComments)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return byThread
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A thread's live comments, oldest first, reactively. @public */
|
|
10
64
|
export function useThreadComments(editor: Editor, threadId: TLCommentThreadId): TLComment[] {
|
|
11
65
|
return useValue(
|
|
12
66
|
'thread comments',
|
|
13
|
-
() =>
|
|
14
|
-
getComments(editor)
|
|
15
|
-
.filter((c) => c.threadId === threadId)
|
|
16
|
-
.sort((a, b) => a.createdAt - b.createdAt),
|
|
67
|
+
() => getCommentsByThread(editor).get().get(threadId) ?? EMPTY_COMMENTS,
|
|
17
68
|
[editor, threadId]
|
|
18
69
|
)
|
|
19
70
|
}
|
|
20
71
|
|
|
21
|
-
/**
|
|
72
|
+
/** Shared empty result so a thread with no comments doesn't churn referential equality. */
|
|
73
|
+
const EMPTY_COMMENTS: TLComment[] = []
|
|
74
|
+
|
|
75
|
+
/** Every live comment in the store ({@link getLiveComments}), oldest first, reactively. Group by
|
|
76
|
+
* `threadId` for per-thread lists. @public */
|
|
22
77
|
export function useComments(editor: Editor): TLComment[] {
|
|
23
78
|
return useValue(
|
|
24
79
|
'all comments',
|
|
25
|
-
() =>
|
|
80
|
+
() => getLiveComments(editor).sort((a, b) => a.createdAt - b.createdAt),
|
|
26
81
|
[editor]
|
|
27
82
|
)
|
|
28
83
|
}
|
package/src/canvas/license.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { useLicenseContext, useLicenseFeatureFlag } from 'tldraw'
|
|
|
7
7
|
* pending, so gated UI stays hidden until the license is confirmed.
|
|
8
8
|
*
|
|
9
9
|
* The built-in commenting components (`CanvasComments`, `CanvasCommentsSidebar`, and the comment
|
|
10
|
-
* tool's
|
|
10
|
+
* tool's Quick Action) gate on this. Use it to gate any custom commenting UI the same way.
|
|
11
11
|
* @public
|
|
12
12
|
*/
|
|
13
13
|
export function useCommentingEnabled(): boolean {
|