@tldraw/commenting 0.0.0-bootstrap → 5.3.0-internal.1640468db8fd

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 (184) hide show
  1. package/commenting.css +284 -153
  2. package/dist-cjs/canvas/comment-body.js +1 -1
  3. package/dist-cjs/canvas/comment-body.js.map +2 -2
  4. package/dist-cjs/canvas/comment-drafts.js +54 -0
  5. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  6. package/dist-cjs/canvas/comment-render.js +2 -0
  7. package/dist-cjs/canvas/comment-render.js.map +2 -2
  8. package/dist-cjs/canvas/comment-tool.js +65 -12
  9. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  10. package/dist-cjs/canvas/comments-filter-menu.js +54 -63
  11. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  12. package/dist-cjs/canvas/comments-overflow-menu.js +21 -27
  13. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  14. package/dist-cjs/canvas/comments-overlay.js +304 -112
  15. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  16. package/dist-cjs/canvas/comments-sidebar.js +12 -18
  17. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  18. package/dist-cjs/canvas/comments-visibility-toggle.js +66 -0
  19. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  20. package/dist-cjs/canvas/options.js +1 -0
  21. package/dist-cjs/canvas/options.js.map +2 -2
  22. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  23. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  24. package/dist-cjs/canvas/state.js +11 -0
  25. package/dist-cjs/canvas/state.js.map +2 -2
  26. package/dist-cjs/canvas/thread-state.js +8 -1
  27. package/dist-cjs/canvas/thread-state.js.map +2 -2
  28. package/dist-cjs/clustering/runtime.js +119 -7
  29. package/dist-cjs/clustering/runtime.js.map +2 -2
  30. package/dist-cjs/index.d.ts +126 -41
  31. package/dist-cjs/index.js +7 -1
  32. package/dist-cjs/index.js.map +2 -2
  33. package/dist-cjs/ui/avatar.js +14 -6
  34. package/dist-cjs/ui/avatar.js.map +2 -2
  35. package/dist-cjs/ui/byline.js +4 -4
  36. package/dist-cjs/ui/byline.js.map +2 -2
  37. package/dist-cjs/ui/comment-author.js +17 -0
  38. package/dist-cjs/ui/comment-author.js.map +7 -0
  39. package/dist-cjs/ui/comment-card.js +4 -4
  40. package/dist-cjs/ui/comment-card.js.map +2 -2
  41. package/dist-cjs/ui/comment-composer.js +92 -29
  42. package/dist-cjs/ui/comment-composer.js.map +2 -2
  43. package/dist-cjs/ui/comment-mention.js +1 -1
  44. package/dist-cjs/ui/comment-mention.js.map +2 -2
  45. package/dist-cjs/ui/comment-pin.js +23 -3
  46. package/dist-cjs/ui/comment-pin.js.map +2 -2
  47. package/dist-cjs/ui/comment-text.js +1 -1
  48. package/dist-cjs/ui/comment-text.js.map +2 -2
  49. package/dist-cjs/ui/comment-thread.js +6 -6
  50. package/dist-cjs/ui/comment-thread.js.map +2 -2
  51. package/dist-cjs/ui/comments-list.js +12 -12
  52. package/dist-cjs/ui/comments-list.js.map +2 -2
  53. package/dist-cjs/ui/count-badge.js +1 -1
  54. package/dist-cjs/ui/count-badge.js.map +2 -2
  55. package/dist-cjs/ui/empty-state.js +3 -3
  56. package/dist-cjs/ui/empty-state.js.map +2 -2
  57. package/dist-cjs/ui/format-time.js +3 -0
  58. package/dist-cjs/ui/format-time.js.map +2 -2
  59. package/dist-cjs/ui/mention-list.js +8 -8
  60. package/dist-cjs/ui/mention-list.js.map +2 -2
  61. package/dist-cjs/ui/mention-suggestion.js +3 -3
  62. package/dist-cjs/ui/mention-suggestion.js.map +2 -2
  63. package/dist-cjs/ui/mention.js +1 -1
  64. package/dist-cjs/ui/mention.js.map +2 -2
  65. package/dist-cjs/ui/reaction.js +11 -4
  66. package/dist-cjs/ui/reaction.js.map +2 -2
  67. package/dist-cjs/ui/reactions.js +10 -2
  68. package/dist-cjs/ui/reactions.js.map +2 -2
  69. package/dist-cjs/ui/send-button.js +1 -1
  70. package/dist-cjs/ui/send-button.js.map +2 -2
  71. package/dist-cjs/ui/tooltip-button.js +32 -0
  72. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  73. package/dist-esm/canvas/comment-body.mjs +1 -1
  74. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  75. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  76. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  77. package/dist-esm/canvas/comment-render.mjs +2 -0
  78. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  79. package/dist-esm/canvas/comment-tool.mjs +67 -14
  80. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  81. package/dist-esm/canvas/comments-filter-menu.mjs +54 -63
  82. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  83. package/dist-esm/canvas/comments-overflow-menu.mjs +22 -27
  84. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  85. package/dist-esm/canvas/comments-overlay.mjs +322 -114
  86. package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
  87. package/dist-esm/canvas/comments-sidebar.mjs +15 -21
  88. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  89. package/dist-esm/canvas/comments-visibility-toggle.mjs +46 -0
  90. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  91. package/dist-esm/canvas/options.mjs +1 -0
  92. package/dist-esm/canvas/options.mjs.map +2 -2
  93. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  94. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  95. package/dist-esm/canvas/state.mjs +11 -0
  96. package/dist-esm/canvas/state.mjs.map +2 -2
  97. package/dist-esm/canvas/thread-state.mjs +8 -1
  98. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  99. package/dist-esm/clustering/runtime.mjs +119 -7
  100. package/dist-esm/clustering/runtime.mjs.map +2 -2
  101. package/dist-esm/index.d.mts +126 -41
  102. package/dist-esm/index.mjs +11 -1
  103. package/dist-esm/index.mjs.map +2 -2
  104. package/dist-esm/ui/avatar.mjs +14 -6
  105. package/dist-esm/ui/avatar.mjs.map +2 -2
  106. package/dist-esm/ui/byline.mjs +4 -4
  107. package/dist-esm/ui/byline.mjs.map +2 -2
  108. package/dist-esm/ui/comment-author.mjs +1 -0
  109. package/dist-esm/ui/comment-author.mjs.map +7 -0
  110. package/dist-esm/ui/comment-card.mjs +4 -4
  111. package/dist-esm/ui/comment-card.mjs.map +2 -2
  112. package/dist-esm/ui/comment-composer.mjs +93 -30
  113. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  114. package/dist-esm/ui/comment-mention.mjs +1 -1
  115. package/dist-esm/ui/comment-mention.mjs.map +2 -2
  116. package/dist-esm/ui/comment-pin.mjs +23 -3
  117. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  118. package/dist-esm/ui/comment-text.mjs +1 -1
  119. package/dist-esm/ui/comment-text.mjs.map +2 -2
  120. package/dist-esm/ui/comment-thread.mjs +6 -6
  121. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  122. package/dist-esm/ui/comments-list.mjs +12 -12
  123. package/dist-esm/ui/comments-list.mjs.map +2 -2
  124. package/dist-esm/ui/count-badge.mjs +1 -1
  125. package/dist-esm/ui/count-badge.mjs.map +2 -2
  126. package/dist-esm/ui/empty-state.mjs +3 -3
  127. package/dist-esm/ui/empty-state.mjs.map +2 -2
  128. package/dist-esm/ui/format-time.mjs +3 -0
  129. package/dist-esm/ui/format-time.mjs.map +2 -2
  130. package/dist-esm/ui/mention-list.mjs +8 -8
  131. package/dist-esm/ui/mention-list.mjs.map +2 -2
  132. package/dist-esm/ui/mention-suggestion.mjs +3 -3
  133. package/dist-esm/ui/mention-suggestion.mjs.map +2 -2
  134. package/dist-esm/ui/mention.mjs +1 -1
  135. package/dist-esm/ui/mention.mjs.map +2 -2
  136. package/dist-esm/ui/reaction.mjs +11 -4
  137. package/dist-esm/ui/reaction.mjs.map +2 -2
  138. package/dist-esm/ui/reactions.mjs +10 -2
  139. package/dist-esm/ui/reactions.mjs.map +2 -2
  140. package/dist-esm/ui/send-button.mjs +1 -1
  141. package/dist-esm/ui/send-button.mjs.map +2 -2
  142. package/dist-esm/ui/tooltip-button.mjs +12 -0
  143. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  144. package/package.json +3 -3
  145. package/src/canvas/canvas.css +79 -36
  146. package/src/canvas/comment-body.tsx +1 -1
  147. package/src/canvas/comment-drafts.test.ts +55 -0
  148. package/src/canvas/comment-drafts.ts +46 -0
  149. package/src/canvas/comment-render.test.ts +1 -1
  150. package/src/canvas/comment-render.ts +7 -2
  151. package/src/canvas/comment-tool.tsx +98 -17
  152. package/src/canvas/comments-filter-menu.tsx +27 -33
  153. package/src/canvas/comments-overflow-menu.tsx +13 -21
  154. package/src/canvas/comments-overlay.tsx +441 -145
  155. package/src/canvas/comments-sidebar.tsx +27 -28
  156. package/src/canvas/comments-visibility-toggle.tsx +50 -0
  157. package/src/canvas/options.ts +25 -0
  158. package/src/canvas/sidebar-filters.ts +4 -3
  159. package/src/canvas/state.ts +22 -0
  160. package/src/canvas/thread-state.ts +16 -3
  161. package/src/clustering/runtime.test.ts +183 -7
  162. package/src/clustering/runtime.ts +164 -15
  163. package/src/index.ts +7 -0
  164. package/src/ui/avatar.tsx +15 -11
  165. package/src/ui/byline.tsx +6 -5
  166. package/src/ui/comment-author.ts +15 -0
  167. package/src/ui/comment-card.tsx +6 -5
  168. package/src/ui/comment-composer.tsx +122 -33
  169. package/src/ui/comment-mention.ts +2 -2
  170. package/src/ui/comment-pin.tsx +35 -4
  171. package/src/ui/comment-text.tsx +1 -1
  172. package/src/ui/comment-thread.tsx +8 -6
  173. package/src/ui/comments-list.tsx +19 -14
  174. package/src/ui/comments.css +205 -117
  175. package/src/ui/count-badge.tsx +1 -1
  176. package/src/ui/empty-state.tsx +3 -3
  177. package/src/ui/format-time.ts +5 -0
  178. package/src/ui/mention-list.tsx +13 -16
  179. package/src/ui/mention-suggestion.tsx +3 -3
  180. package/src/ui/mention.tsx +1 -1
  181. package/src/ui/reaction.tsx +6 -3
  182. package/src/ui/reactions.tsx +6 -2
  183. package/src/ui/send-button.tsx +1 -1
  184. package/src/ui/tooltip-button.tsx +20 -0
