@tldraw/mentions 0.0.0-bootstrap
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist-cjs/avatar.js +51 -0
- package/dist-cjs/avatar.js.map +7 -0
- package/dist-cjs/comment-author.js +17 -0
- package/dist-cjs/comment-author.js.map +7 -0
- package/dist-cjs/index.d.ts +131 -0
- package/dist-cjs/index.js +41 -0
- package/dist-cjs/index.js.map +7 -0
- package/dist-cjs/mention-extension.js +42 -0
- package/dist-cjs/mention-extension.js.map +7 -0
- package/dist-cjs/mention-list.js +70 -0
- package/dist-cjs/mention-list.js.map +7 -0
- package/dist-cjs/mention-suggestion.js +206 -0
- package/dist-cjs/mention-suggestion.js.map +7 -0
- package/dist-cjs/mention.js +31 -0
- package/dist-cjs/mention.js.map +7 -0
- package/dist-esm/avatar.mjs +31 -0
- package/dist-esm/avatar.mjs.map +7 -0
- package/dist-esm/comment-author.mjs +1 -0
- package/dist-esm/comment-author.mjs.map +7 -0
- package/dist-esm/index.d.mts +131 -0
- package/dist-esm/index.mjs +25 -0
- package/dist-esm/index.mjs.map +7 -0
- package/dist-esm/mention-extension.mjs +22 -0
- package/dist-esm/mention-extension.mjs.map +7 -0
- package/dist-esm/mention-list.mjs +50 -0
- package/dist-esm/mention-list.mjs.map +7 -0
- package/dist-esm/mention-suggestion.mjs +186 -0
- package/dist-esm/mention-suggestion.mjs.map +7 -0
- package/dist-esm/mention.mjs +11 -0
- package/dist-esm/mention.mjs.map +7 -0
- package/mentions.css +108 -0
- package/package.json +68 -0
- package/src/avatar.tsx +35 -0
- package/src/comment-author.ts +15 -0
- package/src/index.ts +25 -0
- package/src/mention-extension.test.ts +41 -0
- package/src/mention-extension.ts +51 -0
- package/src/mention-list.tsx +103 -0
- package/src/mention-suggestion.test.ts +18 -0
- package/src/mention-suggestion.tsx +286 -0
- package/src/mention.tsx +9 -0
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
* @public
|
|
89
|
+
*/
|
|
90
|
+
export function isMentionPickerOpen(): boolean {
|
|
91
|
+
return mentionPickerOpen.get()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @public */
|
|
95
|
+
export interface MentionSuggestionOptions {
|
|
96
|
+
/** Override a member row's content in the picker. Defaults to avatar + name (+ secondary). */
|
|
97
|
+
renderMember?(member: MentionMember): ReactNode
|
|
98
|
+
/**
|
|
99
|
+
* The tldraw editor the composer lives in. When provided, the popup re-anchors reactively as the
|
|
100
|
+
* canvas camera moves (the composer rides it) instead of polling every frame. Omit off-canvas.
|
|
101
|
+
*/
|
|
102
|
+
editor?: TldrawEditor | null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the TipTap `suggestion` config for the \@-picker. `getSuggestions(query)` is the host's
|
|
107
|
+
* resolver — it returns the members matching the query (sync or async); the SDK owns neither the
|
|
108
|
+
* roster nor the filtering. The plugin runs outside React, so `render` mounts `MentionPopup` via a
|
|
109
|
+
* `ReactRenderer`, forwards navigation keys through the popup's imperative handle, and lets it call
|
|
110
|
+
* `command` to insert.
|
|
111
|
+
* @public
|
|
112
|
+
*/
|
|
113
|
+
export function createMentionSuggestion(
|
|
114
|
+
getSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>,
|
|
115
|
+
options: MentionSuggestionOptions = {}
|
|
116
|
+
): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'> {
|
|
117
|
+
return {
|
|
118
|
+
char: '@',
|
|
119
|
+
items: ({ query }) => getSuggestions(query),
|
|
120
|
+
render: () => {
|
|
121
|
+
let renderer: ReactRenderer<MentionPopupHandle, MentionPopupProps> | null = null
|
|
122
|
+
let container: HTMLElement | null = null
|
|
123
|
+
let editorEl: HTMLElement | null = null
|
|
124
|
+
let canvasEl: Element | null = null
|
|
125
|
+
let stopCameraReaction: (() => void) | null = null
|
|
126
|
+
// The composer field's top-left in page space, plus the popup's screen width. Captured on a
|
|
127
|
+
// fresh read so camera moves can re-derive the popup's screen position from the page anchor
|
|
128
|
+
// (see reposition) rather than the field's DOM rect.
|
|
129
|
+
let anchorPage: { x: number; y: number } | null = null
|
|
130
|
+
let popupWidth = 0
|
|
131
|
+
|
|
132
|
+
const applyScreen = (left: number, top: number, width: number) => {
|
|
133
|
+
if (!container) return
|
|
134
|
+
container.style.left = `${left}px`
|
|
135
|
+
container.style.top = `${top + 4}px`
|
|
136
|
+
container.style.width = `${width}px`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Fresh placement: read the field's real screen rect, position the popup flush under it (not
|
|
140
|
+
// the caret, matching its width), and remember the field's page-space anchor for reposition.
|
|
141
|
+
const place = () => {
|
|
142
|
+
if (!container || !editorEl || container.style.display === 'none') return
|
|
143
|
+
const field = editorEl.closest('.tlui-cmt-composer__field') ?? editorEl
|
|
144
|
+
const rect = field.getBoundingClientRect()
|
|
145
|
+
popupWidth = rect.width
|
|
146
|
+
anchorPage = options.editor?.screenToPage({ x: rect.left, y: rect.bottom }) ?? null
|
|
147
|
+
applyScreen(rect.left, rect.bottom, rect.width)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Re-derive the popup's screen position from the remembered page anchor — pure camera math,
|
|
151
|
+
// always current. Reading the field's DOM rect here instead would lag by a frame: the canvas
|
|
152
|
+
// composer re-positions itself on a React commit (a `useValue(pageToViewport(...))`), which
|
|
153
|
+
// lands after a camera reaction runs, so the rect is still last frame's during a pan.
|
|
154
|
+
const reposition = () => {
|
|
155
|
+
if (!anchorPage || !options.editor) return
|
|
156
|
+
const s = options.editor.pageToScreen(anchorPage)
|
|
157
|
+
applyScreen(s.x, s.y, popupWidth)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The popup is `position: fixed`, but its anchor — a canvas composer — rides the camera:
|
|
161
|
+
// panning or zooming moves the composer with no scroll/resize event to hook. Re-anchor when
|
|
162
|
+
// the camera actually changes (via a tldraw reaction) rather than polling every frame, plus
|
|
163
|
+
// on window scroll/resize for the off-canvas case (which re-read the field directly).
|
|
164
|
+
const startFollowing = () => {
|
|
165
|
+
window.addEventListener('scroll', place, true)
|
|
166
|
+
window.addEventListener('resize', place)
|
|
167
|
+
if (options.editor) {
|
|
168
|
+
stopCameraReaction = react('anchor mention popup to camera', () => {
|
|
169
|
+
options.editor!.getCamera() // track the camera so this re-runs as it moves
|
|
170
|
+
reposition()
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const stopFollowing = () => {
|
|
175
|
+
window.removeEventListener('scroll', place, true)
|
|
176
|
+
window.removeEventListener('resize', place)
|
|
177
|
+
stopCameraReaction?.()
|
|
178
|
+
stopCameraReaction = null
|
|
179
|
+
anchorPage = null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Dismiss the roster — on Escape, or when the composer loses focus — by hiding it and
|
|
183
|
+
// clearing the open flag, so isMentionPickerOpen() stays accurate and the thread's own
|
|
184
|
+
// Escape/outside-click dismissal can take over. The TipTap suggestion stays active, so typing
|
|
185
|
+
// re-shows the roster via onUpdate.
|
|
186
|
+
const hide = () => {
|
|
187
|
+
if (container) container.style.display = 'none'
|
|
188
|
+
mentionPickerOpen.set(false)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Wheel/panning over the popup drives the canvas beneath it, so scrolling to pan or zoom
|
|
192
|
+
// isn't swallowed by the roster — the same passthrough the rest of the comments UI gets from
|
|
193
|
+
// `usePassThroughWheelEvents`. The list still scrolls itself when the roster overflows (we
|
|
194
|
+
// only redispatch when it can't). Done imperatively because the popup lives outside React.
|
|
195
|
+
const onWheel = (e: WheelEvent) => {
|
|
196
|
+
if ((e as any).isSpecialRedispatchedEvent || !canvasEl) return
|
|
197
|
+
const list = container?.querySelector('.tlui-cmt-mention-list')
|
|
198
|
+
if (list && list.scrollHeight > list.clientHeight) return
|
|
199
|
+
e.preventDefault()
|
|
200
|
+
const redispatched = new WheelEvent('wheel', e)
|
|
201
|
+
;(redispatched as any).isSpecialRedispatchedEvent = true
|
|
202
|
+
canvasEl.dispatchEvent(redispatched)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
onStart: (props) => {
|
|
207
|
+
renderer = new ReactRenderer(MentionPopup, {
|
|
208
|
+
props: {
|
|
209
|
+
items: props.items,
|
|
210
|
+
command: props.command,
|
|
211
|
+
renderMember: options.renderMember,
|
|
212
|
+
},
|
|
213
|
+
editor: props.editor,
|
|
214
|
+
})
|
|
215
|
+
editorEl = props.editor.view.dom as HTMLElement
|
|
216
|
+
// The TipTap suggestion has no blur handling, so without this the picker would stay
|
|
217
|
+
// "open" (and the thread would defer its Escape to it) after focus moved away from the
|
|
218
|
+
// composer — leaving Escape a no-op and the roster stuck on screen.
|
|
219
|
+
editorEl.addEventListener('blur', hide)
|
|
220
|
+
container = document.createElement('div')
|
|
221
|
+
container.className = 'tlui-cmt-mention-popup'
|
|
222
|
+
container.appendChild(renderer.element)
|
|
223
|
+
// Mount inside the tldraw container so the popup inherits the theme variables
|
|
224
|
+
// (--tl-color-*); portaling to document.body would strip them and lose the panel.
|
|
225
|
+
const themed = editorEl.closest('.tl-container')
|
|
226
|
+
;(themed ?? document.body).appendChild(container)
|
|
227
|
+
canvasEl = themed?.querySelector('.tl-canvas') ?? null
|
|
228
|
+
container.addEventListener('wheel', onWheel, { passive: false })
|
|
229
|
+
place()
|
|
230
|
+
startFollowing()
|
|
231
|
+
mentionPickerOpen.set(true)
|
|
232
|
+
},
|
|
233
|
+
onUpdate: (props) => {
|
|
234
|
+
if (renderer)
|
|
235
|
+
renderer.updateProps({
|
|
236
|
+
items: props.items,
|
|
237
|
+
command: props.command,
|
|
238
|
+
renderMember: options.renderMember,
|
|
239
|
+
})
|
|
240
|
+
// Typing after an Escape re-shows the roster.
|
|
241
|
+
if (container) container.style.display = ''
|
|
242
|
+
mentionPickerOpen.set(true)
|
|
243
|
+
place()
|
|
244
|
+
},
|
|
245
|
+
onKeyDown: (props) => {
|
|
246
|
+
// Once the roster is hidden (a prior Escape), the TipTap suggestion is still active but
|
|
247
|
+
// this handler goes inert — every key passes through to the composer/thread (so a second
|
|
248
|
+
// Escape closes them, and Arrow/Enter don't select from an invisible list). Typing
|
|
249
|
+
// re-shows the roster via onUpdate.
|
|
250
|
+
if (!isMentionPickerOpen()) return false
|
|
251
|
+
if (props.event.key === 'Escape') {
|
|
252
|
+
// Dismiss only the roster: hide it and stop the key so the composer/thread beneath
|
|
253
|
+
// doesn't also treat Escape as "abandon".
|
|
254
|
+
hide()
|
|
255
|
+
props.event.stopPropagation()
|
|
256
|
+
return true
|
|
257
|
+
}
|
|
258
|
+
if (props.event.key === 'Enter' || props.event.key === 'Tab') {
|
|
259
|
+
// Complete the highlighted member if there is one to complete; if the roster is empty
|
|
260
|
+
// there's nothing to pick, so cancel the picker and swallow the key — the composer
|
|
261
|
+
// beneath neither submits (Enter) nor moves focus / indents (Tab).
|
|
262
|
+
const completed = renderer?.ref?.onKeyDown(props) ?? false
|
|
263
|
+
if (!completed) {
|
|
264
|
+
hide()
|
|
265
|
+
props.event.stopPropagation()
|
|
266
|
+
}
|
|
267
|
+
return true
|
|
268
|
+
}
|
|
269
|
+
if (renderer && renderer.ref) return renderer.ref.onKeyDown(props)
|
|
270
|
+
return false
|
|
271
|
+
},
|
|
272
|
+
onExit: () => {
|
|
273
|
+
stopFollowing()
|
|
274
|
+
if (editorEl) editorEl.removeEventListener('blur', hide)
|
|
275
|
+
if (container) container.removeEventListener('wheel', onWheel)
|
|
276
|
+
if (container) container.remove()
|
|
277
|
+
if (renderer) renderer.destroy()
|
|
278
|
+
renderer = null
|
|
279
|
+
container = null
|
|
280
|
+
canvasEl = null
|
|
281
|
+
mentionPickerOpen.set(false)
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/mention.tsx
ADDED