@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.31036673d3f8

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 (279) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1139 -329
  3. package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
  4. package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
  5. package/dist-cjs/canvas/cluster-input.js +2 -2
  6. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  7. package/dist-cjs/canvas/comment-body.js +1 -1
  8. package/dist-cjs/canvas/comment-body.js.map +2 -2
  9. package/dist-cjs/canvas/comment-drafts.js +54 -0
  10. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  11. package/dist-cjs/canvas/comment-mutations.js +123 -0
  12. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  13. package/dist-cjs/canvas/comment-reactions.js +163 -0
  14. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  15. package/dist-cjs/canvas/comment-render.js +5 -3
  16. package/dist-cjs/canvas/comment-render.js.map +2 -2
  17. package/dist-cjs/canvas/comment-store.js +19 -9
  18. package/dist-cjs/canvas/comment-store.js.map +2 -2
  19. package/dist-cjs/canvas/comment-tool.js +64 -14
  20. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  21. package/dist-cjs/canvas/comments-filter-menu.js +20 -38
  22. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  23. package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
  24. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overlay.js +429 -474
  26. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  27. package/dist-cjs/canvas/comments-sidebar.js +35 -32
  28. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  29. package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
  30. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  31. package/dist-cjs/canvas/context.js +17 -0
  32. package/dist-cjs/canvas/context.js.map +7 -0
  33. package/dist-cjs/canvas/hooks.js +22 -3
  34. package/dist-cjs/canvas/hooks.js.map +2 -2
  35. package/dist-cjs/canvas/options.js +19 -2
  36. package/dist-cjs/canvas/options.js.map +2 -2
  37. package/dist-cjs/canvas/pin-stacking.js +56 -0
  38. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  39. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  40. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  41. package/dist-cjs/canvas/state.js +28 -14
  42. package/dist-cjs/canvas/state.js.map +2 -2
  43. package/dist-cjs/canvas/thread-preview.js +185 -0
  44. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  45. package/dist-cjs/canvas/thread-stack.js +199 -0
  46. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  47. package/dist-cjs/canvas/thread-state.js +67 -21
  48. package/dist-cjs/canvas/thread-state.js.map +2 -2
  49. package/dist-cjs/canvas/thread-view.js +397 -0
  50. package/dist-cjs/canvas/thread-view.js.map +7 -0
  51. package/dist-cjs/clustering/computeClusterTable.js +4 -4
  52. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  53. package/dist-cjs/clustering/runtime.js +119 -7
  54. package/dist-cjs/clustering/runtime.js.map +2 -2
  55. package/dist-cjs/clustering/types.js.map +1 -1
  56. package/dist-cjs/index.d.ts +763 -333
  57. package/dist-cjs/index.js +46 -28
  58. package/dist-cjs/index.js.map +2 -2
  59. package/dist-cjs/ui/byline.js +6 -4
  60. package/dist-cjs/ui/byline.js.map +2 -2
  61. package/dist-cjs/ui/comment-card.js +15 -8
  62. package/dist-cjs/ui/comment-card.js.map +2 -2
  63. package/dist-cjs/ui/comment-composer.js +137 -36
  64. package/dist-cjs/ui/comment-composer.js.map +2 -2
  65. package/dist-cjs/ui/comment-pin.js +24 -2
  66. package/dist-cjs/ui/comment-pin.js.map +2 -2
  67. package/dist-cjs/ui/comment-thread.js +9 -7
  68. package/dist-cjs/ui/comment-thread.js.map +2 -2
  69. package/dist-cjs/ui/comments-list.js +30 -18
  70. package/dist-cjs/ui/comments-list.js.map +2 -2
  71. package/dist-cjs/ui/count-badge.js +3 -2
  72. package/dist-cjs/ui/count-badge.js.map +2 -2
  73. package/dist-cjs/ui/emoji-picker.js +54 -0
  74. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  75. package/dist-cjs/ui/empty-state.js +3 -3
  76. package/dist-cjs/ui/empty-state.js.map +2 -2
  77. package/dist-cjs/ui/format-time.js +4 -1
  78. package/dist-cjs/ui/format-time.js.map +2 -2
  79. package/dist-cjs/ui/reaction-picker.js +73 -0
  80. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  81. package/dist-cjs/ui/reaction.js +59 -6
  82. package/dist-cjs/ui/reaction.js.map +2 -2
  83. package/dist-cjs/ui/reactions.js +20 -7
  84. package/dist-cjs/ui/reactions.js.map +2 -2
  85. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  86. package/dist-cjs/ui/reply-count.js.map +7 -0
  87. package/dist-cjs/ui/send-button.js +20 -1
  88. package/dist-cjs/ui/send-button.js.map +2 -2
  89. package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
  90. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  91. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  92. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  93. package/dist-esm/canvas/cluster-input.mjs +2 -2
  94. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  95. package/dist-esm/canvas/comment-body.mjs +1 -1
  96. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  97. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  98. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  99. package/dist-esm/canvas/comment-mutations.mjs +103 -0
  100. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  101. package/dist-esm/canvas/comment-reactions.mjs +152 -0
  102. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  103. package/dist-esm/canvas/comment-render.mjs +5 -3
  104. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  105. package/dist-esm/canvas/comment-store.mjs +19 -9
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +67 -17
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +450 -479
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +38 -35
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  119. package/dist-esm/canvas/context.mjs +1 -0
  120. package/dist-esm/canvas/context.mjs.map +7 -0
  121. package/dist-esm/canvas/hooks.mjs +28 -5
  122. package/dist-esm/canvas/hooks.mjs.map +2 -2
  123. package/dist-esm/canvas/options.mjs +21 -3
  124. package/dist-esm/canvas/options.mjs.map +2 -2
  125. package/dist-esm/canvas/pin-stacking.mjs +36 -0
  126. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  127. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  128. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  129. package/dist-esm/canvas/state.mjs +29 -19
  130. package/dist-esm/canvas/state.mjs.map +2 -2
  131. package/dist-esm/canvas/thread-preview.mjs +170 -0
  132. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  133. package/dist-esm/canvas/thread-stack.mjs +190 -0
  134. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-state.mjs +71 -22
  136. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-view.mjs +402 -0
  138. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  139. package/dist-esm/clustering/computeClusterTable.mjs +4 -4
  140. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  141. package/dist-esm/clustering/runtime.mjs +119 -7
  142. package/dist-esm/clustering/runtime.mjs.map +2 -2
  143. package/dist-esm/index.d.mts +763 -333
  144. package/dist-esm/index.mjs +87 -41
  145. package/dist-esm/index.mjs.map +2 -2
  146. package/dist-esm/ui/byline.mjs +6 -4
  147. package/dist-esm/ui/byline.mjs.map +2 -2
  148. package/dist-esm/ui/comment-card.mjs +15 -8
  149. package/dist-esm/ui/comment-card.mjs.map +2 -2
  150. package/dist-esm/ui/comment-composer.mjs +148 -36
  151. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  152. package/dist-esm/ui/comment-pin.mjs +24 -2
  153. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  154. package/dist-esm/ui/comment-thread.mjs +9 -7
  155. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  156. package/dist-esm/ui/comments-list.mjs +43 -29
  157. package/dist-esm/ui/comments-list.mjs.map +2 -2
  158. package/dist-esm/ui/count-badge.mjs +3 -2
  159. package/dist-esm/ui/count-badge.mjs.map +2 -2
  160. package/dist-esm/ui/emoji-picker.mjs +34 -0
  161. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  162. package/dist-esm/ui/empty-state.mjs +3 -3
  163. package/dist-esm/ui/empty-state.mjs.map +2 -2
  164. package/dist-esm/ui/format-time.mjs +4 -1
  165. package/dist-esm/ui/format-time.mjs.map +2 -2
  166. package/dist-esm/ui/reaction-picker.mjs +60 -0
  167. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  168. package/dist-esm/ui/reaction.mjs +60 -7
  169. package/dist-esm/ui/reaction.mjs.map +2 -2
  170. package/dist-esm/ui/reactions.mjs +21 -8
  171. package/dist-esm/ui/reactions.mjs.map +2 -2
  172. package/dist-esm/ui/reply-count.mjs +9 -0
  173. package/dist-esm/ui/reply-count.mjs.map +7 -0
  174. package/dist-esm/ui/send-button.mjs +20 -1
  175. package/dist-esm/ui/send-button.mjs.map +2 -2
  176. package/dist-esm/ui/tooltip-button.mjs +12 -0
  177. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  178. package/package.json +6 -6
  179. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  180. package/src/canvas/anchor-lifecycle.ts +205 -0
  181. package/src/canvas/canvas.css +461 -46
  182. package/src/canvas/cluster-input.test.ts +31 -23
  183. package/src/canvas/cluster-input.ts +3 -4
  184. package/src/canvas/comment-body.tsx +1 -1
  185. package/src/canvas/comment-drafts.test.ts +55 -0
  186. package/src/canvas/comment-drafts.ts +46 -0
  187. package/src/canvas/comment-mutations.test.ts +410 -0
  188. package/src/canvas/comment-mutations.ts +288 -0
  189. package/src/canvas/comment-reactions.test.ts +132 -0
  190. package/src/canvas/comment-reactions.tsx +245 -0
  191. package/src/canvas/comment-render.test.ts +1 -1
  192. package/src/canvas/comment-render.ts +9 -5
  193. package/src/canvas/comment-store.ts +67 -27
  194. package/src/canvas/comment-tool.test.ts +162 -0
  195. package/src/canvas/comment-tool.tsx +105 -23
  196. package/src/canvas/comments-filter-menu.tsx +23 -32
  197. package/src/canvas/comments-overflow-menu.tsx +14 -21
  198. package/src/canvas/comments-overlay.tsx +695 -622
  199. package/src/canvas/comments-sidebar.test.ts +45 -0
  200. package/src/canvas/comments-sidebar.tsx +76 -58
  201. package/src/canvas/comments-visibility-toggle.tsx +49 -0
  202. package/src/canvas/context.ts +47 -0
  203. package/src/canvas/hooks.ts +52 -11
  204. package/src/canvas/options.test.ts +52 -2
  205. package/src/canvas/options.ts +133 -9
  206. package/src/canvas/pin-stacking.test.ts +114 -0
  207. package/src/canvas/pin-stacking.ts +57 -0
  208. package/src/canvas/sidebar-filters.ts +4 -3
  209. package/src/canvas/state.ts +92 -35
  210. package/src/canvas/thread-preview.test.ts +91 -0
  211. package/src/canvas/thread-preview.tsx +304 -0
  212. package/src/canvas/thread-stack.tsx +252 -0
  213. package/src/canvas/thread-state.test.ts +209 -0
  214. package/src/canvas/thread-state.ts +137 -36
  215. package/src/canvas/thread-view.tsx +516 -0
  216. package/src/clustering/computeClusterTable.test.ts +24 -23
  217. package/src/clustering/computeClusterTable.ts +5 -5
  218. package/src/clustering/runtime.test.ts +217 -7
  219. package/src/clustering/runtime.ts +171 -17
  220. package/src/clustering/types.ts +9 -9
  221. package/src/index.ts +76 -40
  222. package/src/ui/byline.tsx +7 -5
  223. package/src/ui/comment-card.tsx +20 -10
  224. package/src/ui/comment-composer.tsx +206 -40
  225. package/src/ui/comment-pin.tsx +26 -2
  226. package/src/ui/comment-thread.tsx +13 -6
  227. package/src/ui/comments-list.test.ts +31 -0
  228. package/src/ui/comments-list.tsx +45 -21
  229. package/src/ui/comments.css +566 -281
  230. package/src/ui/count-badge.tsx +9 -2
  231. package/src/ui/emoji-picker.test.ts +25 -0
  232. package/src/ui/emoji-picker.tsx +76 -0
  233. package/src/ui/empty-state.tsx +3 -3
  234. package/src/ui/format-time.ts +7 -2
  235. package/src/ui/reaction-picker.tsx +109 -0
  236. package/src/ui/reaction.tsx +121 -6
  237. package/src/ui/reactions.tsx +69 -10
  238. package/src/ui/reply-count.ts +16 -0
  239. package/src/ui/send-button.tsx +18 -3
  240. package/src/ui/tooltip-button.tsx +20 -0
  241. package/dist-cjs/canvas/region-options.js +0 -49
  242. package/dist-cjs/canvas/region-options.js.map +0 -7
  243. package/dist-cjs/ui/avatar.js +0 -43
  244. package/dist-cjs/ui/avatar.js.map +0 -7
  245. package/dist-cjs/ui/comment-mention.js +0 -42
  246. package/dist-cjs/ui/comment-mention.js.map +0 -7
  247. package/dist-cjs/ui/comment-text.js.map +0 -7
  248. package/dist-cjs/ui/mention-list.js +0 -70
  249. package/dist-cjs/ui/mention-list.js.map +0 -7
  250. package/dist-cjs/ui/mention-suggestion.js +0 -206
  251. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  252. package/dist-cjs/ui/mention.js.map +0 -7
  253. package/dist-cjs/ui/render-markdown.js +0 -63
  254. package/dist-cjs/ui/render-markdown.js.map +0 -7
  255. package/dist-esm/canvas/region-options.mjs +0 -29
  256. package/dist-esm/canvas/region-options.mjs.map +0 -7
  257. package/dist-esm/ui/avatar.mjs +0 -23
  258. package/dist-esm/ui/avatar.mjs.map +0 -7
  259. package/dist-esm/ui/comment-mention.mjs +0 -22
  260. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  261. package/dist-esm/ui/comment-text.mjs +0 -9
  262. package/dist-esm/ui/comment-text.mjs.map +0 -7
  263. package/dist-esm/ui/mention-list.mjs +0 -50
  264. package/dist-esm/ui/mention-list.mjs.map +0 -7
  265. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  266. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  267. package/dist-esm/ui/mention.mjs +0 -11
  268. package/dist-esm/ui/mention.mjs.map +0 -7
  269. package/dist-esm/ui/render-markdown.mjs +0 -45
  270. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  271. package/src/canvas/region-options.ts +0 -57
  272. package/src/ui/avatar.tsx +0 -31
  273. package/src/ui/comment-mention.ts +0 -47
  274. package/src/ui/comment-text.tsx +0 -12
  275. package/src/ui/mention-list.tsx +0 -106
  276. package/src/ui/mention-suggestion.test.ts +0 -18
  277. package/src/ui/mention-suggestion.tsx +0 -285
  278. package/src/ui/mention.tsx +0 -9
  279. package/src/ui/render-markdown.tsx +0 -72