@@ -1,4 +1,4 @@
1
- import { ReactNode, useRef } from 'react'
1
+ import { ReactNode, useCallback, useRef } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import {
4
4
  TLComment,
@@ -9,21 +9,22 @@ import {
9
9
  useTranslation,
10
10
  useValue,
11
11
  } from 'tldraw'
12
+ import { CommentAuthor } from '../ui/comment-author'
12
13
  import { CommentListItemProps, CommentsList } from '../ui/comments-list'
13
- import { UNKNOWN_AUTHOR } from './comment-render'
14
+ import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
14
15
  import { CommentsFilterMenu } from './comments-filter-menu'
15
- import { CommentsOverflowMenu } from './comments-overflow-menu'
16
+ import { CommentsVisibilityToggle } from './comments-visibility-toggle'
16
17
  import { useComments, useCommentThreads } from './hooks'
17
18
  import { useCommentingEnabled } from './license'
18
19
  import { useCommentingOptions } from './options'
19
20
  import { richTextToPlaintext } from './rich-text'
20
- import { openThreadId, sidebarFilters } from './state'
21
+ import { commentsSidebarOpen, openThreadId, sidebarFilters } from './state'
21
22
  import { focusThread } from './thread-state'
22
23
 
23
24
  /** @public */
24
25
  export interface CanvasCommentsSidebarProps {
25
- /** Map an author id to a display name, or `undefined` when the id can't be named. */
26
- resolveName(id: string): string | undefined
26
+ /** Map an author id to their display info, or `undefined` when the id can't be resolved. */
27
+ resolveAuthor(id: string): CommentAuthor | undefined
27
28
  /** The signed-in user's id. Enables the "only your threads" filter when present. */
28
29
  currentUserId?: string
29
30
  /**
@@ -31,8 +32,6 @@ export interface CanvasCommentsSidebarProps {
31
32
  * "only unread" filter when present.
32
33
  */
33
34
  isCommentUnread?(commentId: TLCommentId): boolean
34
- /** Tool ids that show the sidebar. Defaults to the comment tool. */
35
- tools?: string[]
36
35
  /** Header above the list. */
37
36
  header?: ReactNode
38
37
  /** Shown when the page has no threads. */
@@ -42,31 +41,26 @@ export interface CanvasCommentsSidebarProps {
42
41
  }
43
42
 
44
43
  /**
45
- * A comments list panel for the current page, shown while the comment tool is active. Clicking a
46
- * thread brings its pin into view and opens it. Batteries-included over the store (a sibling to
47
- * `CanvasComments`); `CommentsList` is exported for a differently-placed or always-on list.
44
+ * A comments list panel for the current page, shown while {@link commentsSidebarOpen} is set (e.g.
45
+ * toggled by a button). Clicking a thread brings its pin into view and opens it. Batteries-included
46
+ * over the store (a sibling to `CanvasComments`); `CommentsList` is exported for a differently-placed
47
+ * or always-on list.
48
48
  * @public @react
49
49
  */
50
50
  export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
51
- const {
52
- resolveName,
53
- currentUserId,
54
- isCommentUnread,
55
- tools,
56
- header,
57
- empty,
58
- impreciseShapeAnchor,
59
- } = props
51
+ const { resolveAuthor, currentUserId, isCommentUnread, header, empty, impreciseShapeAnchor } =
52
+ props
53
+ // Name-only view of the resolver, for the plaintext previews (which resolve @-mentions).
54
+ const resolveName = useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
60
55
  const editor = useEditor()
61
56
  const options = useCommentingOptions()
62
- const sidebarTools = tools ?? ['comment']
63
57
  const container = useContainer()
64
58
  const commentingEnabled = useCommentingEnabled()
65
59
  const msg = useTranslation()
66
60
  const threads = useCommentThreads(editor)
67
61
  const comments = useComments(editor)
68
62
  const currentPageId = useValue('page id', () => editor.getCurrentPageId(), [editor])
69
- const activeTool = useValue('tool id', () => editor.getCurrentToolId(), [editor])
63
+ const open = useValue('sidebar open', () => commentsSidebarOpen.get(editor), [editor])
70
64
  const openId = useValue('open thread', () => openThreadId.get(editor), [editor])
71
65
  const filters = useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
72
66
  const pageNames = useValue(
@@ -75,7 +69,7 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
75
69
  [editor]
76
70
  )
77
71
 
78
- if (!commentingEnabled || !sidebarTools.includes(activeTool)) return null
72
+ if (!commentingEnabled || !open) return null
79
73
 
80
74
  // Group comments by thread (they arrive oldest-first, so [0] is each thread's first comment).
81
75
  const byThread = new Map<string, TLComment[]>()
@@ -121,11 +115,16 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
121
115
  }
122
116
  return {
123
117
  id: thread.id,
124
- author: resolveName(thread.createdBy) ?? UNKNOWN_AUTHOR,
118
+ author: resolveAuthor(thread.createdBy) ?? UNKNOWN_COMMENT_AUTHOR,
125
119
  preview,
126
120
  date: new Date((first ?? thread).createdAt).toISOString(),
127
121
  resolved: thread.resolved != null,
128
- page: pageNames.get(thread.pageId),
122
+ // The page label only earns its place when it adds information: multiple pages
123
+ // exist, and the thread is somewhere other than where you already are.
124
+ page:
125
+ pageNames.size > 1 && thread.pageId !== currentPageId
126
+ ? pageNames.get(thread.pageId)
127
+ : undefined,
129
128
  count: threadComments.length,
130
129
  selected: openId === thread.id,
131
130
  }
@@ -149,12 +148,12 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
149
148
  items={items}
150
149
  header={header ?? msg('comments.title')}
151
150
  headerAction={
152
- <div className="cmt-list__header-actions">
151
+ <div className="tlui-cmt-list__header-actions">
153
152
  <CommentsFilterMenu
154
153
  canFilterByAuthor={currentUserId !== undefined}
155
154
  canFilterByUnread={isCommentUnread !== undefined}
156
155
  />
157
- <CommentsOverflowMenu />
156
+ <CommentsVisibilityToggle />
158
157
  </div>
159
158
  }
160
159
  empty={
@@ -176,7 +175,7 @@ function SidebarPanel({ container, children }: { container: HTMLElement; childre
176
175
  const ref = useRef<HTMLDivElement>(null)
177
176
  usePassThroughMouseOverEvents(ref)
178
177
  return createPortal(
179
- <div ref={ref} className="cmt-canvas-sidebar">
178
+ <div ref={ref} className="tlui-cmt-canvas-sidebar" onContextMenu={(e) => e.stopPropagation()}>
180
179
  {children}
181
180
  </div>,
182
181
  container
@@ -0,0 +1,50 @@
1
+ import { useEditor, useTranslation, useValue } from 'tldraw'
2
+ import { TooltipButton } from '../ui/tooltip-button'
3
+ import { commentsHidden, toggleCommentsHidden } from './state'
4
+
5
+ /** The sidebar header's show/hide toggle for comment pins — an eye that closes while comments
6
+ * are hidden. The same state as the Shift+C shortcut.
7
+ * @public @react */
8
+ export function CommentsVisibilityToggle() {
9
+ const editor = useEditor()
10
+ const msg = useTranslation()
11
+ const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
12
+ const label = hidden ? msg('comments.show') : msg('comments.hide')
13
+
14
+ return (
15
+ <TooltipButton
16
+ tooltip={label}
17
+ className="tlui-cmt-header-btn"
18
+ aria-pressed={hidden}
19
+ onClick={() => toggleCommentsHidden(editor)}
20
+ >
21
+ {hidden ? <EyeClosedIcon /> : <EyeOpenIcon />}
22
+ </TooltipButton>
23
+ )
24
+ }
25
+
26
+ function EyeOpenIcon() {
27
+ return (
28
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
29
+ <path
30
+ d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.5C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
31
+ fill="currentColor"
32
+ fillRule="evenodd"
33
+ clipRule="evenodd"
34
+ />
35
+ </svg>
36
+ )
37
+ }
38
+
39
+ function EyeClosedIcon() {
40
+ return (
41
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
42
+ <path
43
+ d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 11 7.50001 11C6.99965 11 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.39889 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 10 7.50001 10C10.3078 10 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
44
+ fill="currentColor"
45
+ fillRule="evenodd"
46
+ clipRule="evenodd"
47
+ />
48
+ </svg>
49
+ )
50
+ }
@@ -4,9 +4,24 @@ import {
4
4
  type TLComment,
5
5
  type TLCommentThread,
6
6
  type TLHistoryBatchOptions,
7
+ type TLShapeId,
8
+ type VecLike,
7
9
  useEditor,
8
10
  } from 'tldraw'
9
11
 
12
+ /**
13
+ * The gesture that's creating a shape anchor, passed to
14
+ * {@link CommentingOptions.shouldBePrecise}: the target shape, the page point of the release, and
15
+ * whether Alt was held.
16
+ *
17
+ * @public
18
+ */
19
+ export interface ShapeCommentPrecisionContext {
20
+ readonly shapeId: TLShapeId
21
+ readonly point: VecLike
22
+ readonly altKey: boolean
23
+ }
24
+
10
25
  /**
11
26
  * Component overrides for the batteries-included comments layer. Each slot replaces a built-in
12
27
  * piece; leave a slot unset to keep its default.
@@ -59,6 +74,15 @@ export interface CommentingOptions {
59
74
  // ── Anchoring ────────────────────────────────────────────────────────────────────────────
60
75
  /** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
61
76
  readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
77
+ /**
78
+ * Whether a comment landing on a shape anchors precisely — pinned to the exact clicked spot
79
+ * within the shape — or imprecisely — pinned to the shape as a whole, rendered at
80
+ * `impreciseShapeAnchor`. Called wherever a shape anchor is created (placing with the comment
81
+ * tool, dropping a dragged pin onto a shape). Always precise by default. Return `false` for
82
+ * shape-level anchoring, or decide from the context — the Alt key's state, or the shape itself,
83
+ * e.g. precise only on notes. Governs new placements only; existing anchors render as stored.
84
+ */
85
+ shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean
62
86
 
63
87
  // ── Clustering tuning ─────────────────────────────────────────────────────────────────────
64
88
  /** Screen-pixel margin by which the viewport is inflated when culling cluster badges. */
@@ -81,6 +105,7 @@ export const defaultCommentingOptions = {
81
105
  dragHistory: undefined,
82
106
  enableClustering: true,
83
107
  impreciseShapeAnchor: { x: 1, y: 0 },
108
+ shouldBePrecise: () => true,
84
109
  clusterCullMargin: 120,
85
110
  clusterSplitZoomFactor: 1.05,
86
111
  components: {},
@@ -16,10 +16,11 @@ export interface SidebarFilters {
16
16
  onlyCurrentPage: boolean
17
17
  }
18
18
 
19
- /** @public */
19
+ /** The out-of-the-box view: every page's threads, resolved ones hidden until asked for.
20
+ * @public */
20
21
  export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters = {
21
- showResolved: true,
22
+ showResolved: false,
22
23
  onlyMine: false,
23
24
  onlyUnread: false,
24
- onlyCurrentPage: true,
25
+ onlyCurrentPage: false,
25
26
  }
@@ -36,6 +36,15 @@ export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () =>
36
36
  */
37
37
  export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
38
38
 
39
+ /**
40
+ * Whether the comments sidebar (the thread list) is open. Driven by an explicit control — a button
41
+ * next to Share on dotcom — rather than by which tool is active, so browsing threads is separate
42
+ * from placing them. The comment tool additionally closes it on enter, keeping placement
43
+ * canvas-focused.
44
+ * @public
45
+ */
46
+ export const commentsSidebarOpen = new EditorAtom<boolean>('commentsSidebarOpen', () => false)
47
+
39
48
  /** Which threads the comments sidebar shows.
40
49
  * @public */
41
50
  export const sidebarFilters = new EditorAtom<SidebarFilters>(
@@ -49,6 +58,12 @@ export function toggleCommentsHidden(editor: Editor): void {
49
58
  commentsHidden.update(editor, (hidden) => !hidden)
50
59
  }
51
60
 
61
+ /** Open or close the comments sidebar for an editor.
62
+ * @public */
63
+ export function toggleCommentsSidebar(editor: Editor): void {
64
+ commentsSidebarOpen.update(editor, (open) => !open)
65
+ }
66
+
52
67
  /** React hook for the open thread id.
53
68
  * @public */
54
69
  export function useOpenThreadId(): string | null {
@@ -70,6 +85,13 @@ export function useCommentsHidden(): boolean {
70
85
  return useValue('comments hidden', () => commentsHidden.get(editor), [editor])
71
86
  }
72
87
 
88
+ /** React hook for whether the comments sidebar is open.
89
+ * @public */
90
+ export function useCommentsSidebarOpen(): boolean {
91
+ const editor = useEditor()
92
+ return useValue('comments sidebar open', () => commentsSidebarOpen.get(editor), [editor])
93
+ }
94
+
73
95
  /** React hook for the current sidebar filters.
74
96
  * @public */
75
97
  export function useSidebarFilters(): SidebarFilters {
@@ -10,6 +10,18 @@ export const DEFAULT_IMPRECISE_SHAPE_ANCHOR = { x: 1, y: 0 }
10
10
  * which corner has no resize handle all derive from the chosen corner. */
11
11
  export const REGION_PIN_CORNER: VecLike = { x: 1, y: 1 }
12
12
 
13
+ /** A region anchor's pin corner: the corner its creating drag released on, when recorded, else
14
+ * the editor's configured default. @public */
15
+ export function regionAnchorPinCorner(
16
+ editor: Editor,
17
+ anchor: Extract<TLCommentAnchor, { type: 'region' }>
18
+ ): VecLike {
19
+ if (anchor.pinX !== undefined && anchor.pinY !== undefined) {
20
+ return { x: anchor.pinX, y: anchor.pinY }
21
+ }
22
+ return getRegionCommentOptions(editor).pinCorner
23
+ }
24
+
13
25
  /** The page point of a region's pin corner. */
14
26
  export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CORNER): VecLike {
15
27
  return {
@@ -45,7 +57,7 @@ export function anchorPagePoint(
45
57
  case 'point':
46
58
  return { x: anchor.x, y: anchor.y }
47
59
  case 'region':
48
- return regionPinPoint(anchor, getRegionCommentOptions(editor).pinCorner)
60
+ return regionPinPoint(anchor, regionAnchorPinCorner(editor, anchor))
49
61
  case 'page':
50
62
  return null
51
63
  }
@@ -53,8 +65,9 @@ export function anchorPagePoint(
53
65
 
54
66
  /**
55
67
  * A shape anchor for a page point. `x`/`y` are the point's normalized (0–1) offset within the
56
- * shape's page bounds, remembered either way. When `precise` (Alt held) the pin sits at exactly
57
- * `x`/`y`; otherwise it sits at the consumer's imprecise default (top-right out of the box).
68
+ * shape's page bounds, remembered either way. When `precise` the pin sits at exactly `x`/`y`;
69
+ * otherwise it sits at the consumer's imprecise default (top-right out of the box). Placement
70
+ * gestures get `precise` from the `shouldBePrecise` commenting option (always precise, by default).
58
71
  * @public
59
72
  */
60
73
  export function shapeAnchorAt(
@@ -460,6 +460,7 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
460
460
  ])
461
461
  )
462
462
  expect(rt.k).toBe(1)
463
+ expect(rt.getSuppressedCount()).toBe(0)
463
464
  expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
464
465
  })
465
466
 
@@ -477,7 +478,8 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
477
478
  [D.id, D],
478
479
  ])
479
480
  )
480
- expect(rt.k).toBe(0)
481
+ expect(rt.k).toBe(1)
482
+ expect(rt.getSuppressedCount()).toBe(1)
481
483
  expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
482
484
  })
483
485
 
@@ -512,7 +514,9 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
512
514
  const table = microTraceTable()
513
515
  const rt = createClusterRuntime(table)
514
516
  rt.seedFrom(5, new Map())
515
- expect(rt.k).toBe(0)
517
+ expect(rt.k).toBe(1)
518
+ expect(rt.getSuppressedCount()).toBe(1)
519
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
516
520
  const partial = node(['a', 'b'], 5, 0)
517
521
  rt.seedFrom(
518
522
  5,
@@ -522,7 +526,9 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
522
526
  [D.id, D],
523
527
  ])
524
528
  )
