@tldraw/commenting 0.0.0-bootstrap
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 +11 -0
- package/commenting.css +832 -0
- package/dist-cjs/canvas/cluster-input.js +40 -0
- package/dist-cjs/canvas/cluster-input.js.map +7 -0
- package/dist-cjs/canvas/comment-body.js +31 -0
- package/dist-cjs/canvas/comment-body.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +72 -0
- package/dist-cjs/canvas/comment-render.js.map +7 -0
- package/dist-cjs/canvas/comment-store.js +48 -0
- package/dist-cjs/canvas/comment-store.js.map +7 -0
- package/dist-cjs/canvas/comment-tool.js +160 -0
- package/dist-cjs/canvas/comment-tool.js.map +7 -0
- package/dist-cjs/canvas/comments-filter-menu.js +109 -0
- package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-menu-item.js +42 -0
- package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
- package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
- package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-overlay.js +1081 -0
- package/dist-cjs/canvas/comments-overlay.js.map +7 -0
- package/dist-cjs/canvas/comments-sidebar.js +134 -0
- package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
- package/dist-cjs/canvas/hooks.js +45 -0
- package/dist-cjs/canvas/hooks.js.map +7 -0
- package/dist-cjs/canvas/license.js +28 -0
- package/dist-cjs/canvas/license.js.map +7 -0
- package/dist-cjs/canvas/options.js +45 -0
- package/dist-cjs/canvas/options.js.map +7 -0
- package/dist-cjs/canvas/region-options.js +49 -0
- package/dist-cjs/canvas/region-options.js.map +7 -0
- package/dist-cjs/canvas/rich-text.js +28 -0
- package/dist-cjs/canvas/rich-text.js.map +7 -0
- package/dist-cjs/canvas/sidebar-filters.js +30 -0
- package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
- package/dist-cjs/canvas/state.js +76 -0
- package/dist-cjs/canvas/state.js.map +7 -0
- package/dist-cjs/canvas/thread-state.js +81 -0
- package/dist-cjs/canvas/thread-state.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +83 -0
- package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
- package/dist-cjs/clustering/mst.js +117 -0
- package/dist-cjs/clustering/mst.js.map +7 -0
- package/dist-cjs/clustering/replay.js +275 -0
- package/dist-cjs/clustering/replay.js.map +7 -0
- package/dist-cjs/clustering/runtime.js +134 -0
- package/dist-cjs/clustering/runtime.js.map +7 -0
- package/dist-cjs/clustering/schedule.js +170 -0
- package/dist-cjs/clustering/schedule.js.map +7 -0
- package/dist-cjs/clustering/types.js +17 -0
- package/dist-cjs/clustering/types.js.map +7 -0
- package/dist-cjs/index.d.ts +868 -0
- package/dist-cjs/index.js +126 -0
- package/dist-cjs/index.js.map +7 -0
- package/dist-cjs/ui/avatar.js +43 -0
- package/dist-cjs/ui/avatar.js.map +7 -0
- package/dist-cjs/ui/byline.js +35 -0
- package/dist-cjs/ui/byline.js.map +7 -0
- package/dist-cjs/ui/comment-card.js +37 -0
- package/dist-cjs/ui/comment-card.js.map +7 -0
- package/dist-cjs/ui/comment-composer.js +136 -0
- package/dist-cjs/ui/comment-composer.js.map +7 -0
- package/dist-cjs/ui/comment-extensions.js +37 -0
- package/dist-cjs/ui/comment-extensions.js.map +7 -0
- package/dist-cjs/ui/comment-mention.js +42 -0
- package/dist-cjs/ui/comment-mention.js.map +7 -0
- package/dist-cjs/ui/comment-pin.js +29 -0
- package/dist-cjs/ui/comment-pin.js.map +7 -0
- package/dist-cjs/ui/comment-text.js +29 -0
- package/dist-cjs/ui/comment-text.js.map +7 -0
- package/dist-cjs/ui/comment-thread.js +45 -0
- package/dist-cjs/ui/comment-thread.js.map +7 -0
- package/dist-cjs/ui/comments-list.js +106 -0
- package/dist-cjs/ui/comments-list.js.map +7 -0
- package/dist-cjs/ui/count-badge.js +28 -0
- package/dist-cjs/ui/count-badge.js.map +7 -0
- package/dist-cjs/ui/empty-state.js +31 -0
- package/dist-cjs/ui/empty-state.js.map +7 -0
- package/dist-cjs/ui/format-time.js +46 -0
- package/dist-cjs/ui/format-time.js.map +7 -0
- package/dist-cjs/ui/mention-list.js +70 -0
- package/dist-cjs/ui/mention-list.js.map +7 -0
- package/dist-cjs/ui/mention-suggestion.js +206 -0
- package/dist-cjs/ui/mention-suggestion.js.map +7 -0
- package/dist-cjs/ui/mention.js +31 -0
- package/dist-cjs/ui/mention.js.map +7 -0
- package/dist-cjs/ui/reaction.js +31 -0
- package/dist-cjs/ui/reaction.js.map +7 -0
- package/dist-cjs/ui/reactions.js +34 -0
- package/dist-cjs/ui/reactions.js.map +7 -0
- package/dist-cjs/ui/render-markdown.js +63 -0
- package/dist-cjs/ui/render-markdown.js.map +7 -0
- package/dist-cjs/ui/send-button.js +28 -0
- package/dist-cjs/ui/send-button.js.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +20 -0
- package/dist-esm/canvas/cluster-input.mjs.map +7 -0
- package/dist-esm/canvas/comment-body.mjs +11 -0
- package/dist-esm/canvas/comment-body.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +52 -0
- package/dist-esm/canvas/comment-render.mjs.map +7 -0
- package/dist-esm/canvas/comment-store.mjs +28 -0
- package/dist-esm/canvas/comment-store.mjs.map +7 -0
- package/dist-esm/canvas/comment-tool.mjs +142 -0
- package/dist-esm/canvas/comment-tool.mjs.map +7 -0
- package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
- package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-menu-item.mjs +22 -0
- package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-overlay.mjs +1094 -0
- package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
- package/dist-esm/canvas/comments-sidebar.mjs +120 -0
- package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
- package/dist-esm/canvas/hooks.mjs +25 -0
- package/dist-esm/canvas/hooks.mjs.map +7 -0
- package/dist-esm/canvas/license.mjs +8 -0
- package/dist-esm/canvas/license.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +27 -0
- package/dist-esm/canvas/options.mjs.map +7 -0
- package/dist-esm/canvas/region-options.mjs +29 -0
- package/dist-esm/canvas/region-options.mjs.map +7 -0
- package/dist-esm/canvas/rich-text.mjs +8 -0
- package/dist-esm/canvas/rich-text.mjs.map +7 -0
- package/dist-esm/canvas/sidebar-filters.mjs +10 -0
- package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
- package/dist-esm/canvas/state.mjs +60 -0
- package/dist-esm/canvas/state.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +61 -0
- package/dist-esm/canvas/thread-state.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +63 -0
- package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
- package/dist-esm/clustering/mst.mjs +97 -0
- package/dist-esm/clustering/mst.mjs.map +7 -0
- package/dist-esm/clustering/replay.mjs +255 -0
- package/dist-esm/clustering/replay.mjs.map +7 -0
- package/dist-esm/clustering/runtime.mjs +114 -0
- package/dist-esm/clustering/runtime.mjs.map +7 -0
- package/dist-esm/clustering/schedule.mjs +150 -0
- package/dist-esm/clustering/schedule.mjs.map +7 -0
- package/dist-esm/clustering/types.mjs +1 -0
- package/dist-esm/clustering/types.mjs.map +7 -0
- package/dist-esm/index.d.mts +868 -0
- package/dist-esm/index.mjs +139 -0
- package/dist-esm/index.mjs.map +7 -0
- package/dist-esm/ui/avatar.mjs +23 -0
- package/dist-esm/ui/avatar.mjs.map +7 -0
- package/dist-esm/ui/byline.mjs +15 -0
- package/dist-esm/ui/byline.mjs.map +7 -0
- package/dist-esm/ui/comment-card.mjs +17 -0
- package/dist-esm/ui/comment-card.mjs.map +7 -0
- package/dist-esm/ui/comment-composer.mjs +116 -0
- package/dist-esm/ui/comment-composer.mjs.map +7 -0
- package/dist-esm/ui/comment-extensions.mjs +17 -0
- package/dist-esm/ui/comment-extensions.mjs.map +7 -0
- package/dist-esm/ui/comment-mention.mjs +22 -0
- package/dist-esm/ui/comment-mention.mjs.map +7 -0
- package/dist-esm/ui/comment-pin.mjs +9 -0
- package/dist-esm/ui/comment-pin.mjs.map +7 -0
- package/dist-esm/ui/comment-text.mjs +9 -0
- package/dist-esm/ui/comment-text.mjs.map +7 -0
- package/dist-esm/ui/comment-thread.mjs +25 -0
- package/dist-esm/ui/comment-thread.mjs.map +7 -0
- package/dist-esm/ui/comments-list.mjs +86 -0
- package/dist-esm/ui/comments-list.mjs.map +7 -0
- package/dist-esm/ui/count-badge.mjs +8 -0
- package/dist-esm/ui/count-badge.mjs.map +7 -0
- package/dist-esm/ui/empty-state.mjs +11 -0
- package/dist-esm/ui/empty-state.mjs.map +7 -0
- package/dist-esm/ui/format-time.mjs +26 -0
- package/dist-esm/ui/format-time.mjs.map +7 -0
- package/dist-esm/ui/mention-list.mjs +50 -0
- package/dist-esm/ui/mention-list.mjs.map +7 -0
- package/dist-esm/ui/mention-suggestion.mjs +186 -0
- package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
- package/dist-esm/ui/mention.mjs +11 -0
- package/dist-esm/ui/mention.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +11 -0
- package/dist-esm/ui/reaction.mjs.map +7 -0
- package/dist-esm/ui/reactions.mjs +14 -0
- package/dist-esm/ui/reactions.mjs.map +7 -0
- package/dist-esm/ui/render-markdown.mjs +45 -0
- package/dist-esm/ui/render-markdown.mjs.map +7 -0
- package/dist-esm/ui/send-button.mjs +8 -0
- package/dist-esm/ui/send-button.mjs.map +7 -0
- package/package.json +71 -0
- package/src/canvas/canvas.css +148 -0
- package/src/canvas/cluster-input.test.ts +223 -0
- package/src/canvas/cluster-input.ts +29 -0
- package/src/canvas/comment-body.tsx +21 -0
- package/src/canvas/comment-render.test.ts +90 -0
- package/src/canvas/comment-render.ts +92 -0
- package/src/canvas/comment-store.ts +55 -0
- package/src/canvas/comment-tool.tsx +200 -0
- package/src/canvas/comments-filter-menu.tsx +104 -0
- package/src/canvas/comments-menu-item.tsx +24 -0
- package/src/canvas/comments-overflow-menu.tsx +62 -0
- package/src/canvas/comments-overlay.tsx +1460 -0
- package/src/canvas/comments-sidebar.tsx +184 -0
- package/src/canvas/hooks.ts +28 -0
- package/src/canvas/license.ts +15 -0
- package/src/canvas/options.test.ts +127 -0
- package/src/canvas/options.ts +111 -0
- package/src/canvas/region-options.ts +57 -0
- package/src/canvas/rich-text.ts +15 -0
- package/src/canvas/sidebar-filters.ts +25 -0
- package/src/canvas/state.ts +102 -0
- package/src/canvas/thread-state.ts +91 -0
- package/src/clustering/computeClusterTable.test.ts +299 -0
- package/src/clustering/computeClusterTable.ts +82 -0
- package/src/clustering/locality.test.ts +189 -0
- package/src/clustering/mst.test.ts +383 -0
- package/src/clustering/mst.ts +134 -0
- package/src/clustering/replay.test.ts +547 -0
- package/src/clustering/replay.ts +326 -0
- package/src/clustering/runtime.test.ts +596 -0
- package/src/clustering/runtime.ts +153 -0
- package/src/clustering/schedule.test.ts +514 -0
- package/src/clustering/schedule.ts +184 -0
- package/src/clustering/types.ts +102 -0
- package/src/index.ts +96 -0
- package/src/ui/avatar.tsx +31 -0
- package/src/ui/byline.tsx +23 -0
- package/src/ui/comment-card.tsx +32 -0
- package/src/ui/comment-composer.tsx +179 -0
- package/src/ui/comment-extensions.ts +25 -0
- package/src/ui/comment-mention.ts +47 -0
- package/src/ui/comment-pin.tsx +21 -0
- package/src/ui/comment-text.tsx +12 -0
- package/src/ui/comment-thread.tsx +56 -0
- package/src/ui/comments-list.tsx +139 -0
- package/src/ui/comments.css +678 -0
- package/src/ui/count-badge.tsx +9 -0
- package/src/ui/empty-state.tsx +16 -0
- package/src/ui/format-time.ts +29 -0
- package/src/ui/mention-list.tsx +106 -0
- package/src/ui/mention-suggestion.test.ts +18 -0
- package/src/ui/mention-suggestion.tsx +285 -0
- package/src/ui/mention.tsx +9 -0
- package/src/ui/reaction.tsx +16 -0
- package/src/ui/reactions.tsx +15 -0
- package/src/ui/render-markdown.tsx +72 -0
- package/src/ui/send-button.tsx +15 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/* The comments layer is portaled into the editor container. Pins live in the canvas-in-front layer
|
|
2
|
+
(beneath the UI panels at z-index 300), so the toolbar and style panel draw over them; the open
|
|
3
|
+
thread's popover portals up to the menus layer separately (`.cmt-canvas-popover`) so it isn't
|
|
4
|
+
clipped. Click-through except on its own children. */
|
|
5
|
+
.cmt-canvas-layer {
|
|
6
|
+
position: absolute;
|
|
7
|
+
inset: 0;
|
|
8
|
+
z-index: var(--tl-layer-canvas-in-front);
|
|
9
|
+
pointer-events: none;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.cmt-canvas-pin,
|
|
13
|
+
.cmt-canvas-cluster,
|
|
14
|
+
.cmt-canvas-composer {
|
|
15
|
+
position: absolute;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.cmt-canvas-pin,
|
|
19
|
+
.cmt-canvas-composer {
|
|
20
|
+
pointer-events: auto;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Badges are clickable (zoom to the cluster's first split). */
|
|
24
|
+
.cmt-canvas-cluster {
|
|
25
|
+
transform: translate(-50%, -50%);
|
|
26
|
+
pointer-events: auto;
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.cmt-cluster-fade {
|
|
31
|
+
opacity: 1;
|
|
32
|
+
transition: opacity 150ms ease;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.cmt-cluster-fade--entering {
|
|
36
|
+
opacity: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.cmt-cluster-fade--present {
|
|
40
|
+
opacity: 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.cmt-cluster-fade--exiting {
|
|
44
|
+
opacity: 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.cmt-cluster-fade--entering *,
|
|
48
|
+
.cmt-cluster-fade--exiting * {
|
|
49
|
+
pointer-events: none !important;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@media (prefers-reduced-motion: reduce) {
|
|
53
|
+
.cmt-cluster-fade {
|
|
54
|
+
transition: none;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* The open thread (its pin + popover) sits above the other pins, so nearby markers don't
|
|
59
|
+
overlap its popover — they tuck behind it. */
|
|
60
|
+
.cmt-canvas-pin--open {
|
|
61
|
+
z-index: 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* A region anchor's area: a dashed box under the pins, non-interactive so canvas events pass
|
|
65
|
+
through. Drawn for the live drag draft and for a region thread while hovered or open. */
|
|
66
|
+
.cmt-canvas-region {
|
|
67
|
+
position: absolute;
|
|
68
|
+
pointer-events: none;
|
|
69
|
+
border: 2px dashed var(--tl-color-selected);
|
|
70
|
+
border-radius: 6px;
|
|
71
|
+
background: color-mix(in srgb, var(--tl-color-selected) 10%, transparent);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* A movable region body (the 'body'/'both' move modes): draggable to translate the whole region.
|
|
75
|
+
Interactive, so it captures the interior while shown — the tradeoff of dragging the body. */
|
|
76
|
+
.cmt-canvas-region--movable {
|
|
77
|
+
pointer-events: auto;
|
|
78
|
+
cursor: move;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Corner resize handles on a region box (three corners; the pin corner is the move handle). */
|
|
82
|
+
.cmt-canvas-region-handle {
|
|
83
|
+
position: absolute;
|
|
84
|
+
width: 9px;
|
|
85
|
+
height: 9px;
|
|
86
|
+
transform: translate(-50%, -50%);
|
|
87
|
+
background: var(--tl-color-panel);
|
|
88
|
+
border: 1.5px solid var(--tl-color-selected);
|
|
89
|
+
border-radius: 2px;
|
|
90
|
+
pointer-events: auto;
|
|
91
|
+
touch-action: none;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Centre the pin marker on its anchor point. */
|
|
95
|
+
.cmt-canvas-pin__marker {
|
|
96
|
+
width: max-content;
|
|
97
|
+
transform: translate(-50%, -50%);
|
|
98
|
+
cursor: grab;
|
|
99
|
+
touch-action: none;
|
|
100
|
+
}
|
|
101
|
+
.cmt-canvas-pin__marker:active {
|
|
102
|
+
cursor: grabbing;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* The open thread's popover — portaled to the menus layer (above the UI), positioned at the pin. */
|
|
106
|
+
.cmt-canvas-popover {
|
|
107
|
+
position: absolute;
|
|
108
|
+
z-index: var(--tl-layer-menus);
|
|
109
|
+
pointer-events: auto;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* The placement composer — a distinct floating view (portaled to the menus layer, below the click
|
|
113
|
+
point): a pencil "draft" avatar beside a pill field that is itself the surface (no wrapping
|
|
114
|
+
panel). Scoped here so the in-thread reply composer keeps its own squarer look. */
|
|
115
|
+
.cmt-canvas-composer {
|
|
116
|
+
position: absolute;
|
|
117
|
+
z-index: var(--tl-layer-menus);
|
|
118
|
+
pointer-events: auto;
|
|
119
|
+
transform: translateY(8px);
|
|
120
|
+
/* The rich-text editor has no intrinsic width (unlike the old <input>), so give the floating
|
|
121
|
+
placement composer a definite width or it collapses to nothing. */
|
|
122
|
+
width: 280px;
|
|
123
|
+
}
|
|
124
|
+
.cmt-canvas-composer .cmt-composer__field {
|
|
125
|
+
background: var(--tl-color-panel);
|
|
126
|
+
border-color: transparent;
|
|
127
|
+
border-radius: var(--tl-radius-4);
|
|
128
|
+
box-shadow: var(--tl-shadow-2);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* The comments list panel — a right-side surface shown while the comment tool is active. Below
|
|
132
|
+
the popover layer, so an open thread still draws over it. Sits under the top-right share panel
|
|
133
|
+
(top offset clears its ~48px height) and sizes to its content, scrolling past max-height rather
|
|
134
|
+
than stretching the full canvas height. */
|
|
135
|
+
.cmt-canvas-sidebar {
|
|
136
|
+
position: absolute;
|
|
137
|
+
top: 56px;
|
|
138
|
+
right: 8px;
|
|
139
|
+
width: 280px;
|
|
140
|
+
max-height: calc(100% - 64px);
|
|
141
|
+
z-index: var(--tl-layer-panels);
|
|
142
|
+
pointer-events: auto;
|
|
143
|
+
display: flex;
|
|
144
|
+
background: var(--tl-color-panel);
|
|
145
|
+
border-radius: var(--tl-radius-4);
|
|
146
|
+
box-shadow: var(--tl-shadow-2);
|
|
147
|
+
overflow: hidden;
|
|
148
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Editor, TLCommentAnchor, TLCommentThread } from 'tldraw'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
// This import is red until step 6's filter module is implemented — that is
|
|
4
|
+
// intentional. Implement `cluster-input.ts` per CLUSTERING-STEPS.md step 6
|
|
5
|
+
// until this suite passes, without modifying this file.
|
|
6
|
+
import { collectClusterLeaves } from './cluster-input'
|
|
7
|
+
|
|
8
|
+
const CURRENT_PAGE = 'page:one'
|
|
9
|
+
const OTHER_PAGE = 'page:two'
|
|
10
|
+
|
|
11
|
+
/** Minimal thread record for filter tests. */
|
|
12
|
+
function thread(
|
|
13
|
+
id: string,
|
|
14
|
+
anchor: TLCommentAnchor,
|
|
15
|
+
opts: { pageId?: string; resolved?: boolean } = {}
|
|
16
|
+
): TLCommentThread {
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
typeName: 'comment-thread',
|
|
20
|
+
pageId: opts.pageId ?? CURRENT_PAGE,
|
|
21
|
+
anchor,
|
|
22
|
+
createdBy: 'user:1',
|
|
23
|
+
createdAt: 0,
|
|
24
|
+
resolved: opts.resolved ? { at: 1, by: 'user:1' } : null,
|
|
25
|
+
meta: {},
|
|
26
|
+
} as unknown as TLCommentThread
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stub editor: the filter's only editor dependencies are the current page id
|
|
31
|
+
* and shape page bounds (via anchorPagePoint). `shapes` maps shape id → bounds
|
|
32
|
+
* for shapes that exist; anything else resolves to undefined (deleted shape).
|
|
33
|
+
*/
|
|
34
|
+
function stubEditor(
|
|
35
|
+
shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {}
|
|
36
|
+
): Editor {
|
|
37
|
+
return {
|
|
38
|
+
getCurrentPageId: () => CURRENT_PAGE,
|
|
39
|
+
getShapePageBounds: (id: string) => {
|
|
40
|
+
const bounds = shapes[id]
|
|
41
|
+
if (!bounds) return undefined
|
|
42
|
+
return { ...bounds, w: bounds.maxX - bounds.minX, h: bounds.maxY - bounds.minY }
|
|
43
|
+
},
|
|
44
|
+
} as unknown as Editor
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function leafIds(leaves: { id: string }[]): string[] {
|
|
48
|
+
return leaves.map((l) => l.id).sort()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('collectClusterLeaves anchor resolution', () => {
|
|
52
|
+
it('maps point anchors to their page coordinates', () => {
|
|
53
|
+
const leaves = collectClusterLeaves(
|
|
54
|
+
stubEditor(),
|
|
55
|
+
[thread('t1', { type: 'point', x: 12, y: 34 })],
|
|
56
|
+
null
|
|
57
|
+
)
|
|
58
|
+
expect(leaves).toEqual([{ id: 't1', point: { x: 12, y: 34 } }])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('maps region anchors to their bottom-right corner (x + w, y + h)', () => {
|
|
62
|
+
const leaves = collectClusterLeaves(
|
|
63
|
+
stubEditor(),
|
|
64
|
+
[thread('t1', { type: 'region', x: 10, y: 20, w: 30, h: 40 })],
|
|
65
|
+
null
|
|
66
|
+
)
|
|
67
|
+
expect(leaves).toEqual([{ id: 't1', point: { x: 40, y: 60 } }])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('maps shape and text-range anchors to the shape bounds top-right corner', () => {
|
|
71
|
+
const editor = stubEditor({
|
|
72
|
+
'shape:a': { minX: 0, minY: 5, maxX: 100, maxY: 50 },
|
|
73
|
+
})
|
|
74
|
+
const leaves = collectClusterLeaves(
|
|
75
|
+
editor,
|
|
76
|
+
[
|
|
77
|
+
thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false }),
|
|
78
|
+
thread('t2', { type: 'text-range', shapeId: 'shape:a' as any, from: 0, to: 3 }),
|
|
79
|
+
],
|
|
80
|
+
null
|
|
81
|
+
)
|
|
82
|
+
expect(leaves).toEqual([
|
|
83
|
+
{ id: 't1', point: { x: 100, y: 5 } },
|
|
84
|
+
{ id: 't2', point: { x: 100, y: 5 } },
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('places imprecise shape leaves at a custom impreciseShapeAnchor, matching pin rendering', () => {
|
|
89
|
+
const editor = stubEditor({
|
|
90
|
+
'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 },
|
|
91
|
+
})
|
|
92
|
+
const threads = [
|
|
93
|
+
thread('imprecise', {
|
|
94
|
+
type: 'shape',
|
|
95
|
+
shapeId: 'shape:a' as any,
|
|
96
|
+
x: 0,
|
|
97
|
+
y: 0,
|
|
98
|
+
isPrecise: false,
|
|
99
|
+
}),
|
|
100
|
+
thread('precise', {
|
|
101
|
+
type: 'shape',
|
|
102
|
+
shapeId: 'shape:a' as any,
|
|
103
|
+
x: 0.5,
|
|
104
|
+
y: 0.5,
|
|
105
|
+
isPrecise: true,
|
|
106
|
+
}),
|
|
107
|
+
]
|
|
108
|
+
// bottom-center instead of the default top-right
|
|
109
|
+
const leaves = collectClusterLeaves(editor, threads, null, { x: 0.5, y: 1 })
|
|
110
|
+
expect(leaves).toEqual([
|
|
111
|
+
{ id: 'imprecise', point: { x: 50, y: 50 } },
|
|
112
|
+
{ id: 'precise', point: { x: 50, y: 25 } },
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('excludes shape anchors whose shape no longer resolves', () => {
|
|
117
|
+
const leaves = collectClusterLeaves(
|
|
118
|
+
stubEditor({}), // no shapes exist
|
|
119
|
+
[
|
|
120
|
+
thread('gone', {
|
|
121
|
+
type: 'shape',
|
|
122
|
+
shapeId: 'shape:deleted' as any,
|
|
123
|
+
x: 0,
|
|
124
|
+
y: 0,
|
|
125
|
+
isPrecise: false,
|
|
126
|
+
}),
|
|
127
|
+
thread('kept', { type: 'point', x: 1, y: 2 }),
|
|
128
|
+
],
|
|
129
|
+
null
|
|
130
|
+
)
|
|
131
|
+
expect(leafIds(leaves)).toEqual(['kept'])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('excludes page anchors (no spatial position)', () => {
|
|
135
|
+
const leaves = collectClusterLeaves(
|
|
136
|
+
stubEditor(),
|
|
137
|
+
[thread('pageThread', { type: 'page' }), thread('kept', { type: 'point', x: 1, y: 2 })],
|
|
138
|
+
null
|
|
139
|
+
)
|
|
140
|
+
expect(leafIds(leaves)).toEqual(['kept'])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('collectClusterLeaves filtering', () => {
|
|
145
|
+
it('excludes threads on other pages', () => {
|
|
146
|
+
const leaves = collectClusterLeaves(
|
|
147
|
+
stubEditor(),
|
|
148
|
+
[
|
|
149
|
+
thread('here', { type: 'point', x: 0, y: 0 }),
|
|
150
|
+
thread('elsewhere', { type: 'point', x: 0, y: 0 }, { pageId: OTHER_PAGE }),
|
|
151
|
+
],
|
|
152
|
+
null
|
|
153
|
+
)
|
|
154
|
+
expect(leafIds(leaves)).toEqual(['here'])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('excludes the open thread, and only the open thread', () => {
|
|
158
|
+
const threads = [
|
|
159
|
+
thread('open', { type: 'point', x: 0, y: 0 }),
|
|
160
|
+
thread('closed', { type: 'point', x: 10, y: 0 }),
|
|
161
|
+
]
|
|
162
|
+
expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'open'))).toEqual(['closed'])
|
|
163
|
+
expect(leafIds(collectClusterLeaves(stubEditor(), threads, null))).toEqual(['closed', 'open'])
|
|
164
|
+
// an id that matches no thread excludes nothing
|
|
165
|
+
expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'unknown'))).toEqual([
|
|
166
|
+
'closed',
|
|
167
|
+
'open',
|
|
168
|
+
])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('includes resolved threads (v1 decision — resolve is appearance-only)', () => {
|
|
172
|
+
const leaves = collectClusterLeaves(
|
|
173
|
+
stubEditor(),
|
|
174
|
+
[
|
|
175
|
+
thread('resolved', { type: 'point', x: 0, y: 0 }, { resolved: true }),
|
|
176
|
+
thread('unresolved', { type: 'point', x: 10, y: 0 }),
|
|
177
|
+
],
|
|
178
|
+
null
|
|
179
|
+
)
|
|
180
|
+
expect(leafIds(leaves)).toEqual(['resolved', 'unresolved'])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('returns [] for no threads', () => {
|
|
184
|
+
expect(collectClusterLeaves(stubEditor(), [], null)).toEqual([])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('applies every rule at once on a mixed set', () => {
|
|
188
|
+
const editor = stubEditor({
|
|
189
|
+
'shape:live': { minX: 0, minY: 0, maxX: 10, maxY: 10 },
|
|
190
|
+
})
|
|
191
|
+
const threads = [
|
|
192
|
+
thread('point', { type: 'point', x: 1, y: 2 }),
|
|
193
|
+
thread('region', { type: 'region', x: 0, y: 0, w: 5, h: 5 }),
|
|
194
|
+
thread('onShape', {
|
|
195
|
+
type: 'shape',
|
|
196
|
+
shapeId: 'shape:live' as any,
|
|
197
|
+
x: 0,
|
|
198
|
+
y: 0,
|
|
199
|
+
isPrecise: false,
|
|
200
|
+
}),
|
|
201
|
+
thread('orphaned', {
|
|
202
|
+
type: 'shape',
|
|
203
|
+
shapeId: 'shape:gone' as any,
|
|
204
|
+
x: 0,
|
|
205
|
+
y: 0,
|
|
206
|
+
isPrecise: false,
|
|
207
|
+
}),
|
|
208
|
+
thread('pageLevel', { type: 'page' }),
|
|
209
|
+
thread('otherPage', { type: 'point', x: 9, y: 9 }, { pageId: OTHER_PAGE }),
|
|
210
|
+
thread('openOne', { type: 'point', x: 3, y: 3 }),
|
|
211
|
+
thread('resolvedOne', { type: 'point', x: 4, y: 4 }, { resolved: true }),
|
|
212
|
+
]
|
|
213
|
+
const leaves = collectClusterLeaves(editor, threads, 'openOne')
|
|
214
|
+
expect(leafIds(leaves)).toEqual(['onShape', 'point', 'region', 'resolvedOne'])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('does not mutate the threads array', () => {
|
|
218
|
+
const threads = [thread('a', { type: 'point', x: 0, y: 0 }), thread('b', { type: 'page' })]
|
|
219
|
+
const snapshot = JSON.parse(JSON.stringify(threads))
|
|
220
|
+
collectClusterLeaves(stubEditor(), threads, 'a')
|
|
221
|
+
expect(JSON.parse(JSON.stringify(threads))).toEqual(snapshot)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Editor, TLCommentThread } from 'tldraw'
|
|
2
|
+
import type { LeafInput } from '../clustering/types'
|
|
3
|
+
import { anchorPagePoint } from './thread-state'
|
|
4
|
+
|
|
5
|
+
/** @public */
|
|
6
|
+
export function collectClusterLeaves(
|
|
7
|
+
editor: Editor,
|
|
8
|
+
threads: readonly TLCommentThread[],
|
|
9
|
+
openThreadId: string | null,
|
|
10
|
+
impreciseShapeAnchor?: { x: number; y: number }
|
|
11
|
+
): LeafInput[] {
|
|
12
|
+
const pageId = editor.getCurrentPageId()
|
|
13
|
+
const leaves: LeafInput[] = []
|
|
14
|
+
|
|
15
|
+
for (const thread of threads) {
|
|
16
|
+
if (thread.id === openThreadId) continue
|
|
17
|
+
if (thread.pageId !== pageId) continue
|
|
18
|
+
|
|
19
|
+
const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
|
|
20
|
+
if (!point) continue
|
|
21
|
+
|
|
22
|
+
leaves.push({
|
|
23
|
+
id: thread.id,
|
|
24
|
+
point: { x: point.x, y: point.y },
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return leaves
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { TLRichText } from 'tldraw'
|
|
3
|
+
import { renderCommentHtml } from './comment-render'
|
|
4
|
+
|
|
5
|
+
/** @public */
|
|
6
|
+
export interface CommentBodyProps {
|
|
7
|
+
richText: TLRichText
|
|
8
|
+
/** Maps a member id to its current display name, so \@mentions show the live name. */
|
|
9
|
+
resolveName?(id: string): string | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders a comment's rich-text body read-only through the limited comment extension set (no
|
|
14
|
+
* headings), so formatting (bold, links, lists, highlight) is preserved rather than flattened, and
|
|
15
|
+
* headings can never render. Use this as the `body` of a `CommentCard` on a canvas.
|
|
16
|
+
* @public @react
|
|
17
|
+
*/
|
|
18
|
+
export function CommentBody({ richText, resolveName }: CommentBodyProps) {
|
|
19
|
+
const html = useMemo(() => renderCommentHtml(richText, resolveName), [richText, resolveName])
|
|
20
|
+
return <div className="cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
|
|
21
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
|
3
|
+
import { renderCommentHtml, renderCommentPlaintext } from './comment-render'
|
|
4
|
+
|
|
5
|
+
const doc = (...content: any[]) => ({ type: 'doc', content }) as any
|
|
6
|
+
const para = (...content: any[]) => ({ type: 'paragraph', content })
|
|
7
|
+
const text = (value: string, marks?: any[]) => ({
|
|
8
|
+
type: 'text',
|
|
9
|
+
text: value,
|
|
10
|
+
...(marks ? { marks } : {}),
|
|
11
|
+
})
|
|
12
|
+
const mention = (id: string, label?: string) => ({ type: 'mention', attrs: { id, label } })
|
|
13
|
+
|
|
14
|
+
describe('renderCommentHtml', () => {
|
|
15
|
+
it('preserves bold, links, and lists from the limited set', () => {
|
|
16
|
+
const html = renderCommentHtml(doc(para(text('hi', [{ type: 'bold' }]))))
|
|
17
|
+
expect(html).toContain('<strong>hi</strong>')
|
|
18
|
+
|
|
19
|
+
const list = renderCommentHtml(
|
|
20
|
+
doc({
|
|
21
|
+
type: 'bulletList',
|
|
22
|
+
content: [{ type: 'listItem', content: [para(text('one'))] }],
|
|
23
|
+
})
|
|
24
|
+
)
|
|
25
|
+
expect(list).toContain('<ul')
|
|
26
|
+
expect(list).toContain('one')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders a heading node as a paragraph, never a heading', () => {
|
|
30
|
+
const html = renderCommentHtml(
|
|
31
|
+
doc({ type: 'heading', attrs: { level: 1 }, content: [text('Title')] })
|
|
32
|
+
)
|
|
33
|
+
expect(html).not.toMatch(/<h[1-6]/)
|
|
34
|
+
expect(html).toContain('<p')
|
|
35
|
+
expect(html).toContain('Title')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('renders a mention as a pill and resolves its id to the current name', () => {
|
|
39
|
+
const body = doc(para(text('hey '), mention('u1', 'Ada')))
|
|
40
|
+
const html = renderCommentHtml(body, (id) => (id === 'u1' ? 'Ada Lovelace' : '?'))
|
|
41
|
+
expect(html).toContain('cmt-mention')
|
|
42
|
+
// the live name from the resolver, not the label stored at insert time
|
|
43
|
+
expect(html).toContain('@Ada Lovelace')
|
|
44
|
+
expect(html).not.toContain('@Ada<')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('falls back to the stored label when no resolver is given', () => {
|
|
48
|
+
const html = renderCommentHtml(doc(para(mention('u1', 'Ada'))))
|
|
49
|
+
expect(html).toContain('@Ada')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('escapes a resolved name, so it can never inject markup', () => {
|
|
53
|
+
const html = renderCommentHtml(doc(para(mention('u1'))), () => '<img src=x onerror=alert(1)>')
|
|
54
|
+
expect(html).not.toContain('<img')
|
|
55
|
+
expect(html).toContain('<img')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('renderCommentPlaintext', () => {
|
|
60
|
+
it('flattens paragraphs and demotes headings without throwing', () => {
|
|
61
|
+
const flat = renderCommentPlaintext(
|
|
62
|
+
doc({ type: 'heading', attrs: { level: 2 }, content: [text('Title')] }, para(text('body')))
|
|
63
|
+
)
|
|
64
|
+
expect(flat).toBe('Title\nbody')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns an empty string for an empty body', () => {
|
|
68
|
+
expect(renderCommentPlaintext(EMPTY_COMMENT)).toBe('')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('renders a mention as @name, resolved to the current name', () => {
|
|
72
|
+
const result = renderCommentPlaintext(
|
|
73
|
+
doc(para(text('hi '), mention('u1', 'Ada'))),
|
|
74
|
+
() => 'Ada Lovelace'
|
|
75
|
+
)
|
|
76
|
+
expect(result).toBe('hi @Ada Lovelace')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('isCommentEmpty', () => {
|
|
81
|
+
it('treats the empty seed and empty encodings as empty', () => {
|
|
82
|
+
expect(isCommentEmpty(EMPTY_COMMENT)).toBe(true)
|
|
83
|
+
expect(isCommentEmpty(doc())).toBe(true)
|
|
84
|
+
expect(isCommentEmpty(doc(para()))).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('treats a body with text as non-empty', () => {
|
|
88
|
+
expect(isCommentEmpty(doc(para(text('hi'))))).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { generateHTML, generateText, JSONContent } from '@tiptap/core'
|
|
2
|
+
import { TLRichText } from 'tldraw'
|
|
3
|
+
import { commentTipTapExtensions, isCommentEmpty } from '../ui/comment-extensions'
|
|
4
|
+
import { commentMention } from '../ui/comment-mention'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The author name shown in a byline when no source can name an id (e.g. a deleted account, or a
|
|
8
|
+
* member with no comment and no live presence). The toolkit's one generic default, applied where a
|
|
9
|
+
* byline needs a name; hosts pre-empt it by returning a name from `resolveName`. Not a translation
|
|
10
|
+
* key — a single English literal, matching the prior host-side default.
|
|
11
|
+
*/
|
|
12
|
+
export const UNKNOWN_AUTHOR = 'Someone'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The comment extension set has no heading node, so a body that nonetheless contains one (e.g. a
|
|
16
|
+
* record created programmatically or synced from a client with a fuller set) would make TipTap
|
|
17
|
+
* throw on an unknown node type. Demote any heading to a paragraph first, so headings can never
|
|
18
|
+
* render as headings and rendering never crashes.
|
|
19
|
+
*/
|
|
20
|
+
function demoteHeadings(node: JSONContent): JSONContent {
|
|
21
|
+
const content = Array.isArray(node.content) ? node.content.map(demoteHeadings) : node.content
|
|
22
|
+
if (node.type === 'heading') {
|
|
23
|
+
const { attrs: _attrs, ...rest } = node
|
|
24
|
+
return { ...rest, type: 'paragraph', content }
|
|
25
|
+
}
|
|
26
|
+
return content === node.content ? node : { ...node, content }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a body contains a mention node. Mention text is resolved from a member id at render time,
|
|
31
|
+
* so a body with one can't be cached by identity alone (the same body renders differently as names
|
|
32
|
+
* change) — those bodies skip the cache and re-render each call.
|
|
33
|
+
*/
|
|
34
|
+
function hasMention(node: JSONContent): boolean {
|
|
35
|
+
if (node.type === 'mention') return true
|
|
36
|
+
return Array.isArray(node.content) ? node.content.some(hasMention) : false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const htmlCache = new WeakMap<TLRichText, string>()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render a comment body to HTML through the limited comment extension set (no headings), so a body
|
|
43
|
+
* always renders with comment formatting regardless of the host editor's rich-text config. Mirrors
|
|
44
|
+
* tldraw's `renderHtmlFromRichText`, including the empty-paragraph fix that keeps blank lines from
|
|
45
|
+
* collapsing. `resolveName` maps a member id to its current name for any @mentions.
|
|
46
|
+
*/
|
|
47
|
+
export function renderCommentHtml(
|
|
48
|
+
richText: TLRichText,
|
|
49
|
+
resolveName?: (id: string) => string | undefined
|
|
50
|
+
): string {
|
|
51
|
+
const mentions = hasMention(richText as JSONContent)
|
|
52
|
+
if (!mentions) {
|
|
53
|
+
const cached = htmlCache.get(richText)
|
|
54
|
+
if (cached !== undefined) return cached
|
|
55
|
+
}
|
|
56
|
+
const extensions = mentions
|
|
57
|
+
? [...commentTipTapExtensions, commentMention({ resolveName })]
|
|
58
|
+
: commentTipTapExtensions
|
|
59
|
+
const html = generateHTML(demoteHeadings(richText as JSONContent), extensions).replaceAll(
|
|
60
|
+
'<p dir="auto"></p>',
|
|
61
|
+
'<p><br /></p>'
|
|
62
|
+
)
|
|
63
|
+
if (!mentions) htmlCache.set(richText, html)
|
|
64
|
+
return html
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const textCache = new WeakMap<TLRichText, string>()
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Flatten a comment body to plaintext through the limited comment extension set — paragraphs and
|
|
71
|
+
* list items separated by newlines. Used for previews (e.g. the sidebar) where formatting is dropped.
|
|
72
|
+
* `resolveName` maps a member id to its current name for any @mentions.
|
|
73
|
+
*/
|
|
74
|
+
export function renderCommentPlaintext(
|
|
75
|
+
richText: TLRichText,
|
|
76
|
+
resolveName?: (id: string) => string | undefined
|
|
77
|
+
): string {
|
|
78
|
+
if (isCommentEmpty(richText)) return ''
|
|
79
|
+
const mentions = hasMention(richText as JSONContent)
|
|
80
|
+
if (!mentions) {
|
|
81
|
+
const cached = textCache.get(richText)
|
|
82
|
+
if (cached !== undefined) return cached
|
|
83
|
+
}
|
|
84
|
+
const extensions = mentions
|
|
85
|
+
? [...commentTipTapExtensions, commentMention({ resolveName })]
|
|
86
|
+
: commentTipTapExtensions
|
|
87
|
+
const text = generateText(demoteHeadings(richText as JSONContent), extensions, {
|
|
88
|
+
blockSeparator: '\n',
|
|
89
|
+
})
|
|
90
|
+
if (!mentions) textCache.set(richText, text)
|
|
91
|
+
return text
|
|
92
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Editor,
|
|
3
|
+
TLComment,
|
|
4
|
+
TLCommentId,
|
|
5
|
+
TLCommentThread,
|
|
6
|
+
TLCommentThreadId,
|
|
7
|
+
TLRecord,
|
|
8
|
+
} from 'tldraw'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Typed access to comment records on the editor store.
|
|
12
|
+
*
|
|
13
|
+
* Comment threads and comments live on the editor's local store so the canvas can render them
|
|
14
|
+
* reactively, but they are opt-in records that aren't part of the `TLRecord` union (they ride the
|
|
15
|
+
* sync server's object-store lane on the wire — see `TLCommentThread`). `editor.store` is therefore
|
|
16
|
+
* statically typed `Store<TLRecord>` and doesn't know about them, so every access has to reinterpret
|
|
17
|
+
* the type. These helpers own that reinterpretation — an `unknown` hop to exactly the type the store
|
|
18
|
+
* expects, so the rest of each call stays checked — behind one boundary, and keep call sites typed.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A record that lives in a comment thread: the thread itself or one of its messages. @public */
|
|
22
|
+
export type TLCommentRecord = TLComment | TLCommentThread
|
|
23
|
+
|
|
24
|
+
/** Write comment records to the store. @public */
|
|
25
|
+
export function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void {
|
|
26
|
+
editor.store.put(records as unknown as TLRecord[])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Remove comment records from the store by id. @public */
|
|
30
|
+
export function removeCommentRecords(
|
|
31
|
+
editor: Editor,
|
|
32
|
+
ids: (TLCommentId | TLCommentThreadId)[]
|
|
33
|
+
): void {
|
|
34
|
+
editor.store.remove(ids as unknown as TLRecord['id'][])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read one comment record by id, or `undefined` if the id isn't a present comment record. @public */
|
|
38
|
+
export function getCommentRecord(editor: Editor, id: string): TLCommentRecord | undefined {
|
|
39
|
+
const record = editor.store.get(id as TLRecord['id']) as unknown as TLCommentRecord | undefined
|
|
40
|
+
if (!record) return undefined
|
|
41
|
+
if (record.typeName === 'comment' || record.typeName === 'comment-thread') return record
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** All comment threads currently in the store (non-reactive; wrap in `useValue` to react). @public */
|
|
46
|
+
export function getCommentThreads(editor: Editor): TLCommentThread[] {
|
|
47
|
+
const typeName = 'comment-thread' as TLRecord['typeName']
|
|
48
|
+
return editor.store.query.records(typeName).get() as unknown as TLCommentThread[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** All comments currently in the store (non-reactive; wrap in `useValue` to react). @public */
|
|
52
|
+
export function getComments(editor: Editor): TLComment[] {
|
|
53
|
+
const typeName = 'comment' as TLRecord['typeName']
|
|
54
|
+
return editor.store.query.records(typeName).get() as unknown as TLComment[]
|
|
55
|
+
}
|