@@ -0,0 +1,516 @@
1
+ import { ReactNode, useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import {
4
+ createComment,
5
+ Editor,
6
+ TLComment,
7
+ TLCommentThread,
8
+ TLRichText,
9
+ TldrawUiDropdownMenuContent,
10
+ TldrawUiDropdownMenuGroup,
11
+ TldrawUiDropdownMenuItem,
12
+ TldrawUiDropdownMenuRoot,
13
+ TldrawUiDropdownMenuTrigger,
14
+ TldrawUiIcon,
15
+ useContainer,
16
+ usePassThroughMouseOverEvents,
17
+ usePassThroughWheelEvents,
18
+ useTranslation,
19
+ } from 'tldraw'
20
+ import { CommentCard, CommentCardProps } from '../ui/comment-card'
21
+ import { CommentComposer } from '../ui/comment-composer'
22
+ import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
23
+ import { CommentThread } from '../ui/comment-thread'
24
+ import { TooltipButton } from '../ui/tooltip-button'
25
+ import { CommentBody } from './comment-body'
26
+ import {
27
+ clearCommentDraft,
28
+ getCommentDraft,
29
+ replyDraftSlot,
30
+ saveCommentDraft,
31
+ } from './comment-drafts'
32
+ import {
33
+ commitCommentMutation,
34
+ deleteComment,
35
+ deleteThread,
36
+ editComment,
37
+ putRecordsInCommit,
38
+ reopenThread,
39
+ resolveThread,
40
+ } from './comment-mutations'
41
+ import { CommentReactionPicker, CommentReactions } from './comment-reactions'
42
+ import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
43
+ import { type CommentingContext } from './context'
44
+ import { useThreadComments } from './hooks'
45
+ import { type CommentingComponents, useCanComment, useCommentingOptions } from './options'
46
+ import { openThreadId } from './state'
47
+
48
+ const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
49
+
50
+ /**
51
+ * A name-only view of an author resolver, for the mention/rich-text paths. Stable identity, so
52
+ * `CommentBody`'s memoized render doesn't recompute on every render of its host.
53
+ */
54
+ export function useResolveName(resolveAuthor: CommentingContext['resolveAuthor']) {
55
+ return useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
56
+ }
57
+
58
+ export function toCardProps(
59
+ comment: TLComment,
60
+ props: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'>,
61
+ components: CommentingComponents,
62
+ resolveName: (id: string) => string | undefined
63
+ ): CommentCardProps {
64
+ const Body = components.CommentBody
65
+ // The `CommentBody` component slot overrides the built-in rich-text default (which resolves
66
+ // mention ids to names).
67
+ const body = Body ? (
68
+ <Body comment={comment} />
69
+ ) : (
70
+ <CommentBody richText={comment.body} resolveName={resolveName} />
71
+ )
72
+ return {
73
+ author: props.resolveAuthor(comment.authorId) ?? UNKNOWN_COMMENT_AUTHOR,
74
+ body,
75
+ date: new Date(comment.createdAt).toISOString(),
76
+ you: comment.authorId === props.currentUserId,
77
+ edited: comment.editedAt != null,
78
+ }
79
+ }
80
+
81
+ /** A pin is this square (mirrors `--tlui-cmt-pin-size`). Needed because the two marker kinds are
82
+ * different sizes *and* anchor at different points, and lining their previews up means
83
+ * correcting for that. Keep in sync with the stylesheet. */
84
+ const PIN_SIZE = 28
85
+
86
+ /** A cluster/stack badge is this square (`--tlui-cmt-marker-size`) — pin-sized, so the marker
87
+ * kinds read as one family. Keep in sync with the stylesheet. */
88
+ const MARKER_SIZE = PIN_SIZE
89
+
90
+ /** Where a popover's top card sits vertically, measured from the marker visual's middle. */
91
+ const CARD_TOP_Y = -28
92
+
93
+ /** The horizontal gap between a marker visual's right edge and its popover/preview panel. The
94
+ * preview's hover bridge spans exactly this (see `--tlui-cmt-preview-bridge`). */
95
+ const PREVIEW_GAP = 6
96
+
97
+ /**
98
+ * Where a marker's popover sits relative to the marker's anchor point.
99
+ *
100
+ * The hover preview places itself at these same origins, so the two views of a thread differ only
101
+ * by the header the popover has — which its own stylesheet then compensates for. Moving a popover
102
+ * here moves its preview with it.
103
+ *
104
+ * Both marker kinds hang off their point the same way (`translate(0, -100%)`): the anchor is the
105
+ * visual's bottom-left corner. They differ only in size, so each offset clears its own width plus
106
+ * the shared gap, and re-bases the shared card-top measure from its bottom anchor to its middle —
107
+ * keeping the two previews' top cards on the same line.
108
+ */
109
+ export const POPOVER_OFFSET = {
110
+ thread: { x: PIN_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - PIN_SIZE / 2 },
111
+ list: { x: MARKER_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - MARKER_SIZE / 2 },
112
+ } as const
113
+
114
+ /** The open thread's popover container, portaled above the UI panels. Over it, wheel and hover
115
+ * events pass through to the canvas (unless it scrolls its own content), like tldraw's panels. */
116
+ export function ThreadPopover({
117
+ container,
118
+ style,
119
+ children,
120
+ }: {
121
+ container: HTMLElement
122
+ style: CSSProperties
123
+ children: ReactNode
124
+ }) {
125
+ const ref = useRef<HTMLDivElement>(null)
126
+ usePassThroughWheelEvents(ref)
127
+ usePassThroughMouseOverEvents(ref)
128
+ return createPortal(
129
+ // contextmenu also stops here: portals bubble React events to the canvas's context-menu
130
+ // trigger (the layer mounts inside it), which would open the canvas menu over this panel.
131
+ <div
132
+ ref={ref}
133
+ className="tlui-cmt-canvas-popover"
134
+ style={style}
135
+ onPointerDown={stop}
136
+ onContextMenu={stop}
137
+ >
138
+ {children}
139
+ </div>,
140
+ container
141
+ )
142
+ }
143
+
144
+ /**
145
+ * One thread's interactive view: its comments, the reply composer, edit-in-place on your own
146
+ * comments, and the resolve/delete actions. Reads and writes comment records via the editor's
147
+ * store; read receipts are reported for every unread comment while mounted, so only mount it
148
+ * where the thread is actually being shown.
149
+ */
150
+ export function ThreadView({
151
+ editor,
152
+ thread,
153
+ ...props
154
+ }: CommentingContext & { editor: Editor; thread: TLCommentThread }) {
155
+ const {
156
+ currentUserId,
157
+ resolveAuthor,
158
+ onPostComment,
159
+ isCommentUnread,
160
+ onCommentRead,
161
+ getMentionSuggestions,
162
+ renderMentionSuggestion,
163
+ } = props
164
+ const options = useCommentingOptions()
165
+ const comments = useThreadComments(editor, thread.id)
166
+ const msg = useTranslation()
167
+ const resolveName = useResolveName(resolveAuthor)
168
+ const me = currentUserId ? resolveAuthor(currentUserId) : undefined
169
+ // Composing, editing, deleting, and resolving are all commenting writes: gated on the viewer's
170
+ // permission. Where it's withheld the composer gives way to the ComposerFallback slot (a
171
+ // sign-in prompt, say) and the action affordances are hidden.
172
+ const canComment = useCanComment(currentUserId)
173
+ const ComposerFallback = options.components.ComposerFallback
174
+ // An unsent reply survives closing the thread (saved on every change, keyed by thread id) —
175
+ // the flip side of dismissing without a discard warning.
176
+ const [reply, setReply] = useState<TLRichText>(
177
+ () => getCommentDraft(replyDraftSlot(thread.id)) ?? EMPTY_COMMENT
178
+ )
179
+ const [editingId, setEditingId] = useState<string | null>(null)
180
+ const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
181
+ const canReply = canComment && !thread.resolved
182
+ const container = useContainer()
183
+ // Where focus goes when the edit composer closes, resolved at that moment rather than held as an
184
+ // element: the card (and its edit button with it) unmounts while the composer stands in its
185
+ // place, so the node captured on the way in is gone by the way out.
186
+ const editReturnFocus = useRef<(() => HTMLElement | null) | null>(null)
187
+
188
+ // Tab from the canvas drops the caret in the reply box. Clicking a pin opens the thread but
189
+ // leaves focus on the editor container, so the first Tab would otherwise walk the app's own UI
190
+ // instead of the panel the click just opened. Capture phase, ahead of the editor's own handling.
191
+ // Fires once per open thread, so Tab inside the thread stays plain tab-through.
192
+ const [focusReply, setFocusReply] = useState(false)
193
+ const tabTaken = useRef(false)
194
+ const swallowTabUp = useRef(false)
195
+ useEffect(() => {
196
+ if (!canReply) {
197
+ // Resolving unmounts the reply box under an open thread. Reopening it should feel like a
198
+ // freshly opened thread: no autoFocus left armed from the last Tab, and Tab available again.
199
+ setFocusReply(false)
200
+ tabTaken.current = false
201
+ return
202
+ }
203
+ const doc = container.ownerDocument
204
+ const onKeyDown = (e: KeyboardEvent) => {
205
+ if (e.key !== 'Tab' || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
206
+ if (e.defaultPrevented || tabTaken.current) return
207
+ // Focus rests on the container (or nothing at all) after a pin click. Anywhere else means
208
+ // it's already somewhere deliberate — the thread's own controls, the sidebar, a panel —
209
+ // and Tab belongs to whatever holds it.
210
+ if (e.target !== container && e.target !== doc.body) return
211
+ tabTaken.current = true
212
+ swallowTabUp.current = true
213
+ setFocusReply(true)
214
+ e.preventDefault()
215
+ e.stopPropagation()
216
+ }
217
+ // The select tool navigates shapes on Tab's *keyup*, and the composer isn't focused until the
218
+ // next frame — so a quick tap with shapes selected would both focus the reply and step the
219
+ // selection. Swallow the release of the press we took, and only that one.
220
+ const onKeyUp = (e: KeyboardEvent) => {
221
+ if (e.key !== 'Tab' || !swallowTabUp.current) return
222
+ swallowTabUp.current = false
223
+ e.preventDefault()
224
+ e.stopPropagation()
225
+ }
226
+ doc.addEventListener('keydown', onKeyDown, true)
227
+ doc.addEventListener('keyup', onKeyUp, true)
228
+ return () => {
229
+ doc.removeEventListener('keydown', onKeyDown, true)
230
+ doc.removeEventListener('keyup', onKeyUp, true)
231
+ }
232
+ }, [canReply, container])
233
+
234
+ // Leaving the edit composer — Escape, or a save — unmounts it while it holds focus, and the
235
+ // browser drops focus on the editor container. That's outside the thread, so a Tab from there
236
+ // walks the app's UI instead of carrying on from where the edit left off (the thread's one
237
+ // Tab-into-the-reply-box has usually been spent by then). Hand focus back to whatever opened the
238
+ // composer instead: the card's edit button, or the reply box when the edit came from arrow-up.
239
+ // The card's actions row reveals itself on `:focus-within`, so the button focus is visible.
240
+ useEffect(() => {
241
+ if (editingId !== null) return
242
+ const resolve = editReturnFocus.current
243
+ editReturnFocus.current = null
244
+ // Runs after React has swapped the card back in, so the target is mounted again by now.
245
+ resolve?.()?.focus()
246
+ }, [editingId])
247
+
248
+ // Every unread comment on display gets reported read — including replies that arrive while
249
+ // the view stays mounted, since the effect re-runs as `comments` changes. The host's receipt
250
+ // write flips isCommentUnread to false, so re-runs find nothing to report.
251
+ useEffect(() => {
252
+ if (!isCommentUnread || !onCommentRead) return
253
+ for (const comment of comments) {
254
+ if (isCommentUnread(comment.id)) {
255
+ onCommentRead(comment.id)
256
+ }
257
+ }
258
+ }, [comments, isCommentUnread, onCommentRead])
259
+
260
+ const postReply = () => {
261
+ if (isCommentEmpty(reply) || !currentUserId) return
262
+ commitCommentMutation(editor, () => {
263
+ const comment = createComment({
264
+ threadId: thread.id,
265
+ pageId: thread.pageId,
266
+ authorId: currentUserId,
267
+ body: reply,
268
+ })
269
+ putRecordsInCommit(editor, [comment])
270
+ if (onPostComment) onPostComment(comment)
271
+ })
272
+ setReply(EMPTY_COMMENT)
273
+ clearCommentDraft(replyDraftSlot(thread.id))
274
+ }
275
+
276
+ const toggleResolve = () => {
277
+ if (!currentUserId) return
278
+ if (thread.resolved) reopenThread(editor, thread)
279
+ else resolveThread(editor, thread, currentUserId)
280
+ }
281
+
282
+ const removeThread = () => {
283
+ if (!currentUserId) return
284
+ deleteThread(editor, thread)
285
+ }
286
+
287
+ const startEdit = (comment: TLComment, { fromMoreMenu = false } = {}) => {
288
+ // The ⋯ menu's button is the thing to come back to when Edit opened the composer — looked up
289
+ // again on the way out, since the menu item that was clicked (and the card) unmount while the
290
+ // composer stands in their place. Otherwise come back to whatever held focus: arrow-up-to-edit
291
+ // comes straight from the reply box, which stays put. The document body and the editor
292
+ // container are where focus falls when nothing holds it, so neither is somewhere to return to.
293
+ const active = container.ownerDocument.activeElement
294
+ editReturnFocus.current = fromMoreMenu
295
+ ? () => container.querySelector<HTMLElement>(`[data-cmt-more-for="${comment.id}"]`)
296
+ : active instanceof HTMLElement && active !== container && active !== document.body
297
+ ? () => (active.isConnected ? active : null)
298
+ : null
299
+ setEditingId(comment.id)
300
+ setEditText(comment.body)
301
+ }
302
+
303
+ const saveEdit = () => {
304
+ const comment = comments.find((c) => c.id === editingId)
305
+ if (!comment || isCommentEmpty(editText)) return
306
+ editComment(editor, comment, editText)
307
+ setEditingId(null)
308
+ }
309
+
310
+ // Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
311
+ // with an edit affordance on your own comments.
312
+ const renderComment = (card: CommentCardProps, index: number): ReactNode => {
313
+ const comment = comments[index]
314
+ if (editingId === comment.id) {
315
+ return (
316
+ <div
317
+ className="tlui-cmt-editing"
318
+ onKeyDown={(e) => {
319
+ if (e.key === 'Escape') {
320
+ setEditingId(null)
321
+ e.stopPropagation()
322
+ }
323
+ }}
324
+ >
325
+ <CommentComposer
326
+ author={card.author}
327
+ placeholder={msg('comments.edit-placeholder')}
328
+ value={editText}
329
+ onChange={setEditText}
330
+ onSubmit={saveEdit}
331
+ sendLabel={msg('comments.save')}
332
+ disabled={isCommentEmpty(editText)}
333
+ getMentionSuggestions={getMentionSuggestions}
334
+ renderMentionSuggestion={renderMentionSuggestion}
335
+ autoFocus
336
+ />
337
+ </div>
338
+ )
339
+ }
340
+ return (
341
+ <CommentCard
342
+ {...card}
343
+ footer={
344
+ <CommentReactions
345
+ comment={comment}
346
+ // Reacting is a commenting write: without `canComment` the tally renders
347
+ // read-only (no identity → pills aren't clickable).
348
+ currentUserId={canComment ? currentUserId : null}
349
+ resolveName={resolveName}
350
+ />
351
+ }
352
+ actions={
353
+ canComment && (
354
+ <>
355
+ <CommentReactionPicker comment={comment} currentUserId={currentUserId} />
356
+ {comment.authorId === currentUserId && (
357
+ <TldrawUiDropdownMenuRoot id={`comment-actions-${comment.id}`}>
358
+ <TldrawUiDropdownMenuTrigger>
359
+ <TooltipButton
360
+ tooltip={msg('comments.more-options')}
361
+ className="tlui-cmt-thread__action"
362
+ data-cmt-more-for={comment.id}
363
+ >
364
+ <TldrawUiIcon
365
+ icon="dots-vertical"
366
+ label={msg('comments.more-options')}
367
+ small
368
+ />
369
+ </TooltipButton>
370
+ </TldrawUiDropdownMenuTrigger>
371
+ <TldrawUiDropdownMenuContent
372
+ className="tlui-cmt-menu"
373
+ side="bottom"
374
+ align="start"
375
+ alignOffset={0}
376
+ sideOffset={4}
377
+ >
378
+ <TldrawUiDropdownMenuGroup>
379
+ <TldrawUiDropdownMenuItem>
380
+ <button
381
+ type="button"
382
+ className="tlui-cmt-menu-item"
383
+ onClick={() => startEdit(comment, { fromMoreMenu: true })}
384
+ >
385
+ <span>{msg('comments.edit')}</span>
386
+ </button>
387
+ </TldrawUiDropdownMenuItem>
388
+ <TldrawUiDropdownMenuItem>
389
+ <button
390
+ type="button"
391
+ className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
392
+ onClick={() => deleteComment(editor, comment)}
393
+ >
394
+ <span>{msg('action.delete')}</span>
395
+ </button>
396
+ </TldrawUiDropdownMenuItem>
397
+ </TldrawUiDropdownMenuGroup>
398
+ </TldrawUiDropdownMenuContent>
399
+ </TldrawUiDropdownMenuRoot>
400
+ )}
401
+ </>
402
+ )
403
+ }
404
+ />
405
+ )
406
+ }
407
+
408
+ // Resolve and delete are commenting writes: behind `canComment`, plus the `currentUserId` a
409
+ // resolve stamps into `resolved.by`.
410
+ const headerActions = (
411
+ <>
412
+ {/* Deleting a thread is creator-only (server-enforced), and it's the menu's only item. */}
413
+ {canComment && currentUserId && currentUserId === thread.createdBy && (
414
+ <TldrawUiDropdownMenuRoot id={`comment-thread-actions-${thread.id}`}>
415
+ <TldrawUiDropdownMenuTrigger>
416
+ <TooltipButton
417
+ tooltip={msg('comments.more-options')}
418
+ className="tlui-cmt-thread__action"
419
+ >
420
+ <TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
421
+ </TooltipButton>
422
+ </TldrawUiDropdownMenuTrigger>
423
+ <TldrawUiDropdownMenuContent
424
+ className="tlui-cmt-menu"
425
+ side="bottom"
426
+ align="start"
427
+ alignOffset={0}
428
+ sideOffset={4}
429
+ >
430
+ <TldrawUiDropdownMenuGroup>
431
+ <TldrawUiDropdownMenuItem>
432
+ <button
433
+ type="button"
434
+ className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
435
+ onClick={removeThread}
436
+ >
437
+ <span>{msg('comments.delete')}</span>
438
+ </button>
439
+ </TldrawUiDropdownMenuItem>
440
+ </TldrawUiDropdownMenuGroup>
441
+ </TldrawUiDropdownMenuContent>
442
+ </TldrawUiDropdownMenuRoot>
443
+ )}
444
+ {canComment && currentUserId && (
445
+ <TooltipButton
446
+ tooltip={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
447
+ className="tlui-cmt-thread__action"
448
+ onClick={toggleResolve}
449
+ >
450
+ <TldrawUiIcon
451
+ icon="check"
452
+ label={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
453
+ small
454
+ />
455
+ </TooltipButton>
456
+ )}
457
+ <TooltipButton
458
+ tooltip={msg('comments.dismiss')}
459
+ className="tlui-cmt-thread__action"
460
+ onClick={() => openThreadId.set(editor, null)}
461
+ >
462
+ <TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
463
+ </TooltipButton>
464
+ </>
465
+ )
466
+
467
+ return (
468
+ <CommentThread
469
+ header={msg('comments.thread-title')}
470
+ headerActions={headerActions}
471
+ renderComment={renderComment}
472
+ comments={comments.map((c) => toCardProps(c, props, options.components, resolveName))}
473
+ resolvedBanner={
474
+ thread.resolved
475
+ ? msg('comments.resolved-by').replace(
476
+ '{name}',
477
+ resolveAuthor(thread.resolved.by)?.name ?? UNKNOWN_AUTHOR
478
+ )
479
+ : undefined
480
+ }
481
+ composer={
482
+ canReply
483
+ ? {
484
+ author: me ?? UNKNOWN_COMMENT_AUTHOR,
485
+ placeholder: msg('comments.reply-placeholder'),
486
+ sendLabel: msg('comments.send'),
487
+ value: reply,
488
+ onChange: (value: TLRichText) => {
489
+ setReply(value)
490
+ saveCommentDraft(replyDraftSlot(thread.id), value)
491
+ },
492
+ onSubmit: postReply,
493
+ // Up in the empty reply box edits the comment directly above it, chat-style —
494
+ // only when that comment is yours (the same gate as the Edit link).
495
+ onArrowUpWhenEmpty: () => {
496
+ const last = comments[comments.length - 1]
497
+ if (last && last.authorId === currentUserId) startEdit(last)
498
+ },
499
+ // No user, no author for the record — dead send button.
500
+ disabled: isCommentEmpty(reply) || !currentUserId,
501
+ getMentionSuggestions,
502
+ renderMentionSuggestion,
503
+ // Reuses the composer's own focus path, so the caret lands at the end of a
504
+ // restored draft rather than in front of it.
505
+ autoFocus: focusReply,
506
+ }
507
+ : undefined
508
+ }
509
+ footer={
510
+ !canComment && !thread.resolved && ComposerFallback ? (
511
+ <ComposerFallback context="thread" />
512
+ ) : undefined
513
+ }
514
+ />
515
+ )
516
+ }
@@ -86,27 +86,27 @@ describe('computeClusterTable composition', () => {
86
86
  }
87
87
  })
88
88
 
89
- it('resolves all defaults from an empty option set (Tc 40, Tu 60, eps 0.12, Dmax 120)', () => {
89
+ it('resolves all defaults from an empty option set (Tc 22, Tu 26.4, eps 0.7, Dmax 82.5)', () => {
90
90
  const leaves = randomLeaves(30, 7)
91
91
  const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
92
92
  const expected = manualPipeline(leaves, {
93
- Tc: 40,
94
- Tu: 60,
95
- eps: 0.12,
96
- Dmax: 120,
93
+ Tc: 22,
94
+ Tu: 26.4,
95
+ eps: 0.7,
96
+ Dmax: 82.5,
97
97
  ...ZOOM_BOUNDS,
98
98
  })
99
99
  expect(tableShapes(table)).toEqual(expected.map(eventShape))
100
100
  })
101
101
 
102
- it('derives Tu and Dmax from a caller-supplied Tc (Tu = 1.5·Tc, Dmax = 3·Tc)', () => {
102
+ it('derives Tu and Dmax from a caller-supplied Tc (Tu = 1.2·Tc, Dmax = 3.75·Tc)', () => {
103
103
  const leaves = randomLeaves(30, 13)
104
104
  const table = computeClusterTable(leaves, { Tc: 50, ...ZOOM_BOUNDS })
105
105
  const expected = manualPipeline(leaves, {
106
106
  Tc: 50,
107
- Tu: 75,
108
- eps: 0.12,
109
- Dmax: 150,
107
+ Tu: 60,
108
+ eps: 0.7,
109
+ Dmax: 187.5,
110
110
  ...ZOOM_BOUNDS,
111
111
  })
112
112
  expect(tableShapes(table)).toEqual(expected.map(eventShape))
@@ -114,11 +114,12 @@ describe('computeClusterTable composition', () => {
114
114
 
115
115
  it('caps coincident pairs at the default maxSplitZoom of 6 (600%)', () => {
116
116
  // Two threads at the same point: raw merge zoom is Infinity (never split), but the
117
- // default cap schedules them to merge at 4 and split at 6 like any ultra-tight pair.
117
+ // default cap schedules them to merge at 5 (maxSplitZoom / (Tu/Tc) = 6 / 1.2) and split
118
+ // at 6 like any ultra-tight pair.
118
119
  const leaves = [leaf('a', 50, 50), leaf('b', 50, 50)]
119
120
  const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
120
121
  expect(table.events).toHaveLength(1)
121
- expect(table.events[0].zMerge).toBe(4)
122
+ expect(table.events[0].zMerge).toBe(5)
122
123
  expect(table.events[0].zSplit).toBe(6)
123
124
  })
124
125
 
@@ -126,10 +127,10 @@ describe('computeClusterTable composition', () => {
126
127
  const leaves = randomLeaves(30, 29)
127
128
  const table = computeClusterTable(leaves, { Tu: 100, eps: 0, ...ZOOM_BOUNDS })
128
129
  const expected = manualPipeline(leaves, {
129
- Tc: 40,
130
+ Tc: 22,
130
131
  Tu: 100,
131
132
  eps: 0,
132
- Dmax: 120,
133
+ Dmax: 82.5,
133
134
  ...ZOOM_BOUNDS,
134
135
  })
135
136
  expect(tableShapes(table)).toEqual(expected.map(eventShape))
@@ -193,9 +194,9 @@ describe('computeClusterTable validation', () => {
193
194
  expect(() => computeClusterTable(leaves, { Tc: 0, ...ZOOM_BOUNDS })).toThrow()
194
195
  expect(() => computeClusterTable(leaves, { Tc: -5, ...ZOOM_BOUNDS })).toThrow()
195
196
  expect(() => computeClusterTable(leaves, { Tc: 40, Tu: 40, ...ZOOM_BOUNDS })).toThrow()
196
- expect(() => computeClusterTable(leaves, { Tu: 30, ...ZOOM_BOUNDS })).toThrow() // < default Tc 40
197
+ expect(() => computeClusterTable(leaves, { Tu: 20, ...ZOOM_BOUNDS })).toThrow() // < default Tc 22
197
198
  expect(() => computeClusterTable(leaves, { eps: -0.01, ...ZOOM_BOUNDS })).toThrow()
198
- expect(() => computeClusterTable(leaves, { Dmax: 30, ...ZOOM_BOUNDS })).toThrow() // < default Tc 40
199
+ expect(() => computeClusterTable(leaves, { Dmax: 20, ...ZOOM_BOUNDS })).toThrow() // < default Tc 22
199
200
  expect(() => computeClusterTable(leaves, { Tc: 50, Dmax: 40, ...ZOOM_BOUNDS })).toThrow()
200
201
  expect(() => computeClusterTable(leaves, { minZoom: 0, maxZoom: 8 })).toThrow()
201
202
  expect(() => computeClusterTable(leaves, { minZoom: 8, maxZoom: 8 })).toThrow()
@@ -226,8 +227,8 @@ describe('computeClusterTable end-to-end scenarios', () => {
226
227
  const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
227
228
  const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
228
229
 
229
- // events (centroid pricing): four pair births at zMerge 4 (zSplit 6), two
230
- // quads at zMerge 2 (zSplit 3), the bridge at zMerge 1 (zSplit 1.5)
230
+ // events (centroid pricing): four pair births at zMerge 2.2 (zSplit 2.64), two
231
+ // quads at zMerge 1.1 (zSplit 1.32), the bridge at zMerge 0.55 (zSplit 0.66)
231
232
  const seedAt = (zoom: number) => {
232
233
  const rt = createClusterRuntime(table)
233
234
  rt.seed(zoom)
@@ -235,12 +236,12 @@ describe('computeClusterTable end-to-end scenarios', () => {
235
236
  }
236
237
  // zoom 8: above every band midpoint → 8 separate pins
237
238
  expect(seedAt(8)).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8'])
238
- // zoom 3: below the pair midpoints (√244.9), above the quad midpoint (√6 ≈ 2.45)
239
- expect(seedAt(3)).toEqual(['cluster:2:p1', 'cluster:2:p3', 'cluster:2:p5', 'cluster:2:p7'])
240
- // zoom 2: quads merged, above the bridge midpoint (√1.51.22)
241
- expect(seedAt(2)).toEqual(['cluster:4:p1', 'cluster:4:p5'])
242
- // zoom 1: below everything → one badge of 8
243
- expect(seedAt(1)).toEqual(['cluster:8:p1'])
239
+ // zoom 2: below the pair midpoints (√5.812.41), above the quad midpoint (√1.451.2)
240
+ expect(seedAt(2)).toEqual(['cluster:2:p1', 'cluster:2:p3', 'cluster:2:p5', 'cluster:2:p7'])
241
+ // zoom 1: quads merged, above the bridge midpoint (√0.3630.6)
242
+ expect(seedAt(1)).toEqual(['cluster:4:p1', 'cluster:4:p5'])
243
+ // zoom 0.5: below everything → one badge of 8
244
+ expect(seedAt(0.5)).toEqual(['cluster:8:p1'])
244
245
  })
245
246
 
246
247
  it('keeps far-apart groups exactly independent at eps = 0 (bridge pruned by minZoom)', () => {
@@ -13,7 +13,7 @@ interface ResolvedClusterOptions {
13
13
  maxSplitZoom: number
14
14
  }
15
15
 
16
- /** @public */
16
+ /** @internal */
17
17
  export function computeClusterTable(
18
18
  leaves: readonly LeafInput[],
19
19
  options: ClusterOptions
@@ -40,10 +40,10 @@ export function computeClusterTable(
40
40
  }
41
41
 
42
42
  function resolveOptions(options: ClusterOptions): ResolvedClusterOptions {
43
- const Tc = options.Tc ?? 40
44
- const Tu = options.Tu ?? 1.5 * Tc
45
- const eps = options.eps ?? 0.12
46
- const Dmax = options.Dmax ?? 3 * Tc
43
+ const Tc = options.Tc ?? 22
44
+ const Tu = options.Tu ?? 1.2 * Tc
45
+ const eps = options.eps ?? 0.7
46
+ const Dmax = options.Dmax ?? 3.75 * Tc
47
47
  const maxSplitZoom = options.maxSplitZoom ?? 6
48
48
  const { minZoom, maxZoom } = options
49
49