525
- expect(rt.k).toBe(0)
529
+ expect(rt.k).toBe(1)
530
+ expect(rt.getSuppressedCount()).toBe(1)
531
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
526
532
  })
527
533
 
528
534
  it('membership in a superset cluster counts as merged', () => {
@@ -534,10 +540,58 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
534
540
  expect(visibleIds(rt)).toEqual(['cluster:4:a'])
535
541
  })
536
542
 
537
- it('classification stops at the first inactive event (prefix cut is conservative)', () => {
538
- // Two independent band events at zoom 4.5. The first is split in history, the
539
- // second merged but active events must be a prefix, so the second resolves
540
- // to split (conservative: never merges anything history did not sanction).
543
+ it('carries over mixed band states exactly (split before merged in table order)', () => {
544
+ // Two independent band events at zoom 4.5: the earlier-sorted one split in history,
545
+ // the later one merged. The old prefix cut forced the second to split (the mass-split
546
+ // bug); with suppression the carryover is exact each keeps its own state.
547
+ const AB = node(['a', 'b'], 5, 0)
548
+ const CD = node(['c', 'd'], 105, 0)
549
+ const table: ClusterTable = {
550
+ events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
551
+ leaves: [A, B, C, D],
552
+ }
553
+ const rt = createClusterRuntime(table)
554
+ rt.seedFrom(
555
+ 4.5,
556
+ new Map([
557
+ [A.id, A],
558
+ [B.id, B],
559
+ [CD.id, CD],
560
+ ])
561
+ )
562
+ expect(rt.k).toBe(2)
563
+ expect(rt.getSuppressedCount()).toBe(1)
564
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'cluster:2:c'])
565
+ })
566
+
567
+ it('a suppressed event merges (heals) when a zoom-out crosses its own zMerge', () => {
568
+ const AB = node(['a', 'b'], 5, 0)
569
+ const CD = node(['c', 'd'], 105, 0)
570
+ const table: ClusterTable = {
571
+ events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
572
+ leaves: [A, B, C, D],
573
+ }
574
+ const rt = createClusterRuntime(table)
575
+ rt.seedFrom(
576
+ 4.5,
577
+ new Map([
578
+ [A.id, A],
579
+ [B.id, B],
580
+ [CD.id, CD],
581
+ ])
582
+ )
583
+ const versionBefore = rt.version
584
+ rt.onCamera(4.4) // still inside AB's band: nothing happens
585
+ expect(rt.version).toBe(versionBefore)
586
+ rt.onCamera(3.9) // below AB's zMerge (4): the held-out merge fires at its own threshold
587
+ expect(rt.version).toBeGreaterThan(versionBefore)
588
+ expect(rt.k).toBe(2)
589
+ expect(rt.getSuppressedCount()).toBe(0)
590
+ expect(visibleIds(rt)).toEqual(['cluster:2:a', 'cluster:2:c'])
591
+ expectPostconditions(rt, table, 3.9)
592
+ })
593
+
594
+ it('the split walk retreats past a suppressed event without corrupting the partition', () => {
541
595
  const AB = node(['a', 'b'], 5, 0)
542
596
  const CD = node(['c', 'd'], 105, 0)
543
597
  const table: ClusterTable = {
@@ -553,8 +607,18 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
553
607
  [CD.id, CD],
554
608
  ])
555
609
  )
