@estiva-app/ui 0.66.0 → 0.68.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/BlockNodes.d.ts +12 -1
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/EditorTableControls.d.ts +38 -0
- package/dist/EditorTableControls.d.ts.map +1 -0
- package/dist/LinkChips.d.ts +82 -0
- package/dist/LinkChips.d.ts.map +1 -0
- package/dist/NavItem.d.ts +11 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/ReferenceCards.d.ts +77 -0
- package/dist/ReferenceCards.d.ts.map +1 -0
- package/dist/ReferenceMenu.d.ts +18 -0
- package/dist/ReferenceMenu.d.ts.map +1 -0
- package/dist/RichText.d.ts +9 -2
- package/dist/RichText.d.ts.map +1 -1
- package/dist/SelectionToolbar.d.ts +9 -1
- package/dist/SelectionToolbar.d.ts.map +1 -1
- package/dist/editor.d.ts +4 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +750 -27
- package/dist/editor.js.map +4 -4
- package/dist/index.js +62 -33
- package/dist/index.js.map +4 -4
- package/package.json +2 -1
- package/registry.json +414 -4
- package/src/BlockNodes.test.tsx +2 -1
- package/src/BlockNodes.tsx +51 -9
- package/src/EditorTableControls.mdx +78 -0
- package/src/EditorTableControls.stories.tsx +63 -0
- package/src/EditorTableControls.test.tsx +288 -0
- package/src/EditorTableControls.tsx +255 -0
- package/src/LinkChips.mdx +57 -0
- package/src/LinkChips.stories.tsx +67 -0
- package/src/LinkChips.test.tsx +178 -0
- package/src/LinkChips.ts +243 -0
- package/src/NavItem.mdx +10 -0
- package/src/NavItem.stories.tsx +23 -1
- package/src/NavItem.test.tsx +36 -0
- package/src/NavItem.tsx +40 -16
- package/src/ReferenceCards.mdx +79 -0
- package/src/ReferenceCards.stories.tsx +64 -0
- package/src/ReferenceCards.test.tsx +143 -0
- package/src/ReferenceCards.ts +225 -0
- package/src/ReferenceMenu.mdx +75 -0
- package/src/ReferenceMenu.stories.tsx +59 -0
- package/src/ReferenceMenu.test.tsx +289 -0
- package/src/ReferenceMenu.tsx +346 -0
- package/src/RichText.test.tsx +17 -0
- package/src/RichText.tsx +36 -4
- package/src/SelectionToolbar.tsx +10 -2
- package/src/editor.ts +14 -0
- package/src/pages.test.ts +3 -0
- package/src/registry/registry.test.ts +7 -7
- package/src/registry/siblings.test.ts +2 -2
- package/stories/Choosing.mdx +4 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A chip's menu while writing (Miky, 14 Oct): a click on a chip opens Open and
|
|
4
|
+
* its card instead of following it; a chip just pasted opens the menu by
|
|
5
|
+
* itself, without taking the caret, and typing on closes it with no card.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
9
|
+
import { Editor } from '@tiptap/core'
|
|
10
|
+
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
|
|
11
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
12
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
13
|
+
import { BlockId, ReferenceNode, type ReferenceViewProps } from './BlockNodes'
|
|
14
|
+
import { LinkChips } from './LinkChips'
|
|
15
|
+
import { ReferenceCards } from './ReferenceCards'
|
|
16
|
+
import { ReferenceMenu, ReferenceMenuView, isReferenceMenuOpen, referenceMenuClick } from './ReferenceMenu'
|
|
17
|
+
|
|
18
|
+
const URL_ = 'https://app.example/i/one'
|
|
19
|
+
const V = 'nostr:naddr1example'
|
|
20
|
+
|
|
21
|
+
type Json = { type: string; text?: string; attrs?: Record<string, unknown>; content?: Json[] }
|
|
22
|
+
const inline = (ed: Editor) => (ed.getJSON() as Json).content?.[0].content ?? []
|
|
23
|
+
const chipOf = (ed: Editor) => inline(ed).find((n) => n.type === 'reference')
|
|
24
|
+
const para = (...content: unknown[]): Record<string, unknown> => ({ type: 'doc', content: [{ type: 'paragraph', attrs: { blockId: 'p1' }, content }] })
|
|
25
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
26
|
+
// jsdom has no ClipboardEvent; `pasteText` takes the paste's event, so a plain one stands in.
|
|
27
|
+
const paste = (ed: Editor, text: string) => ed.view.pasteText(text, new Event('paste') as ClipboardEvent)
|
|
28
|
+
const press = (ed: Editor, key: string) => {
|
|
29
|
+
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
|
30
|
+
ed.view.dom.dispatchEvent(event)
|
|
31
|
+
return event.defaultPrevented
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let editor: Editor | undefined
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
cleanup()
|
|
37
|
+
editor?.destroy()
|
|
38
|
+
editor = undefined
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** An editor with the menu, and a stand-in app that knows `known` and answers `ask` when told to. */
|
|
42
|
+
const make = (content: Record<string, unknown>, known: Map<string, string>, answers: (() => void)[] = []) => {
|
|
43
|
+
editor = new Editor({
|
|
44
|
+
extensions: [
|
|
45
|
+
StarterKit.configure({ link: false }),
|
|
46
|
+
BlockId,
|
|
47
|
+
ReferenceNode,
|
|
48
|
+
ReferenceCards,
|
|
49
|
+
ReferenceMenu,
|
|
50
|
+
LinkChips.configure({
|
|
51
|
+
referenceOf: (url) => known.get(url),
|
|
52
|
+
ask: (urls) =>
|
|
53
|
+
new Promise<void>((resolve) =>
|
|
54
|
+
answers.push(() => {
|
|
55
|
+
for (const url of urls) known.set(url, V)
|
|
56
|
+
resolve()
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
}),
|
|
60
|
+
],
|
|
61
|
+
content,
|
|
62
|
+
})
|
|
63
|
+
return editor
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The caret at the end of the first paragraph. */
|
|
67
|
+
const toEnd = (ed: Editor) => ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, ed.state.doc.content.size - 1)))
|
|
68
|
+
|
|
69
|
+
describe('ReferenceMenu', () => {
|
|
70
|
+
it('opens by itself on a pasted URL that becomes its chip, and typing on closes it with no card', async () => {
|
|
71
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map([[URL_, V]]))
|
|
72
|
+
await tick()
|
|
73
|
+
toEnd(ed)
|
|
74
|
+
paste(ed, URL_)
|
|
75
|
+
expect(chipOf(ed)?.attrs).toMatchObject({ uri: V, href: URL_ })
|
|
76
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(true)
|
|
77
|
+
// The caret stayed in the text, after the chip.
|
|
78
|
+
expect(ed.state.selection.empty).toBe(true)
|
|
79
|
+
ed.view.dispatch(ed.state.tr.insertText(' and more'))
|
|
80
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(false)
|
|
81
|
+
expect(chipOf(ed)?.attrs?.embed).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('opens when a pasted URL\'s answer comes back with the caret still after it, not once the person typed on', async () => {
|
|
85
|
+
const answers: (() => void)[] = []
|
|
86
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map(), answers)
|
|
87
|
+
await tick()
|
|
88
|
+
toEnd(ed)
|
|
89
|
+
paste(ed, URL_)
|
|
90
|
+
// Asked at once: a pasted URL is whole.
|
|
91
|
+
expect(answers).toHaveLength(1)
|
|
92
|
+
answers[0]()
|
|
93
|
+
await tick()
|
|
94
|
+
expect(chipOf(ed)?.attrs).toMatchObject({ uri: V })
|
|
95
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(true)
|
|
96
|
+
|
|
97
|
+
const later: (() => void)[] = []
|
|
98
|
+
const other = 'https://app.example/i/two'
|
|
99
|
+
const ed2 = make(para({ type: 'text', text: 'See ' }), new Map(), later)
|
|
100
|
+
await tick()
|
|
101
|
+
toEnd(ed2)
|
|
102
|
+
paste(ed2, other)
|
|
103
|
+
ed2.view.dispatch(ed2.state.tr.insertText(' then'))
|
|
104
|
+
later[0]()
|
|
105
|
+
await tick()
|
|
106
|
+
expect(chipOf(ed2)?.attrs).toMatchObject({ uri: V })
|
|
107
|
+
expect(isReferenceMenuOpen(ed2.state)).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('stays open while a URL elsewhere becomes its chip, and closes when the editor loses focus', async () => {
|
|
111
|
+
const answers: (() => void)[] = []
|
|
112
|
+
const other = 'https://app.example/i/two'
|
|
113
|
+
const doc = {
|
|
114
|
+
type: 'doc',
|
|
115
|
+
content: [
|
|
116
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'See ' }, { type: 'reference', attrs: { uri: V } }] },
|
|
117
|
+
{ type: 'paragraph', attrs: { blockId: 'p2' }, content: [{ type: 'text', text: other }] },
|
|
118
|
+
],
|
|
119
|
+
}
|
|
120
|
+
const ed = make(doc, new Map(), answers)
|
|
121
|
+
await tick()
|
|
122
|
+
expect(referenceMenuClick(ed, 5)).toBe(true)
|
|
123
|
+
answers[0]()
|
|
124
|
+
await tick()
|
|
125
|
+
expect((ed.getJSON() as Json).content?.[1].content?.[0].type).toBe('reference')
|
|
126
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(true)
|
|
127
|
+
ed.view.dom.dispatchEvent(new FocusEvent('blur'))
|
|
128
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(false)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('opens on a chip pasted right before an identical one', () => {
|
|
132
|
+
const ed = make(para({ type: 'text', text: 'a ' }, { type: 'reference', attrs: { uri: V } }), new Map())
|
|
133
|
+
ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, 3)))
|
|
134
|
+
ed.view.dispatch(ed.state.tr.insert(3, ed.schema.nodes.reference.create({ uri: V })).setMeta('uiEvent', 'paste'))
|
|
135
|
+
expect(ed.state.selection.from).toBe(4)
|
|
136
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('leaves a key with a modifier to the editor', async () => {
|
|
140
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map([[URL_, V]]))
|
|
141
|
+
await tick()
|
|
142
|
+
toEnd(ed)
|
|
143
|
+
paste(ed, URL_)
|
|
144
|
+
const shifted = new KeyboardEvent('keydown', { key: 'ArrowDown', shiftKey: true, bubbles: true, cancelable: true })
|
|
145
|
+
ed.view.dom.dispatchEvent(shifted)
|
|
146
|
+
expect(shifted.defaultPrevented).toBe(false)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('offers no Open for a chip with nothing to open', () => {
|
|
150
|
+
// No view: the chip draws no control of its own, and it has no URL.
|
|
151
|
+
const ed = make(para({ type: 'text', text: 'See ' }, { type: 'reference', attrs: { uri: V } }), new Map())
|
|
152
|
+
render(<ReferenceMenuView editor={ed} />)
|
|
153
|
+
act(() => void referenceMenuClick(ed, 5))
|
|
154
|
+
expect(screen.queryByRole('menuitem', { name: 'Open' })).toBeNull()
|
|
155
|
+
expect(screen.getByRole('menuitemradio', { name: 'Compact' })).toBeDefined()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('forgets a paste edited inside: each keystroke in it is not an ask', async () => {
|
|
159
|
+
const answers: (() => void)[] = []
|
|
160
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map(), answers)
|
|
161
|
+
await tick()
|
|
162
|
+
toEnd(ed)
|
|
163
|
+
paste(ed, URL_)
|
|
164
|
+
expect(answers).toHaveLength(1)
|
|
165
|
+
// A character fixed in the middle of the pasted URL, the caret left there.
|
|
166
|
+
const at = 5 + URL_.length - 3
|
|
167
|
+
ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, at)))
|
|
168
|
+
for (const ch of 'xyz') ed.view.dispatch(ed.state.tr.insertText(ch))
|
|
169
|
+
toEnd(ed)
|
|
170
|
+
for (const ch of 'abc') ed.view.dispatch(ed.state.tr.insertText(ch))
|
|
171
|
+
expect(answers).toHaveLength(1)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('opens on a chip picked from a list, which puts a space after it', async () => {
|
|
175
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map())
|
|
176
|
+
toEnd(ed)
|
|
177
|
+
ed.commands.insertContent([{ type: 'reference', attrs: { uri: V } }, { type: 'text', text: ' ' }])
|
|
178
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('does not open on a URL typed by hand, an undo, or a document loaded over the editor', async () => {
|
|
182
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map([[URL_, V]]))
|
|
183
|
+
await tick()
|
|
184
|
+
toEnd(ed)
|
|
185
|
+
for (const ch of URL_) ed.view.dispatch(ed.state.tr.insertText(ch))
|
|
186
|
+
ed.view.dispatch(ed.state.tr.insertText(' '))
|
|
187
|
+
expect(chipOf(ed)?.attrs).toMatchObject({ uri: V })
|
|
188
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(false)
|
|
189
|
+
ed.commands.setContent(para({ type: 'text', text: 'x ' }, { type: 'reference', attrs: { uri: V } }))
|
|
190
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('keeps Enter for a new line after a paste until an arrow highlights a row; then Enter chooses it', async () => {
|
|
194
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map([[URL_, V]]))
|
|
195
|
+
await tick()
|
|
196
|
+
toEnd(ed)
|
|
197
|
+
paste(ed, URL_)
|
|
198
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(true)
|
|
199
|
+
// ↓ to Open, ↓ to None, ↓ to Compact, ↓ to Detailed.
|
|
200
|
+
for (let i = 0; i < 4; i++) expect(press(ed, 'ArrowDown')).toBe(true)
|
|
201
|
+
expect(press(ed, 'Enter')).toBe(true)
|
|
202
|
+
expect(chipOf(ed)?.attrs?.embed).toBe('detailed')
|
|
203
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(false)
|
|
204
|
+
// Closed, Enter is the editor's again.
|
|
205
|
+
expect((ed.getJSON() as Json).content).toHaveLength(1)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('opens from the keyboard with Enter on a selected chip, and Escape closes it', () => {
|
|
209
|
+
const ed = make(para({ type: 'text', text: 'See ' }, { type: 'reference', attrs: { uri: V, embed: 'compact' } }), new Map())
|
|
210
|
+
ed.view.dispatch(ed.state.tr.setSelection(NodeSelection.create(ed.state.doc, 5)))
|
|
211
|
+
expect(press(ed, 'Enter')).toBe(true)
|
|
212
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(true)
|
|
213
|
+
// The chip is still there: Enter did not replace it with a line break.
|
|
214
|
+
expect(chipOf(ed)).toBeDefined()
|
|
215
|
+
// Open is highlighted; ↑ wraps to Detailed.
|
|
216
|
+
press(ed, 'ArrowUp')
|
|
217
|
+
expect(press(ed, 'Escape')).toBe(true)
|
|
218
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(false)
|
|
219
|
+
expect(chipOf(ed)?.attrs?.embed).toBe('compact')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('opens on a click only while editing', () => {
|
|
223
|
+
const ed = make(para({ type: 'text', text: 'See ' }, { type: 'reference', attrs: { uri: V } }), new Map())
|
|
224
|
+
ed.setEditable(false)
|
|
225
|
+
expect(referenceMenuClick(ed, 5)).toBe(false)
|
|
226
|
+
ed.setEditable(true)
|
|
227
|
+
expect(referenceMenuClick(ed, 5)).toBe(true)
|
|
228
|
+
expect(isReferenceMenuOpen(ed.state)).toBe(true)
|
|
229
|
+
// The caret right after the chip, not the chip selected: no selection toolbar beside the menu, and typing does not replace it.
|
|
230
|
+
expect(ed.state.selection.empty && ed.state.selection.from).toBe(6)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('ReferenceMenuView', () => {
|
|
235
|
+
function Linked({ uri }: ReferenceViewProps) {
|
|
236
|
+
return (
|
|
237
|
+
<a href={`/o/${uri}`} onClick={(event) => (followed(uri), event.preventDefault())}>
|
|
238
|
+
Item
|
|
239
|
+
</a>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
const followed = vi.fn()
|
|
243
|
+
|
|
244
|
+
function Harness({ content }: { content: Record<string, unknown> }) {
|
|
245
|
+
const e = useEditor({
|
|
246
|
+
extensions: [StarterKit.configure({ link: false }), BlockId, ReferenceNode.configure({ view: Linked }), ReferenceCards, ReferenceMenu],
|
|
247
|
+
content,
|
|
248
|
+
})
|
|
249
|
+
editor = e ?? undefined
|
|
250
|
+
return (
|
|
251
|
+
<>
|
|
252
|
+
<EditorContent editor={e} />
|
|
253
|
+
<ReferenceMenuView editor={e} />
|
|
254
|
+
</>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
it('takes a click on the chip while editing for its menu, and Open hands the click to the chip', async () => {
|
|
259
|
+
followed.mockClear()
|
|
260
|
+
render(<Harness content={para({ type: 'text', text: 'See ' }, { type: 'reference', attrs: { uri: V } })} />)
|
|
261
|
+
const chip = await screen.findByRole('link', { name: 'Item' })
|
|
262
|
+
fireEvent.click(chip)
|
|
263
|
+
expect(followed).not.toHaveBeenCalled()
|
|
264
|
+
await screen.findByRole('menu', { name: 'Link' })
|
|
265
|
+
fireEvent.mouseDown(screen.getByRole('menuitem', { name: 'Open' }))
|
|
266
|
+
expect(followed).toHaveBeenCalledWith(V)
|
|
267
|
+
await waitFor(() => expect(screen.queryByRole('menu', { name: 'Link' })).toBeNull())
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('shows the chip\'s card checked, and choosing one sets it and closes', async () => {
|
|
271
|
+
render(<Harness content={para({ type: 'text', text: 'See ' }, { type: 'reference', attrs: { uri: V } })} />)
|
|
272
|
+
fireEvent.click(await screen.findByRole('link', { name: 'Item' }))
|
|
273
|
+
const none = await screen.findByRole('menuitemradio', { name: 'None' })
|
|
274
|
+
expect(none.getAttribute('aria-checked')).toBe('true')
|
|
275
|
+
fireEvent.mouseDown(screen.getByRole('menuitemradio', { name: 'Detailed' }))
|
|
276
|
+
expect(chipOf(editor!)?.attrs?.embed).toBe('detailed')
|
|
277
|
+
await waitFor(() => expect(screen.queryByRole('menu', { name: 'Link' })).toBeNull())
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('lets a click follow the chip when the editor is not editable', async () => {
|
|
281
|
+
followed.mockClear()
|
|
282
|
+
render(<Harness content={para({ type: 'text', text: 'See ' }, { type: 'reference', attrs: { uri: V } })} />)
|
|
283
|
+
const chip = await screen.findByRole('link', { name: 'Item' })
|
|
284
|
+
act(() => editor!.setEditable(false))
|
|
285
|
+
fireEvent.click(chip)
|
|
286
|
+
expect(followed).toHaveBeenCalledWith(V)
|
|
287
|
+
expect(screen.queryByRole('menu', { name: 'Link' })).toBeNull()
|
|
288
|
+
})
|
|
289
|
+
})
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a chip in a document offers its writer — Open, and its card: none,
|
|
3
|
+
* compact or detailed (SPEC §13.3, "A reference's card").
|
|
4
|
+
*
|
|
5
|
+
* **Where it opens** (Miky, 14 Oct): a click on a chip while editing opens the
|
|
6
|
+
* menu instead of following the chip, and a chip just put in — pasted, picked
|
|
7
|
+
* with `[`, or a pasted URL becoming its chip (`LinkChips`) — opens it by
|
|
8
|
+
* itself, so the card can be chosen at once. That menu takes no focus: the
|
|
9
|
+
* person is still writing, and typing on closes it with no card. Enter on a
|
|
10
|
+
* selected chip opens it from the keyboard.
|
|
11
|
+
*
|
|
12
|
+
* **Keys while it is open**, handed over by the editor as the `/` menu's are:
|
|
13
|
+
* ↓ and ↑ move through the rows (nothing is highlighted until they do, so
|
|
14
|
+
* Enter after a paste still starts a new line), Enter chooses the highlighted
|
|
15
|
+
* row, Escape closes it. Any edit or caret move closes it.
|
|
16
|
+
*
|
|
17
|
+
* Open does what a click on the chip does when reading: the app's own chip
|
|
18
|
+
* gets the click, so it opens where the app opens it. A chip with nothing to
|
|
19
|
+
* click opens its URL in a new tab.
|
|
20
|
+
*
|
|
21
|
+
* Two parts: the extension `ReferenceMenu` holds which chip the menu is open
|
|
22
|
+
* on, and `ReferenceMenuView` draws it beside `EditorContent`. Pair them with
|
|
23
|
+
* `ReferenceNode` and `ReferenceCards`.
|
|
24
|
+
*/
|
|
25
|
+
import { useCallback } from 'react'
|
|
26
|
+
import { Extension, type Editor } from '@tiptap/core'
|
|
27
|
+
import { useEditorState } from '@tiptap/react'
|
|
28
|
+
import { NodeSelection, Plugin, PluginKey, TextSelection, type EditorState, type Transaction } from '@tiptap/pm/state'
|
|
29
|
+
import { ReplaceStep } from '@tiptap/pm/transform'
|
|
30
|
+
import type { EditorView } from '@tiptap/pm/view'
|
|
31
|
+
import { IconCheck } from '@tabler/icons-react'
|
|
32
|
+
import { CHIPS_MADE } from './LinkChips'
|
|
33
|
+
import { MenuItem, MenuSection, MenuSeparator } from './Menu'
|
|
34
|
+
import { Popover } from './Popover'
|
|
35
|
+
import { setReferenceCardAt, type ReferenceCardLayout } from './ReferenceCards'
|
|
36
|
+
|
|
37
|
+
interface MenuState {
|
|
38
|
+
/** The chip's position. */
|
|
39
|
+
pos: number
|
|
40
|
+
/** The highlighted row of `rowsOf`, or -1 for none. */
|
|
41
|
+
active: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const key = new PluginKey<MenuState | null>('referenceMenu')
|
|
45
|
+
|
|
46
|
+
const LAYOUTS: { layout: ReferenceCardLayout; label: string }[] = [
|
|
47
|
+
{ layout: 'none', label: 'None' },
|
|
48
|
+
{ layout: 'compact', label: 'Compact' },
|
|
49
|
+
{ layout: 'detailed', label: 'Detailed' },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/** A row: Open, or a card. */
|
|
53
|
+
type Row = 'open' | ReferenceCardLayout
|
|
54
|
+
|
|
55
|
+
/** The chip's own control to click, or null. */
|
|
56
|
+
function chipTarget(view: EditorView, pos: number): HTMLElement | null {
|
|
57
|
+
const dom = view.nodeDOM(pos)
|
|
58
|
+
return dom instanceof HTMLElement ? dom.querySelector<HTMLElement>('a[href], button') : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An http(s) URL chip's URL, or ''. */
|
|
62
|
+
function webHref(view: EditorView, pos: number): string {
|
|
63
|
+
const href = view.state.doc.nodeAt(pos)?.attrs.href
|
|
64
|
+
return typeof href === 'string' && /^https?:\/\//i.test(href) ? href : ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The menu's rows: Open when there is something to open — the chip's own
|
|
69
|
+
* control, or its URL — and the cards when the chip names something.
|
|
70
|
+
*/
|
|
71
|
+
function rowsOf(view: EditorView, pos: number): Row[] {
|
|
72
|
+
const open: Row[] = chipTarget(view, pos) || webHref(view, pos) ? ['open'] : []
|
|
73
|
+
return view.state.doc.nodeAt(pos)?.attrs.uri ? [...open, ...LAYOUTS.map((card) => card.layout)] : open
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A chip node, or null. */
|
|
77
|
+
function chipAt(state: EditorState, pos: number) {
|
|
78
|
+
const node = state.doc.nodeAt(pos)
|
|
79
|
+
return node && node.type.name === 'reference' ? node : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The chip this transaction just put right before the caret — a paste, a `[`
|
|
84
|
+
* pick (which adds a space after it), or a URL becoming its chip — or null.
|
|
85
|
+
* An undo, and a document loaded over the editor, put nothing in.
|
|
86
|
+
*/
|
|
87
|
+
function justPlaced(tr: Transaction, state: EditorState): number | null {
|
|
88
|
+
if (!tr.docChanged || tr.getMeta('history$') || tr.getMeta('preventUpdate')) return null
|
|
89
|
+
// A whole document set over the editor — someone else's edit loaded — is not something the person put in.
|
|
90
|
+
const whole = tr.steps.some((step) => step instanceof ReplaceStep && step.from === 0 && step.to === tr.before.content.size)
|
|
91
|
+
if (whole && tr.getMeta('uiEvent') !== 'paste') return null
|
|
92
|
+
// A URL becoming its chip opens the menu only when it was pasted, with the caret still right after it.
|
|
93
|
+
const made = tr.getMeta(CHIPS_MADE) as { pasted: boolean } | undefined
|
|
94
|
+
if (made && !made.pasted) return null
|
|
95
|
+
const { selection } = state
|
|
96
|
+
if (!selection.empty) return null
|
|
97
|
+
let at = selection.from
|
|
98
|
+
let before = selection.$from.nodeBefore
|
|
99
|
+
if (!made && before?.isText && before.text === ' ') {
|
|
100
|
+
at -= 1
|
|
101
|
+
before = state.doc.resolve(at).nodeBefore
|
|
102
|
+
}
|
|
103
|
+
if (!before || before.type.name !== 'reference' || !before.attrs.uri) return null
|
|
104
|
+
const pos = at - before.nodeSize
|
|
105
|
+
const old = tr.mapping.invert().map(pos)
|
|
106
|
+
const was = tr.before.nodeAt(old)
|
|
107
|
+
// The same chip as before the step — a key pressed after it, a card set on it — is not new. One put in just before
|
|
108
|
+
// an identical chip pushes that chip along, so it no longer maps onto `pos` (code review).
|
|
109
|
+
if (was && was.type.name === 'reference' && was.attrs.uri === before.attrs.uri && tr.mapping.map(old, 1) === pos) return null
|
|
110
|
+
return pos
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function dispatchMenu(view: EditorView, next: MenuState | null): void {
|
|
114
|
+
view.dispatch(view.state.tr.setMeta(key, next))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// True while Open hands the click to the chip, so the chip's own click is not taken for a click that opens the menu.
|
|
118
|
+
let passing = false
|
|
119
|
+
|
|
120
|
+
/** Opens what the chip at `pos` names, as a click on it does when reading. */
|
|
121
|
+
function openChip(view: EditorView, pos: number): void {
|
|
122
|
+
const target = chipTarget(view, pos)
|
|
123
|
+
if (target) {
|
|
124
|
+
passing = true
|
|
125
|
+
try {
|
|
126
|
+
target.click()
|
|
127
|
+
} finally {
|
|
128
|
+
passing = false
|
|
129
|
+
}
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
const href = webHref(view, pos)
|
|
133
|
+
if (href) window.open(href, '_blank', 'noopener,noreferrer')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Runs `row` of the menu open on the chip at `pos`, and closes it. */
|
|
137
|
+
function choose(editor: Editor, pos: number, row: Row | undefined): void {
|
|
138
|
+
dispatchMenu(editor.view, null)
|
|
139
|
+
if (row === 'open') openChip(editor.view, pos)
|
|
140
|
+
else if (row) setReferenceCardAt(editor, pos, row)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Whether the menu is open, for an editor's own Escape: while it is, Escape closes it rather than leaving. */
|
|
144
|
+
export function isReferenceMenuOpen(state: EditorState): boolean {
|
|
145
|
+
return !!key.getState(state)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A click on the chip at `pos`, from its node view: while editing, with the
|
|
150
|
+
* menu installed, it opens the menu, and the caller stops the click there.
|
|
151
|
+
* Returns false when the click should go on to the chip.
|
|
152
|
+
*/
|
|
153
|
+
export function referenceMenuClick(editor: Editor, pos: number): boolean {
|
|
154
|
+
if (passing || editor.isDestroyed || !editor.isEditable || key.getState(editor.state) === undefined) return false
|
|
155
|
+
// A chip with nothing to open and no card to choose has no menu: the click goes on to it.
|
|
156
|
+
if (!chipAt(editor.state, pos) || rowsOf(editor.view, pos).length === 0) return false
|
|
157
|
+
editor.view.dispatch(caretAfter(editor.state, pos).setMeta(key, { pos, active: -1 }))
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The caret right after the chip, as a paste leaves it: a selected chip would
|
|
163
|
+
* bring up the selection toolbar beside the menu, and typing would replace it.
|
|
164
|
+
*/
|
|
165
|
+
function caretAfter(state: EditorState, pos: number): Transaction {
|
|
166
|
+
const node = state.doc.nodeAt(pos)
|
|
167
|
+
return state.tr.setSelection(TextSelection.create(state.doc, pos + (node?.nodeSize ?? 1)))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Which chip the menu is open on: see the module comment. */
|
|
171
|
+
export const ReferenceMenu = Extension.create({
|
|
172
|
+
name: 'referenceMenu',
|
|
173
|
+
// Before the editor's own keys, so Enter on a highlighted row is the row's, not a new line.
|
|
174
|
+
priority: 1000,
|
|
175
|
+
addProseMirrorPlugins() {
|
|
176
|
+
const editor = this.editor
|
|
177
|
+
return [
|
|
178
|
+
new Plugin<MenuState | null>({
|
|
179
|
+
key,
|
|
180
|
+
state: {
|
|
181
|
+
init: () => null,
|
|
182
|
+
apply: (tr, value, before, after) => {
|
|
183
|
+
const meta = tr.getMeta(key) as MenuState | null | undefined
|
|
184
|
+
if (meta !== undefined) return meta
|
|
185
|
+
const placed = justPlaced(tr, after)
|
|
186
|
+
if (placed !== null) return { pos: placed, active: -1 }
|
|
187
|
+
if (!value) return null
|
|
188
|
+
const pos = tr.mapping.map(value.pos)
|
|
189
|
+
if (!chipAt(after, pos)) return null
|
|
190
|
+
// What another plugin appends — an id, a trailing line — is not the person typing on, and nor is a URL
|
|
191
|
+
// elsewhere becoming its chip when its answer comes back (code review).
|
|
192
|
+
if (tr.docChanged && !tr.getMeta('appendedTransaction') && tr.getMeta(CHIPS_MADE) === undefined) return null
|
|
193
|
+
const onChip = after.selection instanceof NodeSelection && after.selection.from === pos
|
|
194
|
+
if (tr.selectionSet && !after.selection.eq(before.selection) && !onChip) return null
|
|
195
|
+
return pos === value.pos ? value : { ...value, pos }
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
props: {
|
|
199
|
+
handleDOMEvents: {
|
|
200
|
+
// Focus gone, the keys no longer reach the menu, and a card chosen now would miss the save on leaving (code review).
|
|
201
|
+
blur: (view) => {
|
|
202
|
+
if (key.getState(view.state)) dispatchMenu(view, null)
|
|
203
|
+
return false
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
handleKeyDown: (view, event) => {
|
|
207
|
+
// With a modifier, a key is the editor's: Shift+↓ extends the selection, Mod+Enter is not a pick.
|
|
208
|
+
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return false
|
|
209
|
+
const open = key.getState(view.state)
|
|
210
|
+
if (!open) {
|
|
211
|
+
// Enter on a selected chip: the menu, from the keyboard, its first row highlighted.
|
|
212
|
+
const { selection } = view.state
|
|
213
|
+
if (event.key !== 'Enter' || !view.editable || !(selection instanceof NodeSelection) || selection.node.type.name !== 'reference') return false
|
|
214
|
+
if (rowsOf(view, selection.from).length === 0) return false
|
|
215
|
+
view.dispatch(caretAfter(view.state, selection.from).setMeta(key, { pos: selection.from, active: 0 }))
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
const rows = rowsOf(view, open.pos)
|
|
219
|
+
if (event.key === 'Escape') {
|
|
220
|
+
dispatchMenu(view, null)
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
if (rows.length === 0) return false
|
|
224
|
+
if (event.key === 'ArrowDown') {
|
|
225
|
+
dispatchMenu(view, { ...open, active: (open.active + 1) % rows.length })
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
if (event.key === 'ArrowUp') {
|
|
229
|
+
dispatchMenu(view, { ...open, active: open.active <= 0 ? rows.length - 1 : open.active - 1 })
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
if (event.key === 'Enter' && open.active >= 0) {
|
|
233
|
+
choose(editor, open.pos, rows[open.active])
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
return false
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
]
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
/** What the panel draws: the chip the menu is open on, and its card. */
|
|
245
|
+
interface Shown {
|
|
246
|
+
pos: number
|
|
247
|
+
active: number
|
|
248
|
+
named: boolean
|
|
249
|
+
layout: ReferenceCardLayout
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface ReferenceMenuViewProps {
|
|
253
|
+
editor: Editor | null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The menu `ReferenceMenu` holds, hung under its chip. Render it always, beside `EditorContent`. */
|
|
257
|
+
export function ReferenceMenuView({ editor }: ReferenceMenuViewProps) {
|
|
258
|
+
const shown = useEditorState({
|
|
259
|
+
editor,
|
|
260
|
+
selector: ({ editor: ed }): Shown | null => {
|
|
261
|
+
if (!ed || ed.isDestroyed) return null
|
|
262
|
+
const open = key.getState(ed.state)
|
|
263
|
+
if (!open) return null
|
|
264
|
+
const node = chipAt(ed.state, open.pos)
|
|
265
|
+
if (!node) return null
|
|
266
|
+
const embed = node.attrs.embed
|
|
267
|
+
// An unknown layout draws compact (§13.3), so it reads as compact here.
|
|
268
|
+
const layout: ReferenceCardLayout = embed === 'detailed' ? 'detailed' : typeof embed === 'string' && embed !== '' && embed !== 'none' ? 'compact' : 'none'
|
|
269
|
+
return { pos: open.pos, active: open.active, named: !!node.attrs.uri, layout }
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
// A press anywhere in the panel keeps focus in the text, as a row's does: the editor saves and ends its menus when it loses focus.
|
|
273
|
+
const keepFocus = useCallback((list: HTMLDivElement | null) => {
|
|
274
|
+
const panel = list?.closest('[role="dialog"]')
|
|
275
|
+
if (!panel) return
|
|
276
|
+
const keep = (event: Event) => event.preventDefault()
|
|
277
|
+
panel.addEventListener('mousedown', keep)
|
|
278
|
+
return () => panel.removeEventListener('mousedown', keep)
|
|
279
|
+
}, [])
|
|
280
|
+
|
|
281
|
+
const live = shown && editor && !editor.isDestroyed ? editor : null
|
|
282
|
+
const dom = live && shown ? live.view.nodeDOM(shown.pos) : null
|
|
283
|
+
const anchor = dom instanceof HTMLElement ? dom : null
|
|
284
|
+
const rows = live && shown ? rowsOf(live.view, shown.pos) : []
|
|
285
|
+
const opens = rows[0] === 'open'
|
|
286
|
+
const run = (row: Row) => {
|
|
287
|
+
if (editor && shown) choose(editor, shown.pos, row)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
// finalFocus={false}: focus never left the text.
|
|
292
|
+
<Popover
|
|
293
|
+
anchor={anchor}
|
|
294
|
+
open={!!shown && !!anchor && rows.length > 0}
|
|
295
|
+
onOpenChange={(next) => {
|
|
296
|
+
if (!next && editor && !editor.isDestroyed && key.getState(editor.state)) dispatchMenu(editor.view, null)
|
|
297
|
+
}}
|
|
298
|
+
side="bottom"
|
|
299
|
+
align="left"
|
|
300
|
+
ariaLabel="Link"
|
|
301
|
+
finalFocus={false}
|
|
302
|
+
>
|
|
303
|
+
{shown && (
|
|
304
|
+
<div ref={keepFocus} role="menu" aria-label="Link" className="flex flex-col gap-0.5">
|
|
305
|
+
{/* Only when there is something to open: the chip's own control, or its URL (code review). */}
|
|
306
|
+
{opens && (
|
|
307
|
+
<MenuItem
|
|
308
|
+
role="menuitem"
|
|
309
|
+
tabIndex={-1}
|
|
310
|
+
label="Open"
|
|
311
|
+
selected={shown.active === 0}
|
|
312
|
+
onMouseDown={(event) => {
|
|
313
|
+
event.preventDefault()
|
|
314
|
+
run('open')
|
|
315
|
+
}}
|
|
316
|
+
/>
|
|
317
|
+
)}
|
|
318
|
+
{shown.named && (
|
|
319
|
+
<>
|
|
320
|
+
{opens && <MenuSeparator />}
|
|
321
|
+
<div role="group" aria-label="Card">
|
|
322
|
+
<MenuSection label="Card">
|
|
323
|
+
{LAYOUTS.map(({ layout, label }, i) => (
|
|
324
|
+
<MenuItem
|
|
325
|
+
key={layout}
|
|
326
|
+
role="menuitemradio"
|
|
327
|
+
aria-checked={shown.layout === layout}
|
|
328
|
+
tabIndex={-1}
|
|
329
|
+
label={label}
|
|
330
|
+
selected={shown.active === i + (opens ? 1 : 0)}
|
|
331
|
+
trailing={shown.layout === layout ? <IconCheck size={16} aria-hidden /> : undefined}
|
|
332
|
+
onMouseDown={(event) => {
|
|
333
|
+
event.preventDefault()
|
|
334
|
+
run(layout)
|
|
335
|
+
}}
|
|
336
|
+
/>
|
|
337
|
+
))}
|
|
338
|
+
</MenuSection>
|
|
339
|
+
</div>
|
|
340
|
+
</>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
</Popover>
|
|
345
|
+
)
|
|
346
|
+
}
|
package/src/RichText.test.tsx
CHANGED
|
@@ -19,6 +19,23 @@ const row = (typeName: string, ...texts: string[]): RichTextBlock => ({ type: 'u
|
|
|
19
19
|
const draw = (blocks: RichTextBlock[], props = {}) => render(<RichText blocks={blocks} {...props} />).container
|
|
20
20
|
|
|
21
21
|
describe('RichText', () => {
|
|
22
|
+
it('draws renderAfter after a line of words, inside a list item, and nowhere it returns nothing', () => {
|
|
23
|
+
const after = (block: RichTextBlock) => (block.id === 'skip' ? null : <aside data-after={block.id} />)
|
|
24
|
+
const c = draw(
|
|
25
|
+
[
|
|
26
|
+
{ type: 'paragraph', id: 'p1', inline: [t('One')] },
|
|
27
|
+
{ type: 'paragraph', id: 'skip', inline: [t('Two')] },
|
|
28
|
+
{ type: 'bulletList', id: 'l1', children: [{ type: 'listItem', id: 'i1', inline: [t('Item')] }] },
|
|
29
|
+
{ type: 'heading', id: 'h1', level: 2, inline: [t('Head')] },
|
|
30
|
+
],
|
|
31
|
+
{ renderAfter: after },
|
|
32
|
+
)
|
|
33
|
+
expect([...c.querySelectorAll('[data-after]')].map((e) => e.getAttribute('data-after'))).toEqual(['p1', 'i1', 'h1'])
|
|
34
|
+
// After its paragraph, not inside it; inside its item, not after the list.
|
|
35
|
+
expect(c.querySelector('p[data-block-id="p1"] + aside[data-after="p1"]')).not.toBeNull()
|
|
36
|
+
expect(c.querySelector('li[data-block-id="i1"] > aside[data-after="i1"]')).not.toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
22
39
|
it('draws each block as its element', () => {
|
|
23
40
|
const c = draw([
|
|
24
41
|
{ type: 'heading', level: 1, inline: [t('One')] },
|