@estiva-app/ui 0.51.0 → 0.53.0
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/dist/ComposerTriggers.d.ts +191 -0
- package/dist/ComposerTriggers.d.ts.map +1 -0
- package/dist/SuggestionMenu.d.ts.map +1 -1
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +341 -13
- package/dist/editor.js.map +4 -4
- package/dist/index.js +7 -2
- package/dist/index.js.map +3 -3
- package/dist/suggestionPopup.d.ts +9 -0
- package/dist/suggestionPopup.d.ts.map +1 -1
- package/package.json +6 -1
- package/registry.json +326 -3
- package/src/ComposerTriggers.mdx +115 -0
- package/src/ComposerTriggers.stories.tsx +99 -0
- package/src/ComposerTriggers.test.tsx +268 -0
- package/src/ComposerTriggers.tsx +547 -0
- package/src/SuggestionMenu.tsx +13 -3
- package/src/editor.ts +29 -0
- package/src/registry/registry.test.ts +8 -7
- package/src/suggestionPopup.tsx +29 -1
- package/stories/Choosing.mdx +1 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Extension, type Editor } from '@tiptap/core'
|
|
3
|
+
import { PluginKey } from '@tiptap/pm/state'
|
|
4
|
+
import Mention, { type MentionOptions } from '@tiptap/extension-mention'
|
|
5
|
+
import Suggestion from '@tiptap/suggestion'
|
|
6
|
+
import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react'
|
|
7
|
+
import { Avatar } from './Avatar'
|
|
8
|
+
import { EnterHint } from './Menu'
|
|
9
|
+
import { inlineChipClassName } from './InlineChip'
|
|
10
|
+
import type { SuggestionMenuRow } from './SuggestionMenu'
|
|
11
|
+
import { suggestionPopup, type SuggestionPopupOptions } from './suggestionPopup'
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The four composer triggers every Estiva app shares — `@`, `!@`, `[` and `/`
|
|
15
|
+
* (CON-26, Miky 2026-10-02). Peek built them first and they move here unchanged
|
|
16
|
+
* (CON-27); an app passes in only its own data: its people, the things `[` can
|
|
17
|
+
* reference, and the `/` rows it can perform beside the shared Format section.
|
|
18
|
+
*
|
|
19
|
+
* **What a pick writes is not here.** The text a chip becomes in the body and
|
|
20
|
+
* the tags it earns are `@estiva-app/conversation`'s (`mentionText`,
|
|
21
|
+
* `messageReference`, `urgentTagsFor`), so a client without React writes the
|
|
22
|
+
* same bytes. A chip here carries what that needs: a person's `pubkey`, a
|
|
23
|
+
* reference's `uri`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ─── @ and !@ ───
|
|
27
|
+
|
|
28
|
+
/** A person `@` and `!@` offer. */
|
|
29
|
+
export interface MentionPerson {
|
|
30
|
+
/** The app's own key for the person — the row's key and the chip's `id`. */
|
|
31
|
+
id: string
|
|
32
|
+
name: string
|
|
33
|
+
/** The second line of the row: a role, a team. */
|
|
34
|
+
description?: string
|
|
35
|
+
/** Their Nostr key, when they have one — what the body is written with (SPEC §13.1). */
|
|
36
|
+
pubkey?: string | null
|
|
37
|
+
/** The face at the row's start. Default: the package `Avatar` for the name. */
|
|
38
|
+
avatar?: ReactNode
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How many people the list shows. */
|
|
42
|
+
export const PEOPLE_LIMIT = 6
|
|
43
|
+
|
|
44
|
+
/** The people whose name holds the query, the first {@link PEOPLE_LIMIT} of them. */
|
|
45
|
+
export function filterPeople(people: readonly MentionPerson[], query: string): MentionPerson[] {
|
|
46
|
+
const q = query.toLowerCase()
|
|
47
|
+
return people.filter((person) => q === '' || person.name.toLowerCase().includes(q)).slice(0, PEOPLE_LIMIT)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The people list `@` and `!@` open: the face, the name, the second line, and
|
|
52
|
+
* the Enter hint while highlighted — `MenuItem` at its tall size.
|
|
53
|
+
*/
|
|
54
|
+
export function peopleMenu(urgent: boolean): SuggestionPopupOptions<MentionPerson> {
|
|
55
|
+
const section = urgent ? 'Urgent mention' : 'People'
|
|
56
|
+
return {
|
|
57
|
+
ariaLabel: section,
|
|
58
|
+
// The people and the reference lists are the same box: 658px, capped at 360.
|
|
59
|
+
width: 'w-[658px]',
|
|
60
|
+
maxHeight: 'max-h-[360px]',
|
|
61
|
+
sections: (people) => [{ label: section, items: people, className: 'px-3 py-1' }],
|
|
62
|
+
itemKey: (person) => person.id,
|
|
63
|
+
row: (person) => ({
|
|
64
|
+
label: person.name,
|
|
65
|
+
description: person.description,
|
|
66
|
+
leading: person.avatar ?? <Avatar size={32} alt={person.name} />,
|
|
67
|
+
hint: <EnterHint />,
|
|
68
|
+
size: 'tall',
|
|
69
|
+
}),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PersonMentionOptions {
|
|
74
|
+
/** Everyone the list can offer, asked each time the query changes. */
|
|
75
|
+
people: () => readonly MentionPerson[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Doc = { descendants: (cb: (node: { type: { name: string }; attrs: Record<string, unknown> }) => boolean | void) => void }
|
|
79
|
+
|
|
80
|
+
function optionsOf<T>(editor: Editor, name: string): T | undefined {
|
|
81
|
+
return editor.extensionManager.extensions.find((one) => one.name === name)?.options as T | undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `@`'s list and pick, or `!@`'s, for the node `name` — the extension's own, so a renamed one still finds its people. */
|
|
85
|
+
function personSuggestion(urgent: boolean, name: string) {
|
|
86
|
+
return {
|
|
87
|
+
...(urgent ? { char: '!@' } : {}),
|
|
88
|
+
items: ({ query, editor }: { query: string; editor: Editor }) => filterPeople(optionsOf<PersonMentionOptions>(editor, name)?.people() ?? [], query),
|
|
89
|
+
render: suggestionPopup(peopleMenu(urgent)),
|
|
90
|
+
command: ({ editor, range, props: person }: { editor: Editor; range: { from: number; to: number }; props: MentionPerson }) => {
|
|
91
|
+
editor
|
|
92
|
+
.chain()
|
|
93
|
+
.focus()
|
|
94
|
+
.insertContentAt(range, [
|
|
95
|
+
{ type: name, attrs: { id: person.id, label: person.name, pubkey: person.pubkey ?? null } },
|
|
96
|
+
{ type: 'text', text: ' ' },
|
|
97
|
+
])
|
|
98
|
+
.run()
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
What a chip keeps through HTML — a copy and paste, inside an editor or
|
|
105
|
+
between two. The chip's own `renderHTML` writes each as a `data-*` attribute
|
|
106
|
+
and these read them back. Until CON-27 none was written, so a pasted chip
|
|
107
|
+
came back with every value null and was sent as `@null`. What is read is
|
|
108
|
+
checked: a crafted `data-pubkey` that is not a key, or a `data-uri` that is
|
|
109
|
+
not a `nostr:` reference, is dropped rather than written into the body.
|
|
110
|
+
*/
|
|
111
|
+
const HEX_KEY = /^[0-9a-f]{64}$/
|
|
112
|
+
const NOSTR_URI = /^nostr:[a-z0-9]+$/i
|
|
113
|
+
const fromData = (name: string, valid?: RegExp) => ({
|
|
114
|
+
parseHTML: (element: HTMLElement) => {
|
|
115
|
+
const value = element.getAttribute(`data-${name}`)
|
|
116
|
+
return value !== null && (!valid || valid.test(value)) ? value : null
|
|
117
|
+
},
|
|
118
|
+
// The node's own `renderHTML` writes it.
|
|
119
|
+
renderHTML: () => ({}),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const personAttributes = () => ({
|
|
123
|
+
id: { default: null, ...fromData('id') },
|
|
124
|
+
label: { default: null, ...fromData('label') },
|
|
125
|
+
/**
|
|
126
|
+
* The person's Nostr key, when they have one — SPEC §13.1. `id` is the app's
|
|
127
|
+
* own and means nothing to another app; this is what the body is written
|
|
128
|
+
* with, so a mention survives a rename.
|
|
129
|
+
*/
|
|
130
|
+
pubkey: { default: null, ...fromData('pubkey', HEX_KEY) },
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
/** The `data-*` a person chip is written with, so a paste reads it back. */
|
|
134
|
+
function personData(attrs: Record<string, unknown>): Record<string, string> {
|
|
135
|
+
return {
|
|
136
|
+
'data-id': String(attrs.id ?? ''),
|
|
137
|
+
'data-label': String(attrs.label ?? ''),
|
|
138
|
+
...(typeof attrs.pubkey === 'string' && attrs.pubkey ? { 'data-pubkey': attrs.pubkey } : {}),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `@` — a person, as a chip that writes their key (SPEC §13.1).
|
|
144
|
+
*
|
|
145
|
+
* PersonMention.configure({ people: () => directory })
|
|
146
|
+
*
|
|
147
|
+
* Its `pubkey` and `label` are what `mentionText` takes.
|
|
148
|
+
*/
|
|
149
|
+
export const PersonMention = Mention.extend<MentionOptions<MentionPerson, MentionPerson> & PersonMentionOptions>({
|
|
150
|
+
name: 'mention',
|
|
151
|
+
addOptions() {
|
|
152
|
+
return { ...this.parent!(), HTMLAttributes: {}, people: () => [], suggestion: personSuggestion(false, this.name) }
|
|
153
|
+
},
|
|
154
|
+
addAttributes: personAttributes,
|
|
155
|
+
renderHTML({ node }) {
|
|
156
|
+
// No node view, so this spec is what the editor draws: the one chip.
|
|
157
|
+
return ['span', { 'data-mention': 'true', ...personData(node.attrs), class: inlineChipClassName('person', 'cursor-default') }, `@${node.attrs.label}`]
|
|
158
|
+
},
|
|
159
|
+
parseHTML() {
|
|
160
|
+
return [{ tag: 'span[data-mention]' }]
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* `!@` — a person, urgently: the same chip in the urgent tone. Its text is an
|
|
166
|
+
* ordinary mention; the urgency travels as a tag (`urgentTagsFor`), so hand the
|
|
167
|
+
* write {@link urgentPubkeys}.
|
|
168
|
+
*/
|
|
169
|
+
export const UrgentPersonMention = Mention.extend<MentionOptions<MentionPerson, MentionPerson> & PersonMentionOptions>({
|
|
170
|
+
name: 'urgentMention',
|
|
171
|
+
addOptions() {
|
|
172
|
+
return { ...this.parent!(), HTMLAttributes: {}, people: () => [], suggestion: personSuggestion(true, this.name) }
|
|
173
|
+
},
|
|
174
|
+
addAttributes: personAttributes,
|
|
175
|
+
renderHTML({ node }) {
|
|
176
|
+
return [
|
|
177
|
+
'span',
|
|
178
|
+
{ 'data-urgent-mention': 'true', ...personData(node.attrs), class: inlineChipClassName('urgent', 'cursor-default') },
|
|
179
|
+
`@${node.attrs.label}`,
|
|
180
|
+
]
|
|
181
|
+
},
|
|
182
|
+
parseHTML() {
|
|
183
|
+
return [{ tag: 'span[data-urgent-mention]' }]
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The keys the document's urgent mentions name — what the write turns into
|
|
189
|
+
* `["urgent", <pubkey>]` tags. A chip with no key has no tag to carry; its text
|
|
190
|
+
* is the old `!@Name`.
|
|
191
|
+
*/
|
|
192
|
+
export function urgentPubkeys(editor: { state: { doc: Doc } }): string[] | undefined {
|
|
193
|
+
const urgent: string[] = []
|
|
194
|
+
editor.state.doc.descendants((node) => {
|
|
195
|
+
if (node.type.name === 'urgentMention' && typeof node.attrs.pubkey === 'string' && node.attrs.pubkey) urgent.push(node.attrs.pubkey)
|
|
196
|
+
})
|
|
197
|
+
return urgent.length > 0 ? urgent : undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── [ ───
|
|
201
|
+
|
|
202
|
+
/** Something `[` offers: a reference drawn as who said it and how it opens. */
|
|
203
|
+
export interface CaptionedItem {
|
|
204
|
+
/** The referenced thing's id — the row's key and the chip's `id`. */
|
|
205
|
+
id: string
|
|
206
|
+
/** Who, as the list shows them. */
|
|
207
|
+
label: string
|
|
208
|
+
/** The opening words, marks stripped: the caption the row and the chip wear. */
|
|
209
|
+
snippet: string
|
|
210
|
+
/** `nostr:…`, exactly as the body will carry it (`messageReference`). */
|
|
211
|
+
uri: string
|
|
212
|
+
/** What the query is matched against beside `label`. Default: `snippet`. */
|
|
213
|
+
search?: string
|
|
214
|
+
/** The row's second line, e.g. where the thing sits. The chip does not wear it. */
|
|
215
|
+
description?: string
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One labelled group of `[`'s rows, in the order the list draws them. */
|
|
219
|
+
export interface ReferenceSection {
|
|
220
|
+
label: string
|
|
221
|
+
items: readonly CaptionedItem[]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** How many references the list shows. */
|
|
225
|
+
export const REFERENCE_LIMIT = 6
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The references whose label or text holds the query — the last
|
|
229
|
+
* {@link REFERENCE_LIMIT}, in the order given: a thread is unbounded, and a
|
|
230
|
+
* list that grew with it would be a hundred rows of prose.
|
|
231
|
+
*/
|
|
232
|
+
export function filterReferences(items: readonly CaptionedItem[], query: string): CaptionedItem[] {
|
|
233
|
+
const q = query.toLowerCase()
|
|
234
|
+
const shown = q ? items.filter((item) => (item.search ?? item.snippet).toLowerCase().includes(q) || item.label.toLowerCase().includes(q)) : [...items]
|
|
235
|
+
return shown.slice(-REFERENCE_LIMIT)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const captionOf = (label: string, snippet: string) => (snippet ? `${label}: ${snippet}` : label)
|
|
239
|
+
|
|
240
|
+
export interface CaptionedReferenceOptions {
|
|
241
|
+
/** Drawn before the caption: a 14px icon. */
|
|
242
|
+
icon: ReactNode
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A reference mid-sentence, drawn as `label: snippet` and written as its `uri`
|
|
247
|
+
* verbatim (SPEC §13.1). The caption is the snapshot it was picked with — a
|
|
248
|
+
* caption, never parsed. Inserted by {@link ReferenceTrigger}, a paste or a
|
|
249
|
+
* draft; it opens no list of its own.
|
|
250
|
+
*/
|
|
251
|
+
export const CaptionedReference = Mention.extend<MentionOptions & CaptionedReferenceOptions>({
|
|
252
|
+
name: 'captionedReference',
|
|
253
|
+
group: 'inline',
|
|
254
|
+
inline: true,
|
|
255
|
+
atom: true,
|
|
256
|
+
addOptions() {
|
|
257
|
+
return { ...this.parent!(), HTMLAttributes: {}, icon: null, suggestion: { char: '\0', items: () => [] } }
|
|
258
|
+
},
|
|
259
|
+
addAttributes() {
|
|
260
|
+
return {
|
|
261
|
+
id: { default: null, ...fromData('id') },
|
|
262
|
+
label: { default: null, ...fromData('label') },
|
|
263
|
+
snippet: { default: '', parseHTML: (element: HTMLElement) => element.getAttribute('data-snippet') ?? '', renderHTML: () => ({}) },
|
|
264
|
+
uri: { default: null, ...fromData('uri', NOSTR_URI) },
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
renderHTML({ node }) {
|
|
268
|
+
return [
|
|
269
|
+
'span',
|
|
270
|
+
{ [`data-${this.name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`]: 'true', 'data-id': String(node.attrs.id ?? ''), 'data-label': String(node.attrs.label ?? ''), 'data-snippet': String(node.attrs.snippet ?? ''), ...(node.attrs.uri ? { 'data-uri': String(node.attrs.uri) } : {}), class: inlineChipClassName('neutral', 'cursor-default') },
|
|
271
|
+
captionOf(node.attrs.label, node.attrs.snippet),
|
|
272
|
+
]
|
|
273
|
+
},
|
|
274
|
+
parseHTML() {
|
|
275
|
+
return [{ tag: `span[data-${this.name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}]` }]
|
|
276
|
+
},
|
|
277
|
+
addNodeView() {
|
|
278
|
+
const icon = this.options.icon
|
|
279
|
+
return ReactNodeViewRenderer(
|
|
280
|
+
({ node }: ReactNodeViewProps) => (
|
|
281
|
+
<NodeViewWrapper as="span" className={inlineChipClassName('neutral', 'max-w-[24ch] cursor-default')}>
|
|
282
|
+
<span className="flex items-center justify-center w-4 h-4 shrink-0 text-text-secondary">{icon}</span>
|
|
283
|
+
<span className="truncate">{captionOf(node.attrs.label, node.attrs.snippet)}</span>
|
|
284
|
+
</NodeViewWrapper>
|
|
285
|
+
),
|
|
286
|
+
{ as: 'span' },
|
|
287
|
+
)
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
export interface ReferenceMenuOptions {
|
|
292
|
+
/** Names the list, e.g. what can be referenced. */
|
|
293
|
+
ariaLabel: string
|
|
294
|
+
/** The section's heading. */
|
|
295
|
+
sectionLabel: string
|
|
296
|
+
/** At each row's start: a 16px icon. */
|
|
297
|
+
icon: ReactNode
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A row as the list holds it: which section it is drawn under, when the list has several. */
|
|
301
|
+
type ListedItem = CaptionedItem & { section?: string }
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The list `[` opens: `label: snippet` rows, the same box as the people list.
|
|
305
|
+
* One section headed `sectionLabel`, or — when the rows say which section they
|
|
306
|
+
* are in — a heading for each, in the order the rows arrive.
|
|
307
|
+
*/
|
|
308
|
+
export function referenceMenu({ ariaLabel, sectionLabel, icon }: ReferenceMenuOptions): SuggestionPopupOptions<CaptionedItem> {
|
|
309
|
+
return {
|
|
310
|
+
ariaLabel,
|
|
311
|
+
width: 'w-[658px]',
|
|
312
|
+
maxHeight: 'max-h-[360px]',
|
|
313
|
+
sections: (items: ListedItem[]) => {
|
|
314
|
+
const groups: { label: string; items: CaptionedItem[]; className: string }[] = []
|
|
315
|
+
for (const item of items) {
|
|
316
|
+
const label = item.section ?? sectionLabel
|
|
317
|
+
const last = groups[groups.length - 1]
|
|
318
|
+
if (last?.label === label) last.items.push(item)
|
|
319
|
+
else groups.push({ label, items: [item], className: 'px-2' })
|
|
320
|
+
}
|
|
321
|
+
return groups
|
|
322
|
+
},
|
|
323
|
+
itemKey: (item) => `reference-${item.id}`,
|
|
324
|
+
row: (item) => ({ leading: icon, label: captionOf(item.label, item.snippet), description: item.description, hint: <EnterHint /> }),
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface ReferenceTriggerOptions extends ReferenceMenuOptions {
|
|
329
|
+
/** Everything `[` can offer, asked each time the query changes. Narrowed by {@link filterReferences}. */
|
|
330
|
+
items: () => readonly CaptionedItem[]
|
|
331
|
+
/**
|
|
332
|
+
* In place of `items`: the sections to draw for what was typed, already
|
|
333
|
+
* narrowed and ordered by the app. Empty sections are not drawn.
|
|
334
|
+
*/
|
|
335
|
+
sections?: (query: string) => readonly ReferenceSection[]
|
|
336
|
+
/**
|
|
337
|
+
* Called with a listener while the list is open; the app calls it when what
|
|
338
|
+
* `sections` would answer has changed — a search landing — and the open list
|
|
339
|
+
* is drawn again for the same query. Returns the unsubscribe.
|
|
340
|
+
*/
|
|
341
|
+
subscribe?: (listener: () => void) => () => void
|
|
342
|
+
/** The node a pick inserts. Default `captionedReference`. */
|
|
343
|
+
nodeName: string
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const listed = (options: ReferenceTriggerOptions, query: string): ListedItem[] =>
|
|
347
|
+
options.sections
|
|
348
|
+
? options.sections(query).flatMap((section) => section.items.map((item) => ({ ...item, section: section.label })))
|
|
349
|
+
: filterReferences(options.items(), query)
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* `[` — reference something, inserted as a {@link CaptionedReference} chip.
|
|
353
|
+
*
|
|
354
|
+
* Its own extension rather than the chip's suggestion, so the chip's text (its
|
|
355
|
+
* `renderText`) does not change with the key that summons the list.
|
|
356
|
+
*/
|
|
357
|
+
export const ReferenceTrigger = Extension.create<ReferenceTriggerOptions>({
|
|
358
|
+
name: 'referenceTrigger',
|
|
359
|
+
addOptions() {
|
|
360
|
+
return { items: () => [], nodeName: 'captionedReference', ariaLabel: 'References', sectionLabel: 'References', icon: null }
|
|
361
|
+
},
|
|
362
|
+
addProseMirrorPlugins() {
|
|
363
|
+
const options = this.options
|
|
364
|
+
return [
|
|
365
|
+
Suggestion<CaptionedItem>({
|
|
366
|
+
editor: this.editor,
|
|
367
|
+
char: '[',
|
|
368
|
+
pluginKey: new PluginKey('referenceTrigger'),
|
|
369
|
+
// As a mention's: only where the chip may go.
|
|
370
|
+
allow: ({ state, range }) => {
|
|
371
|
+
const type = state.schema.nodes[options.nodeName]
|
|
372
|
+
return !!type && !!state.doc.resolve(range.from).parent.type.contentMatch.matchType(type)
|
|
373
|
+
},
|
|
374
|
+
items: ({ query }) => listed(options, query),
|
|
375
|
+
render: suggestionPopup({ ...referenceMenu(options), subscribe: options.subscribe, refresh: (query) => listed(options, query) }),
|
|
376
|
+
command: ({ editor, range, props: item }) => {
|
|
377
|
+
editor
|
|
378
|
+
.chain()
|
|
379
|
+
.focus()
|
|
380
|
+
.insertContentAt(range, [
|
|
381
|
+
{ type: options.nodeName, attrs: { id: item.id, label: item.label, snippet: item.snippet, uri: item.uri } },
|
|
382
|
+
{ type: 'text', text: ' ' },
|
|
383
|
+
])
|
|
384
|
+
.run()
|
|
385
|
+
},
|
|
386
|
+
}),
|
|
387
|
+
]
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
// ─── / ───
|
|
392
|
+
|
|
393
|
+
/** A row of the `/` list, and what choosing it does. */
|
|
394
|
+
export interface SlashCommand {
|
|
395
|
+
key: string
|
|
396
|
+
row: SuggestionMenuRow
|
|
397
|
+
/** Matched beside the row's label: an id, a trigger, a description. */
|
|
398
|
+
keywords?: readonly string[]
|
|
399
|
+
/** Runs once the `/query` that summoned the list has been removed. */
|
|
400
|
+
run: (editor: Editor) => void
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export interface SlashSection {
|
|
404
|
+
label: string
|
|
405
|
+
commands: readonly SlashCommand[]
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export type FormatId = 'paragraph' | 'heading' | 'subheading' | 'quote' | 'bulletList' | 'orderedList' | 'code'
|
|
409
|
+
|
|
410
|
+
export interface FormatDef {
|
|
411
|
+
id: FormatId
|
|
412
|
+
label: string
|
|
413
|
+
/** What you type to get it — the input rule the extension registers. Blank when there is none. */
|
|
414
|
+
trigger?: string
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The formats `/` offers, in order. The hint is what you would type, not a key
|
|
419
|
+
* chord. Bold, italic and underline are absent: they act on text already
|
|
420
|
+
* written, so they are the selection toolbar's.
|
|
421
|
+
*/
|
|
422
|
+
export const FORMATS: readonly FormatDef[] = [
|
|
423
|
+
{ id: 'paragraph', label: 'Text' },
|
|
424
|
+
{ id: 'heading', label: 'Heading', trigger: '#' },
|
|
425
|
+
{ id: 'subheading', label: 'Subheading', trigger: '##' },
|
|
426
|
+
{ id: 'quote', label: 'Quote', trigger: '>' },
|
|
427
|
+
{ id: 'code', label: 'Code', trigger: '`' },
|
|
428
|
+
{ id: 'bulletList', label: 'Bulleted list', trigger: '-' },
|
|
429
|
+
{ id: 'orderedList', label: 'Numbered list', trigger: '1.' },
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
/** Code as a mark inside the line, or as a block of its own. */
|
|
433
|
+
export type CodeFormat = 'inline' | 'block'
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Apply one format to the current block. `set*` wherever Tiptap has one:
|
|
437
|
+
* choosing Heading should make a heading, not turn one off. `false` means
|
|
438
|
+
* nothing changed, not that anything failed.
|
|
439
|
+
*/
|
|
440
|
+
export function applyFormat(editor: Editor, id: FormatId, code: CodeFormat = 'inline'): boolean {
|
|
441
|
+
const chain = editor.chain().focus()
|
|
442
|
+
/*
|
|
443
|
+
The node's own command, called by name: StarterKit's extensions declare
|
|
444
|
+
them, and this package builds without StarterKit. Its own command, rather
|
|
445
|
+
than the core one it wraps, because a list's runs with the app's options
|
|
446
|
+
(`keepMarks`, `itemTypeName`). An editor without that node changes nothing.
|
|
447
|
+
*/
|
|
448
|
+
const run = (command: string, ...args: unknown[]): boolean => {
|
|
449
|
+
const named = (chain as unknown as Record<string, ((...a: unknown[]) => { run: () => boolean }) | undefined>)[command]
|
|
450
|
+
return named ? named(...args).run() : false
|
|
451
|
+
}
|
|
452
|
+
switch (id) {
|
|
453
|
+
case 'paragraph':
|
|
454
|
+
return run('setParagraph')
|
|
455
|
+
case 'heading':
|
|
456
|
+
return run('setHeading', { level: 1 })
|
|
457
|
+
case 'subheading':
|
|
458
|
+
return run('setHeading', { level: 2 })
|
|
459
|
+
case 'quote':
|
|
460
|
+
return run('toggleBlockquote')
|
|
461
|
+
case 'bulletList':
|
|
462
|
+
return run('toggleBulletList')
|
|
463
|
+
case 'orderedList':
|
|
464
|
+
return run('toggleOrderedList')
|
|
465
|
+
case 'code':
|
|
466
|
+
return run(code === 'block' ? 'toggleCodeBlock' : 'toggleCode')
|
|
467
|
+
default:
|
|
468
|
+
return false
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The Format section every app's `/` opens with. */
|
|
473
|
+
export function formatSection(code: CodeFormat = 'inline'): SlashSection {
|
|
474
|
+
return {
|
|
475
|
+
label: 'Format',
|
|
476
|
+
commands: FORMATS.map((format) => ({
|
|
477
|
+
key: `format-${format.id}`,
|
|
478
|
+
row: { label: format.label, shortcut: format.trigger },
|
|
479
|
+
keywords: [format.id],
|
|
480
|
+
run: (editor) => {
|
|
481
|
+
applyFormat(editor, format.id, code)
|
|
482
|
+
},
|
|
483
|
+
})),
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** A row that types another trigger, so its own list opens — `@` from `/`. */
|
|
488
|
+
export function typeTrigger(input: string): SlashCommand['run'] {
|
|
489
|
+
// Deferred a frame: the delete of the `/query` has to land first, or the
|
|
490
|
+
// trigger's plugin sees the character arrive inside a range still being removed.
|
|
491
|
+
return (editor) => {
|
|
492
|
+
requestAnimationFrame(() => editor.commands.insertContent(input))
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function matches(command: SlashCommand, q: string): boolean {
|
|
497
|
+
return [command.row.label, ...(command.keywords ?? [])].some((text) => text.toLowerCase().includes(q))
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The sections narrowed to a query — the text typed after the `/`. Empty sections stay. */
|
|
501
|
+
export function filterSlashSections(sections: readonly SlashSection[], query: string): SlashSection[] {
|
|
502
|
+
const q = query.toLowerCase()
|
|
503
|
+
return sections.map((section) => ({ ...section, commands: q ? section.commands.filter((command) => matches(command, q)) : section.commands }))
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export interface SlashCommandsOptions {
|
|
507
|
+
/** The sections, in order: {@link formatSection} first, then the app's own. */
|
|
508
|
+
sections: readonly SlashSection[]
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
type SlashRow = SlashCommand & { section: number }
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* `/` — format the line or insert something, at the caret, mid-sentence too.
|
|
515
|
+
* A `/` inside a word (`and/or`, a path) opens nothing: the plugin wants a
|
|
516
|
+
* space or the line's start before it.
|
|
517
|
+
*/
|
|
518
|
+
export const SlashCommands = Extension.create<SlashCommandsOptions>({
|
|
519
|
+
name: 'slashCommands',
|
|
520
|
+
addOptions() {
|
|
521
|
+
return { sections: [formatSection()] }
|
|
522
|
+
},
|
|
523
|
+
addProseMirrorPlugins() {
|
|
524
|
+
const sections = this.options.sections
|
|
525
|
+
return [
|
|
526
|
+
Suggestion<SlashRow>({
|
|
527
|
+
editor: this.editor,
|
|
528
|
+
char: '/',
|
|
529
|
+
startOfLine: false,
|
|
530
|
+
pluginKey: new PluginKey('slashCommands'),
|
|
531
|
+
items: ({ query }) => filterSlashSections(sections, query).flatMap((section, at) => section.commands.map((command) => ({ ...command, section: at }))),
|
|
532
|
+
command: ({ editor, range, props }) => {
|
|
533
|
+
editor.chain().focus().deleteRange(range).run()
|
|
534
|
+
props.run(editor)
|
|
535
|
+
},
|
|
536
|
+
render: suggestionPopup<SlashRow>({
|
|
537
|
+
ariaLabel: 'Commands',
|
|
538
|
+
width: 'w-[300px]',
|
|
539
|
+
maxHeight: 'max-h-[400px]',
|
|
540
|
+
sections: (rows) => sections.map((section, at) => ({ label: section.label, items: rows.filter((row) => row.section === at) })),
|
|
541
|
+
itemKey: (row) => row.key,
|
|
542
|
+
row: (row) => row.row,
|
|
543
|
+
}),
|
|
544
|
+
}),
|
|
545
|
+
]
|
|
546
|
+
},
|
|
547
|
+
})
|
package/src/SuggestionMenu.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useId, useImperativeHandle, useMemo, useState, type ReactNode, type Ref } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, type ReactNode, type Ref } from 'react'
|
|
2
2
|
import { MenuItem, MenuSection, MenuSeparator, type MenuItemProps } from './Menu'
|
|
3
3
|
import { Popover } from './Popover'
|
|
4
4
|
|
|
@@ -85,8 +85,18 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
|
|
|
85
85
|
const base = useId()
|
|
86
86
|
const listboxId = `${base}-list`
|
|
87
87
|
|
|
88
|
-
//
|
|
89
|
-
|
|
88
|
+
// The row the person arrowed to, by key — read as the list was when they
|
|
89
|
+
// moved, so not re-run when `keys` changes.
|
|
90
|
+
const chosen = useRef<string | null>(null)
|
|
91
|
+
useEffect(() => void (chosen.current = highlight > 0 ? (keys.split('\n')[highlight] ?? null) : null), [highlight])
|
|
92
|
+
|
|
93
|
+
// A new list is a new list: the old index may not exist in it. A row the
|
|
94
|
+
// person arrowed to stays highlighted while it is still listed, so an answer
|
|
95
|
+
// landing late does not move their pick under the Enter key.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const at = chosen.current ? keys.split('\n').indexOf(chosen.current) : -1
|
|
98
|
+
setHighlight(at > 0 ? at : 0)
|
|
99
|
+
}, [keys])
|
|
90
100
|
|
|
91
101
|
useImperativeHandle(handle, () => ({
|
|
92
102
|
// The editor hands its arrow keys to this list: Base UI's lists need a text field of their own, and an editor is not one.
|
package/src/editor.ts
CHANGED
|
@@ -9,6 +9,35 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
|
|
11
11
|
export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
|
|
12
|
+
export {
|
|
13
|
+
PersonMention,
|
|
14
|
+
UrgentPersonMention,
|
|
15
|
+
CaptionedReference,
|
|
16
|
+
ReferenceTrigger,
|
|
17
|
+
SlashCommands,
|
|
18
|
+
peopleMenu,
|
|
19
|
+
referenceMenu,
|
|
20
|
+
filterPeople,
|
|
21
|
+
urgentPubkeys,
|
|
22
|
+
filterReferences,
|
|
23
|
+
formatSection,
|
|
24
|
+
applyFormat,
|
|
25
|
+
typeTrigger,
|
|
26
|
+
filterSlashSections,
|
|
27
|
+
FORMATS,
|
|
28
|
+
type MentionPerson,
|
|
29
|
+
type PersonMentionOptions,
|
|
30
|
+
type CaptionedItem,
|
|
31
|
+
type CaptionedReferenceOptions,
|
|
32
|
+
type ReferenceTriggerOptions,
|
|
33
|
+
type ReferenceMenuOptions,
|
|
34
|
+
type SlashCommand,
|
|
35
|
+
type SlashSection,
|
|
36
|
+
type SlashCommandsOptions,
|
|
37
|
+
type FormatId,
|
|
38
|
+
type FormatDef,
|
|
39
|
+
type CodeFormat,
|
|
40
|
+
} from './ComposerTriggers'
|
|
12
41
|
export {
|
|
13
42
|
BlockId,
|
|
14
43
|
UnknownBlock,
|
|
@@ -26,7 +26,7 @@ import { SCHEMA_VERSION, validateRegistry, type Registry, type RegistryEntry } f
|
|
|
26
26
|
const root = process.cwd()
|
|
27
27
|
const registry = buildRegistry({ root })
|
|
28
28
|
/** Editor extensions: named like components by the catalogue, and not React components. */
|
|
29
|
-
const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode']
|
|
29
|
+
const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands']
|
|
30
30
|
const entry = (name: string): RegistryEntry => {
|
|
31
31
|
const found = registry.entries.find((candidate) => candidate.name === name)
|
|
32
32
|
if (!found) throw new Error(`no entry for ${name}`)
|
|
@@ -48,19 +48,20 @@ describe('the registry builds', () => {
|
|
|
48
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('counts
|
|
51
|
+
it('counts 120 names over 66 files: 96 components, 23 helpers and 1 hook (NavTree and the composer triggers joined on 2 October, CON-27)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
|
-
// The
|
|
54
|
-
// editor extensions, documented on SelectionToolbar's
|
|
53
|
+
// The ten `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
|
+
// editor extensions, documented on SelectionToolbar's, BlockNodes' and
|
|
55
|
+
// ComposerTriggers' pages.
|
|
55
56
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
56
|
-
expect(kinds).toEqual({ component:
|
|
57
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
57
|
+
expect(kinds).toEqual({ component: 96, helper: 23, hook: 1 })
|
|
58
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(66)
|
|
58
59
|
})
|
|
59
60
|
|
|
60
61
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
61
62
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
62
63
|
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(62)
|
|
63
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
64
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(58)
|
|
64
65
|
})
|
|
65
66
|
|
|
66
67
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|