610
+ rt.onCamera(5.5) // past CD's zSplit (5): CD splits; suppressed AB stays put
611
+ expect(rt.k).toBe(1)
612
+ expect(rt.getSuppressedCount()).toBe(1)
613
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
614
+ rt.onCamera(6.5) // past AB's zSplit (6): retreat past the suppressed (never-applied) event
556
615
  expect(rt.k).toBe(0)
616
+ expect(rt.getSuppressedCount()).toBe(0)
557
617
  expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
618
+ rt.onCamera(3.9) // zoom back out: normal cursor walk from a clean state
619
+ expect(rt.k).toBe(1)
620
+ expect(visibleIds(rt)).toEqual(['c', 'cluster:2:a', 'd'])
621
+ expectPostconditions(rt, table, 3.9)
558
622
  })
559
623
 
560
624
  it('seedFrom followed by onCamera at the same zoom is a no-op', () => {
@@ -594,3 +658,115 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
594
658
  expect(() => rt.seedFrom(NaN, new Map())).toThrow()
595
659
  })
596
660
  })
661
+
662
+ describe('createClusterRuntime detachLeaf (local partition edits)', () => {
663
+ it('shrinks the containing badge in place: members, count, centroid', () => {
664
+ const table = microTraceTable()
665
+ const rt = createClusterRuntime(table)
666
+ rt.seed(4) // P = a+b+c visible, d separate
667
+ const versionBefore = rt.version
668
+ rt.detachLeaf('a')
669
+ expect(rt.version).toBeGreaterThan(versionBefore)
670
+ expect(rt.getDetachedCount()).toBe(1)
671
+ const patchedP = rt.getVisible().get(P.id)!
672
+ expect(patchedP.members).toEqual(['b', 'c'])
673
+ expect(patchedP.count).toBe(2)
674
+ // centroid recomputed from the remaining leaves: B (10,0), C (20,0)
675
+ expect(patchedP.centroid).toEqual({ x: 15, y: 0 })
676
+ // keeps the structural id so cursor events keep addressing it
677
+ expect(patchedP.id).toBe(P.id)
678
+ // everything else untouched
679
+ expect(rt.getVisible().get('d')).toEqual(D)
680
+ expect(rt.getVisible().size).toBe(2)
681
+ })
682
+
683
+ it('is idempotent and ignores unknown ids', () => {
684
+ const table = microTraceTable()
685
+ const rt = createClusterRuntime(table)
686
+ rt.seed(4)
687
+ rt.detachLeaf('a')
688
+ const version = rt.version
689
+ rt.detachLeaf('a')
690
+ rt.detachLeaf('nonexistent')
691
+ expect(rt.version).toBe(version)
692
+ expect(rt.getDetachedCount()).toBe(1)
693
+ })
694
+
695
+ it('collapses a badge to its surviving leaf, and to nothing', () => {
696
+ const table = microTraceTable()
697
+ const rt = createClusterRuntime(table)
698
+ rt.seed(4)
699
+ rt.detachLeaf('a')
700
+ rt.detachLeaf('b')
701
+ // P = {a,b,c} minus a,b → the leaf node c, keyed by its own id
702
+ expect(rt.getVisible().get('c')).toEqual(C)
703
+ expect(rt.getVisible().has(P.id)).toBe(false)
704
+ rt.detachLeaf('c')
705
+ expect(visibleIds(rt)).toEqual(['d'])
706
+ })
707
+
708
+ it('removes a leaf that is visible as its own pin', () => {
709
+ const table = microTraceTable()
710
+ const rt = createClusterRuntime(table)
711
+ rt.seed(8) // everything split
712
+ rt.detachLeaf('a')
713
+ expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
714
+ })
715
+
716
+ it('zoom walks stay correct while patches are active (split)', () => {
717
+ const table = microTraceTable()
718
+ const rt = createClusterRuntime(table)
719
+ rt.seed(4)
720
+ rt.detachLeaf('a')
721
+ rt.onCamera(6.5) // past P's zSplit (6): structural split of a+b+c
722
+ expect(rt.k).toBe(0)
723
+ // resolved view: a stays gone, b/c/d as pins — the split itself changed nothing for a
724
+ expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
725
+ })
726
+
727
+ it('zoom walks stay correct while patches are active (merge)', () => {
728
+ const table = microTraceTable()
729
+ const rt = createClusterRuntime(table)
730
+ rt.seed(4)
731
+ rt.detachLeaf('a')
732
+ rt.onCamera(0.9) // below E1's zMerge (1): P+D merge into Q
733
+ expect(rt.k).toBe(2)
734
+ const patchedQ = rt.getVisible().get(Q.id)!
735
+ expect(patchedQ.members).toEqual(['b', 'c', 'd'])
736
+ expect(patchedQ.count).toBe(3)
737
+ // mean of B (10,0), C (20,0), D (100,0)
738
+ expect(patchedQ.centroid.x).toBeCloseTo(130 / 3)
739
+ expect(rt.getVisible().size).toBe(1)
740
+ })
741
+
742
+ it('seed and seedFrom clear detaches', () => {
743
+ const table = microTraceTable()
744
+ const rt = createClusterRuntime(table)
745
+ rt.seed(4)
746
+ rt.detachLeaf('a')
747
+ rt.seed(4)
748
+ expect(rt.getDetachedCount()).toBe(0)
749
+ expect(rt.getVisible().get(P.id)).toEqual(P)
750
+ rt.detachLeaf('a')
751
+ rt.seedFrom(
752
+ 4,
753
+ new Map([
754
+ [P.id, P],
755
+ [D.id, D],
756
+ ])
757
+ )
758
+ expect(rt.getDetachedCount()).toBe(0)
759
+ expect(rt.getVisible().get(P.id)).toEqual(P)
760
+ })
761
+
762
+ it('getVisible returns a stable reference until the partition changes', () => {
763
+ const table = microTraceTable()
764
+ const rt = createClusterRuntime(table)
765
+ rt.seed(4)
766
+ rt.detachLeaf('a')
767
+ const first = rt.getVisible()
768
+ expect(rt.getVisible()).toBe(first)
769
+ rt.detachLeaf('b')
770
+ expect(rt.getVisible()).not.toBe(first)
771
+ })
772
+ })