@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,106 @@
1
+ import { ReactNode } from 'react'
2
+ import { useTranslation } from 'tldraw'
3
+ import { Avatar } from './avatar'
4
+
5
+ /** A person the composer's \@-mention picker can offer. @public */
6
+ export interface MentionMember {
7
+ id: string
8
+ name: string
9
+ /** Avatar image URL. Falls back to a coloured initial when omitted. */
10
+ avatar?: string
11
+ /** Avatar background colour, used when there's no `avatar` image. */
12
+ color?: string
13
+ /** A secondary line under the name — e.g. an email or handle. Omit for a single-line row. */
14
+ secondary?: string
15
+ /** Marks the current user; shown as a "(You)" suffix after the name. */
16
+ you?: boolean
17
+ /** Hosts may carry extra fields; a custom `renderMember` can read them. */
18
+ [key: string]: unknown
19
+ }
20
+
21
+ /** @public */
22
+ export interface MentionListProps {
23
+ /** Members to choose from — already resolved for the current query by the host. */
24
+ members: MentionMember[]
25
+ /** Index of the highlighted row, driven by the composer's keyboard navigation. */
26
+ activeIndex?: number
27
+ /** Called when a member is chosen (click, or Enter on the active row). */
28
+ onSelect?(member: MentionMember): void
29
+ /** Shown when no member matches the query. Defaults to a translated "No matches". */
30
+ emptyLabel?: string
31
+ /** Override a row's content (inside the selectable button). Defaults to avatar + name (+ secondary). */
32
+ renderMember?(member: MentionMember): ReactNode
33
+ }
34
+
35
+ /** The default row content: avatar, name (with a "(You)" marker), and an optional secondary line. */
36
+ function DefaultMemberRow({ member }: { member: MentionMember }) {
37
+ const msg = useTranslation()
38
+ return (
39
+ <>
40
+ <Avatar name={member.name} color={member.color} image={member.avatar} />
41
+ <span className="cmt-mention-list__text">
42
+ <span className="cmt-mention-list__name">
43
+ {member.name}
44
+ {member.you && (
45
+ <span className="cmt-mention-list__you">{`(${msg('comments.mention-you')})`}</span>
46
+ )}
47
+ </span>
48
+ {member.secondary && (
49
+ <span className="cmt-mention-list__secondary">{member.secondary}</span>
50
+ )}
51
+ </span>
52
+ </>
53
+ )
54
+ }
55
+
56
+ /**
57
+ * The \@-mention member picker: a popover list of people to mention, shown as the user types after
58
+ * `@`. Presentational — the composer's suggestion plugin owns resolution, keyboard navigation, and
59
+ * placement, and drives this with the host-resolved `members` and the highlighted `activeIndex`.
60
+ * @public @react
61
+ */
62
+ export function MentionList({
63
+ members,
64
+ activeIndex = 0,
65
+ onSelect,
66
+ emptyLabel,
67
+ renderMember,
68
+ }: MentionListProps) {
69
+ const msg = useTranslation()
70
+ if (members.length === 0) {
71
+ return (
72
+ <div className="cmt-mention-list cmt-mention-list--empty">
73
+ {emptyLabel ?? msg('comments.mention-no-matches')}
74
+ </div>
75
+ )
76
+ }
77
+ return (
78
+ <div className="cmt-mention-list" role="listbox">
79
+ {members.map((member, i) => {
80
+ const active = i === activeIndex
81
+ return (
82
+ <button
83
+ key={member.id}
84
+ type="button"
85
+ role="option"
86
+ aria-selected={active}
87
+ className={
88
+ active
89
+ ? 'cmt-mention-list__item cmt-mention-list__item--active'
90
+ : 'cmt-mention-list__item'
91
+ }
92
+ // Keep the composer focused on a row press: it's portaled outside the editor, so a
93
+ // bare mousedown would blur the composer — which now dismisses the picker — before
94
+ // the click could select. Selection runs on click; focus stays put.
95
+ onMouseDown={(e) => e.preventDefault()}
96
+ onClick={() => {
97
+ if (onSelect) onSelect(member)
98
+ }}
99
+ >
100
+ {renderMember ? renderMember(member) : <DefaultMemberRow member={member} />}
101
+ </button>
102
+ )
103
+ })}
104
+ </div>
105
+ )
106
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { filterMentionMembers } from './mention-suggestion'
3
+
4
+ const members = [
5
+ { id: 'u1', name: 'Ada Lovelace' },
6
+ { id: 'u2', name: 'Alan Turing' },
7
+ { id: 'u3', name: 'Grace Hopper' },
8
+ ]
9
+ const ids = (q: string) => filterMentionMembers(members, q).map((m) => m.id)
10
+
11
+ describe('filterMentionMembers', () => {
12
+ it('matches a case-insensitive substring; empty query keeps all', () => {
13
+ expect(ids('')).toEqual(['u1', 'u2', 'u3'])
14
+ expect(ids('grace')).toEqual(['u3'])
15
+ expect(ids('AL')).toEqual(['u2']) // "Alan", case-insensitive; "Ada Lovelace" has no "al"
16
+ expect(ids('zzz')).toEqual([])
17
+ })
18
+ })
@@ -0,0 +1,285 @@
1
+ import type { MentionNodeAttrs } from '@tiptap/extension-mention'
2
+ import { ReactRenderer } from '@tiptap/react'
3
+ import type { SuggestionKeyDownProps, SuggestionOptions } from '@tiptap/suggestion'
4
+ import { type ReactNode, forwardRef, useImperativeHandle, useState } from 'react'
5
+ import { type Editor as TldrawEditor, atom, react } from 'tldraw'
6
+ import { MentionList, MentionMember } from './mention-list'
7
+
8
+ /** The handle the suggestion plugin drives — it forwards navigation keys into the popup. */
9
+ export interface MentionPopupHandle {
10
+ onKeyDown(props: SuggestionKeyDownProps): boolean
11
+ }
12
+
13
+ interface MentionPopupProps {
14
+ items: MentionMember[]
15
+ command(attrs: MentionNodeAttrs): void
16
+ renderMember?(member: MentionMember): ReactNode
17
+ }
18
+
19
+ /** The live @-picker popup: owns the highlighted index and keyboard, renders the presentational list. */
20
+ const MentionPopup = forwardRef<MentionPopupHandle, MentionPopupProps>(function MentionPopup(
21
+ { items, command, renderMember },
22
+ ref
23
+ ) {
24
+ const [activeIndex, setActiveIndex] = useState(0)
25
+ // A new query yields new items; reset the highlight to the top during render — not in an effect,
26
+ // which would leave a frame where `activeIndex` still points past a shrunk list and Enter selects
27
+ // its (now out-of-range, undefined) item, swallowing the key without inserting a mention.
28
+ const [prevItems, setPrevItems] = useState(items)
29
+ if (items !== prevItems) {
30
+ setPrevItems(items)
31
+ setActiveIndex(0)
32
+ }
33
+
34
+ const select = (member: MentionMember | undefined) => {
35
+ if (member) command({ id: member.id, label: member.name })
36
+ }
37
+
38
+ useImperativeHandle(ref, () => ({
39
+ onKeyDown: ({ event }) => {
40
+ if (items.length === 0) return false
41
+ if (event.key === 'ArrowUp') {
42
+ setActiveIndex((i) => (i + items.length - 1) % items.length)
43
+ return true
44
+ }
45
+ if (event.key === 'ArrowDown') {
46
+ setActiveIndex((i) => (i + 1) % items.length)
47
+ return true
48
+ }
49
+ // Enter and Tab both complete the highlighted member (falling back to the top match so a
50
+ // stale index never selects `undefined`). The empty-roster case is handled a level up, in
51
+ // the suggestion's onKeyDown, which cancels the picker.
52
+ if (event.key === 'Enter' || event.key === 'Tab') {
53
+ select(items[activeIndex] ?? items[0])
54
+ return true
55
+ }
56
+ return false
57
+ },
58
+ }))
59
+
60
+ return (
61
+ <MentionList
62
+ members={items}
63
+ activeIndex={activeIndex}
64
+ onSelect={select}
65
+ renderMember={renderMember}
66
+ />
67
+ )
68
+ })
69
+
70
+ const MAX_SUGGESTIONS = 8
71
+
72
+ /** Members whose name contains the query (case-insensitive), capped to the popup's length.
73
+ * @public */
74
+ export function filterMentionMembers(members: MentionMember[], query: string): MentionMember[] {
75
+ const q = query.toLowerCase()
76
+ return members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, MAX_SUGGESTIONS)
77
+ }
78
+
79
+ // A reactive flag for whether the @-picker is showing. Deliberately NOT tldraw's open-menu registry:
80
+ // registering there mounts MenuClickCapture, which covers the canvas with a click-capture overlay to
81
+ // make it inert while a menu is open. But the picker is an inline autocomplete, not a modal — the
82
+ // canvas must stay pannable/zoomable beneath it — so we track "open" ourselves instead.
83
+ const mentionPickerOpen = atom('isMentionPickerOpen', false)
84
+
85
+ /**
86
+ * Whether the @-mention picker is currently showing. Host dismissal (Escape, outside-click) checks
87
+ * this so it can defer to the picker instead of tearing down the composer or thread beneath it.
88
+ */
89
+ export function isMentionPickerOpen(): boolean {
90
+ return mentionPickerOpen.get()
91
+ }
92
+
93
+ /** @public */
94
+ export interface MentionSuggestionOptions {
95
+ /** Override a member row's content in the picker. Defaults to avatar + name (+ secondary). */
96
+ renderMember?(member: MentionMember): ReactNode
97
+ /**
98
+ * The tldraw editor the composer lives in. When provided, the popup re-anchors reactively as the
99
+ * canvas camera moves (the composer rides it) instead of polling every frame. Omit off-canvas.
100
+ */
101
+ editor?: TldrawEditor | null
102
+ }
103
+
104
+ /**
105
+ * Build the TipTap `suggestion` config for the comment \@-picker. `getSuggestions(query)` is the
106
+ * host's resolver — it returns the members matching the query (sync or async); the SDK owns neither
107
+ * the roster nor the filtering. The plugin runs outside React, so `render` mounts `MentionPopup` via
108
+ * a `ReactRenderer`, forwards navigation keys through the popup's imperative handle, and lets it call
109
+ * `command` to insert.
110
+ * @public
111
+ */
112
+ export function createMentionSuggestion(
113
+ getSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>,
114
+ options: MentionSuggestionOptions = {}
115
+ ): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'> {
116
+ return {
117
+ char: '@',
118
+ items: ({ query }) => getSuggestions(query),
119
+ render: () => {
120
+ let renderer: ReactRenderer<MentionPopupHandle, MentionPopupProps> | null = null
121
+ let container: HTMLElement | null = null
122
+ let editorEl: HTMLElement | null = null
123
+ let canvasEl: Element | null = null
124
+ let stopCameraReaction: (() => void) | null = null
125
+ // The composer field's top-left in page space, plus the popup's screen width. Captured on a
126
+ // fresh read so camera moves can re-derive the popup's screen position from the page anchor
127
+ // (see reposition) rather than the field's DOM rect.
128
+ let anchorPage: { x: number; y: number } | null = null
129
+ let popupWidth = 0
130
+
131
+ const applyScreen = (left: number, top: number, width: number) => {
132
+ if (!container) return
133
+ container.style.left = `${left}px`
134
+ container.style.top = `${top + 4}px`
135
+ container.style.width = `${width}px`
136
+ }
137
+
138
+ // Fresh placement: read the field's real screen rect, position the popup flush under it (not
139
+ // the caret, matching its width), and remember the field's page-space anchor for reposition.
140
+ const place = () => {
141
+ if (!container || !editorEl || container.style.display === 'none') return
142
+ const field = editorEl.closest('.cmt-composer__field') ?? editorEl
143
+ const rect = field.getBoundingClientRect()
144
+ popupWidth = rect.width
145
+ anchorPage = options.editor?.screenToPage({ x: rect.left, y: rect.bottom }) ?? null
146
+ applyScreen(rect.left, rect.bottom, rect.width)
147
+ }
148
+
149
+ // Re-derive the popup's screen position from the remembered page anchor — pure camera math,
150
+ // always current. Reading the field's DOM rect here instead would lag by a frame: the canvas
151
+ // composer re-positions itself on a React commit (a `useValue(pageToViewport(...))`), which
152
+ // lands after a camera reaction runs, so the rect is still last frame's during a pan.
153
+ const reposition = () => {
154
+ if (!anchorPage || !options.editor) return
155
+ const s = options.editor.pageToScreen(anchorPage)
156
+ applyScreen(s.x, s.y, popupWidth)
157
+ }
158
+
159
+ // The popup is `position: fixed`, but its anchor — a canvas composer — rides the camera:
160
+ // panning or zooming moves the composer with no scroll/resize event to hook. Re-anchor when
161
+ // the camera actually changes (via a tldraw reaction) rather than polling every frame, plus
162
+ // on window scroll/resize for the off-canvas case (which re-read the field directly).
163
+ const startFollowing = () => {
164
+ window.addEventListener('scroll', place, true)
165
+ window.addEventListener('resize', place)
166
+ if (options.editor) {
167
+ stopCameraReaction = react('anchor mention popup to camera', () => {
168
+ options.editor!.getCamera() // track the camera so this re-runs as it moves
169
+ reposition()
170
+ })
171
+ }
172
+ }
173
+ const stopFollowing = () => {
174
+ window.removeEventListener('scroll', place, true)
175
+ window.removeEventListener('resize', place)
176
+ stopCameraReaction?.()
177
+ stopCameraReaction = null
178
+ anchorPage = null
179
+ }
180
+
181
+ // Dismiss the roster — on Escape, or when the composer loses focus — by hiding it and
182
+ // clearing the open flag, so isMentionPickerOpen() stays accurate and the thread's own
183
+ // Escape/outside-click dismissal can take over. The TipTap suggestion stays active, so typing
184
+ // re-shows the roster via onUpdate.
185
+ const hide = () => {
186
+ if (container) container.style.display = 'none'
187
+ mentionPickerOpen.set(false)
188
+ }
189
+
190
+ // Wheel/panning over the popup drives the canvas beneath it, so scrolling to pan or zoom
191
+ // isn't swallowed by the roster — the same passthrough the rest of the comments UI gets from
192
+ // `usePassThroughWheelEvents`. The list still scrolls itself when the roster overflows (we
193
+ // only redispatch when it can't). Done imperatively because the popup lives outside React.
194
+ const onWheel = (e: WheelEvent) => {
195
+ if ((e as any).isSpecialRedispatchedEvent || !canvasEl) return
196
+ const list = container?.querySelector('.cmt-mention-list')
197
+ if (list && list.scrollHeight > list.clientHeight) return
198
+ e.preventDefault()
199
+ const redispatched = new WheelEvent('wheel', e)
200
+ ;(redispatched as any).isSpecialRedispatchedEvent = true
201
+ canvasEl.dispatchEvent(redispatched)
202
+ }
203
+
204
+ return {
205
+ onStart: (props) => {
206
+ renderer = new ReactRenderer(MentionPopup, {
207
+ props: {
208
+ items: props.items,
209
+ command: props.command,
210
+ renderMember: options.renderMember,
211
+ },
212
+ editor: props.editor,
213
+ })
214
+ editorEl = props.editor.view.dom as HTMLElement
215
+ // The TipTap suggestion has no blur handling, so without this the picker would stay
216
+ // "open" (and the thread would defer its Escape to it) after focus moved away from the
217
+ // composer — leaving Escape a no-op and the roster stuck on screen.
218
+ editorEl.addEventListener('blur', hide)
219
+ container = document.createElement('div')
220
+ container.className = 'cmt-mention-popup'
221
+ container.appendChild(renderer.element)
222
+ // Mount inside the tldraw container so the popup inherits the theme variables
223
+ // (--tl-color-*); portaling to document.body would strip them and lose the panel.
224
+ const themed = editorEl.closest('.tl-container')
225
+ ;(themed ?? document.body).appendChild(container)
226
+ canvasEl = themed?.querySelector('.tl-canvas') ?? null
227
+ container.addEventListener('wheel', onWheel, { passive: false })
228
+ place()
229
+ startFollowing()
230
+ mentionPickerOpen.set(true)
231
+ },
232
+ onUpdate: (props) => {
233
+ if (renderer)
234
+ renderer.updateProps({
235
+ items: props.items,
236
+ command: props.command,
237
+ renderMember: options.renderMember,
238
+ })
239
+ // Typing after an Escape re-shows the roster.
240
+ if (container) container.style.display = ''
241
+ mentionPickerOpen.set(true)
242
+ place()
243
+ },
244
+ onKeyDown: (props) => {
245
+ // Once the roster is hidden (a prior Escape), the TipTap suggestion is still active but
246
+ // this handler goes inert — every key passes through to the composer/thread (so a second
247
+ // Escape closes them, and Arrow/Enter don't select from an invisible list). Typing
248
+ // re-shows the roster via onUpdate.
249
+ if (!isMentionPickerOpen()) return false
250
+ if (props.event.key === 'Escape') {
251
+ // Dismiss only the roster: hide it and stop the key so the composer/thread beneath
252
+ // doesn't also treat Escape as "abandon".
253
+ hide()
254
+ props.event.stopPropagation()
255
+ return true
256
+ }
257
+ if (props.event.key === 'Enter' || props.event.key === 'Tab') {
258
+ // Complete the highlighted member if there is one to complete; if the roster is empty
259
+ // there's nothing to pick, so cancel the picker and swallow the key — the composer
260
+ // beneath neither submits (Enter) nor moves focus / indents (Tab).
261
+ const completed = renderer?.ref?.onKeyDown(props) ?? false
262
+ if (!completed) {
263
+ hide()
264
+ props.event.stopPropagation()
265
+ }
266
+ return true
267
+ }
268
+ if (renderer && renderer.ref) return renderer.ref.onKeyDown(props)
269
+ return false
270
+ },
271
+ onExit: () => {
272
+ stopFollowing()
273
+ if (editorEl) editorEl.removeEventListener('blur', hide)
274
+ if (container) container.removeEventListener('wheel', onWheel)
275
+ if (container) container.remove()
276
+ if (renderer) renderer.destroy()
277
+ renderer = null
278
+ container = null
279
+ canvasEl = null
280
+ mentionPickerOpen.set(false)
281
+ },
282
+ }
283
+ },
284
+ }
285
+ }
@@ -0,0 +1,9 @@
1
+ /** @public */
2
+ export interface MentionProps {
3
+ name: string
4
+ }
5
+
6
+ /** An \@-mention pill for referencing a person in a comment. @public @react */
7
+ export function Mention({ name }: MentionProps) {
8
+ return <span className="cmt-mention">@{name}</span>
9
+ }
@@ -0,0 +1,16 @@
1
+ /** @public */
2
+ export interface ReactionProps {
3
+ emoji: string
4
+ count: number
5
+ active: boolean
6
+ }
7
+
8
+ /** A single emoji reaction pill with a count, highlighted when the user reacted. @public @react */
9
+ export function Reaction({ emoji, count, active }: ReactionProps) {
10
+ return (
11
+ <button className={active ? 'cmt-reaction cmt-reaction--active' : 'cmt-reaction'} type="button">
12
+ <span className="cmt-reaction__emoji">{emoji}</span>
13
+ <span className="cmt-reaction__count">{count}</span>
14
+ </button>
15
+ )
16
+ }
@@ -0,0 +1,15 @@
1
+ import { Reaction } from './reaction'
2
+
3
+ /** The row of reactions under a comment, plus an add-reaction button. @public @react */
4
+ export function Reactions() {
5
+ return (
6
+ <div className="cmt-reactions">
7
+ <Reaction emoji="👍" count={3} active={true} />
8
+ <Reaction emoji="🎉" count={1} active={false} />
9
+ <Reaction emoji="👀" count={2} active={false} />
10
+ <button className="cmt-reaction cmt-reaction--add" type="button" aria-label="Add reaction">
11
+
12
+ </button>
13
+ </div>
14
+ )
15
+ }
@@ -0,0 +1,72 @@
1
+ import { Fragment, ReactNode } from 'react'
2
+
3
+ /**
4
+ * Minimal markdown for comment bodies: paragraphs, line breaks, bullet lists, and
5
+ * inline **bold**, *italic*, `code`, and [links](url). Not a full CommonMark parser —
6
+ * just the markdown people actually write in comments. Renders React elements only —
7
+ * never raw HTML — so there's no HTML-injection surface.
8
+ * @public
9
+ */
10
+ export function renderMarkdown(text: string): ReactNode {
11
+ return text
12
+ .trim()
13
+ .split(/\n{2,}/)
14
+ .map((block, i) => {
15
+ const lines = block.split('\n')
16
+ if (lines.every((line) => /^\s*[-*]\s+/.test(line))) {
17
+ return (
18
+ <ul key={i} className="md-list">
19
+ {lines.map((line, j) => (
20
+ <li key={j}>{renderInline(line.replace(/^\s*[-*]\s+/, ''))}</li>
21
+ ))}
22
+ </ul>
23
+ )
24
+ }
25
+ return (
26
+ <p key={i} className="md-p">
27
+ {lines.map((line, j) => (
28
+ <Fragment key={j}>
29
+ {j > 0 && <br />}
30
+ {renderInline(line)}
31
+ </Fragment>
32
+ ))}
33
+ </p>
34
+ )
35
+ })
36
+ }
37
+
38
+ const INLINE = /(\*\*[^*]+\*\*|\*[^*]+\*|_[^_]+_|`[^`]+`|\[[^\]]+\]\([^)]+\))/g
39
+
40
+ function renderInline(text: string): ReactNode {
41
+ const out: ReactNode[] = []
42
+ let last = 0
43
+ let key = 0
44
+ for (const match of text.matchAll(INLINE)) {
45
+ const index = match.index ?? 0
46
+ if (index > last) out.push(text.slice(last, index))
47
+ out.push(renderToken(match[0], key++))
48
+ last = index + match[0].length
49
+ }
50
+ if (last < text.length) out.push(text.slice(last))
51
+ return out
52
+ }
53
+
54
+ function renderToken(token: string, key: number): ReactNode {
55
+ if (token.startsWith('**')) return <strong key={key}>{token.slice(2, -2)}</strong>
56
+ if (token.startsWith('`'))
57
+ return (
58
+ <code key={key} className="md-code">
59
+ {token.slice(1, -1)}
60
+ </code>
61
+ )
62
+ if (token.startsWith('[')) {
63
+ const link = /\[([^\]]+)\]\(([^)]+)\)/.exec(token)
64
+ if (!link) return token
65
+ return (
66
+ <a key={key} href={link[2]} target="_blank" rel="noreferrer">
67
+ {link[1]}
68
+ </a>
69
+ )
70
+ }
71
+ return <em key={key}>{token.slice(1, -1)}</em>
72
+ }
@@ -0,0 +1,15 @@
1
+ /** @public */
2
+ export interface SendButtonProps {
3
+ label: string
4
+ disabled?: boolean
5
+ onClick?(): void
6
+ }
7
+
8
+ /** The button that posts a comment. @public @react */
9
+ export function SendButton({ label, disabled, onClick }: SendButtonProps) {
10
+ return (
11
+ <button className="cmt-send" type="button" disabled={disabled} onClick={onClick}>
12
+ {label}
13
+ </button>
14
+ )
15
+ }