@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.
Files changed (242) hide show
  1. package/README.md +11 -0
  2. package/commenting.css +832 -0
  3. package/dist-cjs/canvas/cluster-input.js +40 -0
  4. package/dist-cjs/canvas/cluster-input.js.map +7 -0
  5. package/dist-cjs/canvas/comment-body.js +31 -0
  6. package/dist-cjs/canvas/comment-body.js.map +7 -0
  7. package/dist-cjs/canvas/comment-render.js +72 -0
  8. package/dist-cjs/canvas/comment-render.js.map +7 -0
  9. package/dist-cjs/canvas/comment-store.js +48 -0
  10. package/dist-cjs/canvas/comment-store.js.map +7 -0
  11. package/dist-cjs/canvas/comment-tool.js +160 -0
  12. package/dist-cjs/canvas/comment-tool.js.map +7 -0
  13. package/dist-cjs/canvas/comments-filter-menu.js +109 -0
  14. package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
  15. package/dist-cjs/canvas/comments-menu-item.js +42 -0
  16. package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
  17. package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
  18. package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
  19. package/dist-cjs/canvas/comments-overlay.js +1081 -0
  20. package/dist-cjs/canvas/comments-overlay.js.map +7 -0
  21. package/dist-cjs/canvas/comments-sidebar.js +134 -0
  22. package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
  23. package/dist-cjs/canvas/hooks.js +45 -0
  24. package/dist-cjs/canvas/hooks.js.map +7 -0
  25. package/dist-cjs/canvas/license.js +28 -0
  26. package/dist-cjs/canvas/license.js.map +7 -0
  27. package/dist-cjs/canvas/options.js +45 -0
  28. package/dist-cjs/canvas/options.js.map +7 -0
  29. package/dist-cjs/canvas/region-options.js +49 -0
  30. package/dist-cjs/canvas/region-options.js.map +7 -0
  31. package/dist-cjs/canvas/rich-text.js +28 -0
  32. package/dist-cjs/canvas/rich-text.js.map +7 -0
  33. package/dist-cjs/canvas/sidebar-filters.js +30 -0
  34. package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
  35. package/dist-cjs/canvas/state.js +76 -0
  36. package/dist-cjs/canvas/state.js.map +7 -0
  37. package/dist-cjs/canvas/thread-state.js +81 -0
  38. package/dist-cjs/canvas/thread-state.js.map +7 -0
  39. package/dist-cjs/clustering/computeClusterTable.js +83 -0
  40. package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
  41. package/dist-cjs/clustering/mst.js +117 -0
  42. package/dist-cjs/clustering/mst.js.map +7 -0
  43. package/dist-cjs/clustering/replay.js +275 -0
  44. package/dist-cjs/clustering/replay.js.map +7 -0
  45. package/dist-cjs/clustering/runtime.js +134 -0
  46. package/dist-cjs/clustering/runtime.js.map +7 -0
  47. package/dist-cjs/clustering/schedule.js +170 -0
  48. package/dist-cjs/clustering/schedule.js.map +7 -0
  49. package/dist-cjs/clustering/types.js +17 -0
  50. package/dist-cjs/clustering/types.js.map +7 -0
  51. package/dist-cjs/index.d.ts +868 -0
  52. package/dist-cjs/index.js +126 -0
  53. package/dist-cjs/index.js.map +7 -0
  54. package/dist-cjs/ui/avatar.js +43 -0
  55. package/dist-cjs/ui/avatar.js.map +7 -0
  56. package/dist-cjs/ui/byline.js +35 -0
  57. package/dist-cjs/ui/byline.js.map +7 -0
  58. package/dist-cjs/ui/comment-card.js +37 -0
  59. package/dist-cjs/ui/comment-card.js.map +7 -0
  60. package/dist-cjs/ui/comment-composer.js +136 -0
  61. package/dist-cjs/ui/comment-composer.js.map +7 -0
  62. package/dist-cjs/ui/comment-extensions.js +37 -0
  63. package/dist-cjs/ui/comment-extensions.js.map +7 -0
  64. package/dist-cjs/ui/comment-mention.js +42 -0
  65. package/dist-cjs/ui/comment-mention.js.map +7 -0
  66. package/dist-cjs/ui/comment-pin.js +29 -0
  67. package/dist-cjs/ui/comment-pin.js.map +7 -0
  68. package/dist-cjs/ui/comment-text.js +29 -0
  69. package/dist-cjs/ui/comment-text.js.map +7 -0
  70. package/dist-cjs/ui/comment-thread.js +45 -0
  71. package/dist-cjs/ui/comment-thread.js.map +7 -0
  72. package/dist-cjs/ui/comments-list.js +106 -0
  73. package/dist-cjs/ui/comments-list.js.map +7 -0
  74. package/dist-cjs/ui/count-badge.js +28 -0
  75. package/dist-cjs/ui/count-badge.js.map +7 -0
  76. package/dist-cjs/ui/empty-state.js +31 -0
  77. package/dist-cjs/ui/empty-state.js.map +7 -0
  78. package/dist-cjs/ui/format-time.js +46 -0
  79. package/dist-cjs/ui/format-time.js.map +7 -0
  80. package/dist-cjs/ui/mention-list.js +70 -0
  81. package/dist-cjs/ui/mention-list.js.map +7 -0
  82. package/dist-cjs/ui/mention-suggestion.js +206 -0
  83. package/dist-cjs/ui/mention-suggestion.js.map +7 -0
  84. package/dist-cjs/ui/mention.js +31 -0
  85. package/dist-cjs/ui/mention.js.map +7 -0
  86. package/dist-cjs/ui/reaction.js +31 -0
  87. package/dist-cjs/ui/reaction.js.map +7 -0
  88. package/dist-cjs/ui/reactions.js +34 -0
  89. package/dist-cjs/ui/reactions.js.map +7 -0
  90. package/dist-cjs/ui/render-markdown.js +63 -0
  91. package/dist-cjs/ui/render-markdown.js.map +7 -0
  92. package/dist-cjs/ui/send-button.js +28 -0
  93. package/dist-cjs/ui/send-button.js.map +7 -0
  94. package/dist-esm/canvas/cluster-input.mjs +20 -0
  95. package/dist-esm/canvas/cluster-input.mjs.map +7 -0
  96. package/dist-esm/canvas/comment-body.mjs +11 -0
  97. package/dist-esm/canvas/comment-body.mjs.map +7 -0
  98. package/dist-esm/canvas/comment-render.mjs +52 -0
  99. package/dist-esm/canvas/comment-render.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-store.mjs +28 -0
  101. package/dist-esm/canvas/comment-store.mjs.map +7 -0
  102. package/dist-esm/canvas/comment-tool.mjs +142 -0
  103. package/dist-esm/canvas/comment-tool.mjs.map +7 -0
  104. package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
  105. package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
  106. package/dist-esm/canvas/comments-menu-item.mjs +22 -0
  107. package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
  108. package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
  109. package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
  110. package/dist-esm/canvas/comments-overlay.mjs +1094 -0
  111. package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
  112. package/dist-esm/canvas/comments-sidebar.mjs +120 -0
  113. package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
  114. package/dist-esm/canvas/hooks.mjs +25 -0
  115. package/dist-esm/canvas/hooks.mjs.map +7 -0
  116. package/dist-esm/canvas/license.mjs +8 -0
  117. package/dist-esm/canvas/license.mjs.map +7 -0
  118. package/dist-esm/canvas/options.mjs +27 -0
  119. package/dist-esm/canvas/options.mjs.map +7 -0
  120. package/dist-esm/canvas/region-options.mjs +29 -0
  121. package/dist-esm/canvas/region-options.mjs.map +7 -0
  122. package/dist-esm/canvas/rich-text.mjs +8 -0
  123. package/dist-esm/canvas/rich-text.mjs.map +7 -0
  124. package/dist-esm/canvas/sidebar-filters.mjs +10 -0
  125. package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
  126. package/dist-esm/canvas/state.mjs +60 -0
  127. package/dist-esm/canvas/state.mjs.map +7 -0
  128. package/dist-esm/canvas/thread-state.mjs +61 -0
  129. package/dist-esm/canvas/thread-state.mjs.map +7 -0
  130. package/dist-esm/clustering/computeClusterTable.mjs +63 -0
  131. package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
  132. package/dist-esm/clustering/mst.mjs +97 -0
  133. package/dist-esm/clustering/mst.mjs.map +7 -0
  134. package/dist-esm/clustering/replay.mjs +255 -0
  135. package/dist-esm/clustering/replay.mjs.map +7 -0
  136. package/dist-esm/clustering/runtime.mjs +114 -0
  137. package/dist-esm/clustering/runtime.mjs.map +7 -0
  138. package/dist-esm/clustering/schedule.mjs +150 -0
  139. package/dist-esm/clustering/schedule.mjs.map +7 -0
  140. package/dist-esm/clustering/types.mjs +1 -0
  141. package/dist-esm/clustering/types.mjs.map +7 -0
  142. package/dist-esm/index.d.mts +868 -0
  143. package/dist-esm/index.mjs +139 -0
  144. package/dist-esm/index.mjs.map +7 -0
  145. package/dist-esm/ui/avatar.mjs +23 -0
  146. package/dist-esm/ui/avatar.mjs.map +7 -0
  147. package/dist-esm/ui/byline.mjs +15 -0
  148. package/dist-esm/ui/byline.mjs.map +7 -0
  149. package/dist-esm/ui/comment-card.mjs +17 -0
  150. package/dist-esm/ui/comment-card.mjs.map +7 -0
  151. package/dist-esm/ui/comment-composer.mjs +116 -0
  152. package/dist-esm/ui/comment-composer.mjs.map +7 -0
  153. package/dist-esm/ui/comment-extensions.mjs +17 -0
  154. package/dist-esm/ui/comment-extensions.mjs.map +7 -0
  155. package/dist-esm/ui/comment-mention.mjs +22 -0
  156. package/dist-esm/ui/comment-mention.mjs.map +7 -0
  157. package/dist-esm/ui/comment-pin.mjs +9 -0
  158. package/dist-esm/ui/comment-pin.mjs.map +7 -0
  159. package/dist-esm/ui/comment-text.mjs +9 -0
  160. package/dist-esm/ui/comment-text.mjs.map +7 -0
  161. package/dist-esm/ui/comment-thread.mjs +25 -0
  162. package/dist-esm/ui/comment-thread.mjs.map +7 -0
  163. package/dist-esm/ui/comments-list.mjs +86 -0
  164. package/dist-esm/ui/comments-list.mjs.map +7 -0
  165. package/dist-esm/ui/count-badge.mjs +8 -0
  166. package/dist-esm/ui/count-badge.mjs.map +7 -0
  167. package/dist-esm/ui/empty-state.mjs +11 -0
  168. package/dist-esm/ui/empty-state.mjs.map +7 -0
  169. package/dist-esm/ui/format-time.mjs +26 -0
  170. package/dist-esm/ui/format-time.mjs.map +7 -0
  171. package/dist-esm/ui/mention-list.mjs +50 -0
  172. package/dist-esm/ui/mention-list.mjs.map +7 -0
  173. package/dist-esm/ui/mention-suggestion.mjs +186 -0
  174. package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
  175. package/dist-esm/ui/mention.mjs +11 -0
  176. package/dist-esm/ui/mention.mjs.map +7 -0
  177. package/dist-esm/ui/reaction.mjs +11 -0
  178. package/dist-esm/ui/reaction.mjs.map +7 -0
  179. package/dist-esm/ui/reactions.mjs +14 -0
  180. package/dist-esm/ui/reactions.mjs.map +7 -0
  181. package/dist-esm/ui/render-markdown.mjs +45 -0
  182. package/dist-esm/ui/render-markdown.mjs.map +7 -0
  183. package/dist-esm/ui/send-button.mjs +8 -0
  184. package/dist-esm/ui/send-button.mjs.map +7 -0
  185. package/package.json +71 -0
  186. package/src/canvas/canvas.css +148 -0
  187. package/src/canvas/cluster-input.test.ts +223 -0
  188. package/src/canvas/cluster-input.ts +29 -0
  189. package/src/canvas/comment-body.tsx +21 -0
  190. package/src/canvas/comment-render.test.ts +90 -0
  191. package/src/canvas/comment-render.ts +92 -0
  192. package/src/canvas/comment-store.ts +55 -0
  193. package/src/canvas/comment-tool.tsx +200 -0
  194. package/src/canvas/comments-filter-menu.tsx +104 -0
  195. package/src/canvas/comments-menu-item.tsx +24 -0
  196. package/src/canvas/comments-overflow-menu.tsx +62 -0
  197. package/src/canvas/comments-overlay.tsx +1460 -0
  198. package/src/canvas/comments-sidebar.tsx +184 -0
  199. package/src/canvas/hooks.ts +28 -0
  200. package/src/canvas/license.ts +15 -0
  201. package/src/canvas/options.test.ts +127 -0
  202. package/src/canvas/options.ts +111 -0
  203. package/src/canvas/region-options.ts +57 -0
  204. package/src/canvas/rich-text.ts +15 -0
  205. package/src/canvas/sidebar-filters.ts +25 -0
  206. package/src/canvas/state.ts +102 -0
  207. package/src/canvas/thread-state.ts +91 -0
  208. package/src/clustering/computeClusterTable.test.ts +299 -0
  209. package/src/clustering/computeClusterTable.ts +82 -0
  210. package/src/clustering/locality.test.ts +189 -0
  211. package/src/clustering/mst.test.ts +383 -0
  212. package/src/clustering/mst.ts +134 -0
  213. package/src/clustering/replay.test.ts +547 -0
  214. package/src/clustering/replay.ts +326 -0
  215. package/src/clustering/runtime.test.ts +596 -0
  216. package/src/clustering/runtime.ts +153 -0
  217. package/src/clustering/schedule.test.ts +514 -0
  218. package/src/clustering/schedule.ts +184 -0
  219. package/src/clustering/types.ts +102 -0
  220. package/src/index.ts +96 -0
  221. package/src/ui/avatar.tsx +31 -0
  222. package/src/ui/byline.tsx +23 -0
  223. package/src/ui/comment-card.tsx +32 -0
  224. package/src/ui/comment-composer.tsx +179 -0
  225. package/src/ui/comment-extensions.ts +25 -0
  226. package/src/ui/comment-mention.ts +47 -0
  227. package/src/ui/comment-pin.tsx +21 -0
  228. package/src/ui/comment-text.tsx +12 -0
  229. package/src/ui/comment-thread.tsx +56 -0
  230. package/src/ui/comments-list.tsx +139 -0
  231. package/src/ui/comments.css +678 -0
  232. package/src/ui/count-badge.tsx +9 -0
  233. package/src/ui/empty-state.tsx +16 -0
  234. package/src/ui/format-time.ts +29 -0
  235. package/src/ui/mention-list.tsx +106 -0
  236. package/src/ui/mention-suggestion.test.ts +18 -0
  237. package/src/ui/mention-suggestion.tsx +285 -0
  238. package/src/ui/mention.tsx +9 -0
  239. package/src/ui/reaction.tsx +16 -0
  240. package/src/ui/reactions.tsx +15 -0
  241. package/src/ui/render-markdown.tsx +72 -0
  242. 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('&lt;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
+ }