@estiva-app/ui 0.67.0 → 0.69.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/LinkChips.d.ts +82 -0
- package/dist/LinkChips.d.ts.map +1 -0
- 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 +3 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +595 -20
- package/dist/editor.js.map +4 -4
- package/dist/index.js +39 -19
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +290 -3
- package/src/BlockNodes.test.tsx +2 -1
- package/src/BlockNodes.tsx +51 -9
- package/src/LinkChips.mdx +59 -0
- package/src/LinkChips.stories.tsx +67 -0
- package/src/LinkChips.test.tsx +221 -0
- package/src/LinkChips.ts +252 -0
- 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 +34 -3
- package/src/SelectionToolbar.tsx +10 -2
- package/src/editor.ts +13 -0
- package/src/pages.test.ts +3 -0
- package/src/registry/registry.test.ts +6 -6
- package/stories/Choosing.mdx +3 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* An app URL in a document reads as its chip (SPEC §13.3, §7.7): a URL that
|
|
4
|
+
* names something becomes a URL chip keeping its URL, one that names nothing or
|
|
5
|
+
* is not answered yet stays text, and the URL being typed is left alone.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { Editor } from '@tiptap/core'
|
|
9
|
+
import { TextSelection } from '@tiptap/pm/state'
|
|
10
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
11
|
+
import { BlockId, ReferenceNode } from './BlockNodes'
|
|
12
|
+
import { LinkChips, refreshLinkChips } from './LinkChips'
|
|
13
|
+
import { ReferenceCards, referenceAtSelection, referenceCardPlacements } from './ReferenceCards'
|
|
14
|
+
|
|
15
|
+
const URL_ = 'https://app.example/i/one'
|
|
16
|
+
const V = 'nostr:naddr1example'
|
|
17
|
+
|
|
18
|
+
type Json = { type: string; text?: string; attrs?: Record<string, unknown>; content?: Json[] }
|
|
19
|
+
const inline = (ed: Editor) => (ed.getJSON() as Json).content?.[0].content ?? []
|
|
20
|
+
const para = (...content: unknown[]): Record<string, unknown> => ({ type: 'doc', content: [{ type: 'paragraph', attrs: { blockId: 'p1' }, content }] })
|
|
21
|
+
|
|
22
|
+
let editor: Editor | undefined
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
editor?.destroy()
|
|
25
|
+
editor = undefined
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const make = (content: Record<string, unknown>, known: Map<string, string>, asked: string[][] = []) => {
|
|
29
|
+
editor = new Editor({
|
|
30
|
+
extensions: [
|
|
31
|
+
// A document editor has no link mark: its URLs are text until they are chips.
|
|
32
|
+
StarterKit.configure({ link: false }),
|
|
33
|
+
BlockId,
|
|
34
|
+
ReferenceNode,
|
|
35
|
+
ReferenceCards,
|
|
36
|
+
LinkChips.configure({
|
|
37
|
+
referenceOf: (url) => known.get(url),
|
|
38
|
+
ask: (urls) => {
|
|
39
|
+
asked.push(urls)
|
|
40
|
+
return Promise.resolve()
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
content,
|
|
45
|
+
})
|
|
46
|
+
return editor
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
50
|
+
|
|
51
|
+
describe('LinkChips', () => {
|
|
52
|
+
it('makes a URL that names something its chip on open, keeping the URL and the marks', async () => {
|
|
53
|
+
const ed = make(para({ type: 'text', text: `See ${URL_}.` }), new Map([[URL_, V]]))
|
|
54
|
+
await tick()
|
|
55
|
+
const [before, chip, after] = inline(ed)
|
|
56
|
+
expect(before.text).toBe('See ')
|
|
57
|
+
expect(chip.type).toBe('reference')
|
|
58
|
+
expect(chip.attrs).toMatchObject({ uri: V, href: URL_ })
|
|
59
|
+
expect(after.text).toBe('.')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('leaves a URL that names nothing as text, and asks the app about it', async () => {
|
|
63
|
+
const asked: string[][] = []
|
|
64
|
+
const ed = make(para({ type: 'text', text: `See ${URL_} and https://else.example/x` }), new Map(), asked)
|
|
65
|
+
await tick()
|
|
66
|
+
expect(inline(ed)).toHaveLength(1)
|
|
67
|
+
expect(asked[0]).toEqual([URL_, 'https://else.example/x'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('asks about each URL once, and no more than its limit', async () => {
|
|
71
|
+
const asked: string[][] = []
|
|
72
|
+
editor = new Editor({
|
|
73
|
+
extensions: [
|
|
74
|
+
StarterKit.configure({ link: false }),
|
|
75
|
+
BlockId,
|
|
76
|
+
ReferenceNode,
|
|
77
|
+
LinkChips.configure({ referenceOf: () => undefined, ask: (urls) => void asked.push(urls), limit: 2 }),
|
|
78
|
+
],
|
|
79
|
+
content: para({ type: 'text', text: 'https://a.example/1 https://a.example/2 https://a.example/3' }),
|
|
80
|
+
})
|
|
81
|
+
await tick()
|
|
82
|
+
editor.commands.insertContentAt(1, 'x ')
|
|
83
|
+
expect(asked).toEqual([['https://a.example/1', 'https://a.example/2']])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('turns the URL into its chip once the answer comes back', async () => {
|
|
87
|
+
const known = new Map<string, string>()
|
|
88
|
+
const ed = make(para({ type: 'text', text: `See ${URL_} now` }), known)
|
|
89
|
+
await tick()
|
|
90
|
+
expect(inline(ed)).toHaveLength(1)
|
|
91
|
+
known.set(URL_, V)
|
|
92
|
+
refreshLinkChips(ed)
|
|
93
|
+
expect(inline(ed)[1].attrs).toMatchObject({ uri: V, href: URL_ })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('leaves the URL the caret is at the end of, and takes it once the person moves on', async () => {
|
|
97
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map([[URL_, V]]))
|
|
98
|
+
await tick()
|
|
99
|
+
ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, ed.state.doc.content.size - 1)))
|
|
100
|
+
ed.view.dispatch(ed.state.tr.insertText(URL_))
|
|
101
|
+
expect(inline(ed)).toHaveLength(1)
|
|
102
|
+
ed.view.dispatch(ed.state.tr.insertText(' '))
|
|
103
|
+
expect(inline(ed)[1].attrs).toMatchObject({ uri: V, href: URL_ })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('fills in a URL chip read from a document once it is answered; until then it offers no card', async () => {
|
|
107
|
+
const known = new Map<string, string>()
|
|
108
|
+
const ed = make(para({ type: 'reference', attrs: { uri: '', href: URL_, embed: 'compact' } }), known)
|
|
109
|
+
await tick()
|
|
110
|
+
expect(referenceCardPlacements(ed.state.doc)).toEqual([])
|
|
111
|
+
ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, 1, 2)))
|
|
112
|
+
expect(referenceAtSelection(ed.state)).toBeNull()
|
|
113
|
+
known.set(URL_, V)
|
|
114
|
+
refreshLinkChips(ed)
|
|
115
|
+
expect(inline(ed)[0].attrs).toMatchObject({ uri: V, href: URL_, embed: 'compact' })
|
|
116
|
+
expect(referenceCardPlacements(ed.state.doc)[0].cards).toEqual([{ uri: V, layout: 'compact' }])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('dispatches nothing on open when there is no chip to make, so no other plugin wakes', async () => {
|
|
120
|
+
const ed = make(para({ type: 'text', text: 'Words, no address.' }), new Map([[URL_, V]]))
|
|
121
|
+
let transactions = 0
|
|
122
|
+
ed.on('transaction', () => void transactions++)
|
|
123
|
+
await tick()
|
|
124
|
+
refreshLinkChips(ed)
|
|
125
|
+
expect(transactions).toBe(0)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('leaves a URL in code alone', async () => {
|
|
129
|
+
const ed = make(para({ type: 'text', text: URL_, marks: [{ type: 'code' }] }), new Map([[URL_, V]]))
|
|
130
|
+
await tick()
|
|
131
|
+
expect(inline(ed)[0].type).toBe('text')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('takes a pasted URL chip\'s URL and resolves it itself, never the clipboard\'s reference', async () => {
|
|
135
|
+
const known = new Map<string, string>()
|
|
136
|
+
const ed = make(para({ type: 'text', text: 'x' }), known)
|
|
137
|
+
await tick()
|
|
138
|
+
ed.commands.insertContent(`<p><span data-reference="nostr:naddr1hostile" data-href="${URL_}" data-embed="detailed">${URL_}</span></p>`)
|
|
139
|
+
const chip = (ed.getJSON() as Json).content?.flatMap((b) => b.content ?? []).find((n) => n.type === 'reference')
|
|
140
|
+
expect(chip?.attrs).toMatchObject({ uri: '', href: URL_ })
|
|
141
|
+
expect(referenceCardPlacements(ed.state.doc)).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('drops a pasted URL chip whose href is not http(s)', async () => {
|
|
145
|
+
const ed = make(para({ type: 'text', text: 'x' }), new Map())
|
|
146
|
+
await tick()
|
|
147
|
+
ed.commands.insertContent('<p><span data-reference="nostr:naddr1x" data-href="javascript:alert(1)">x</span></p>')
|
|
148
|
+
const chip = (ed.getJSON() as Json).content?.flatMap((b) => b.content ?? []).find((n) => n.type === 'reference')
|
|
149
|
+
expect(chip?.attrs?.href).toBeNull()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('asks nothing while a URL is typed one character at a time, then the whole URL once', async () => {
|
|
153
|
+
const asked: string[][] = []
|
|
154
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map(), asked)
|
|
155
|
+
await tick()
|
|
156
|
+
ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, ed.state.doc.content.size - 1)))
|
|
157
|
+
for (const ch of URL_) ed.view.dispatch(ed.state.tr.insertText(ch))
|
|
158
|
+
expect(asked).toEqual([])
|
|
159
|
+
ed.view.dispatch(ed.state.tr.insertText(' '))
|
|
160
|
+
expect(asked).toEqual([[URL_]])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('turns a URL with the caret before it, but not one under a selection', async () => {
|
|
164
|
+
const ed = make(para({ type: 'text', text: `${URL_} end` }), new Map([[URL_, V]]))
|
|
165
|
+
await tick()
|
|
166
|
+
// The editor opens with the caret at the URL's start: it is not being typed.
|
|
167
|
+
expect(inline(ed)[0].type).toBe('reference')
|
|
168
|
+
ed.commands.setContent(para({ type: 'text', text: `see ${URL_}` }))
|
|
169
|
+
ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, 10, 14)))
|
|
170
|
+
expect(inline(ed)).toHaveLength(1)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('copies a URL chip as its URL', async () => {
|
|
174
|
+
const ed = make(para({ type: 'text', text: `${URL_} ` }), new Map([[URL_, V]]))
|
|
175
|
+
await tick()
|
|
176
|
+
expect(ed.getText()).toBe(`${URL_} `)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('makes a pasted URL its chip as its own undo step: undo gives the URL back, then takes the paste away', async () => {
|
|
180
|
+
const ed = make(para({ type: 'text', text: 'See ' }), new Map([[URL_, V]]))
|
|
181
|
+
await tick()
|
|
182
|
+
ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, ed.state.doc.content.size - 1)))
|
|
183
|
+
ed.view.pasteText(URL_, new Event('paste') as ClipboardEvent)
|
|
184
|
+
expect(inline(ed).map((n) => n.type)).toEqual(['text', 'reference'])
|
|
185
|
+
ed.commands.undo()
|
|
186
|
+
expect(inline(ed)).toEqual([{ type: 'text', text: `See ${URL_}` }])
|
|
187
|
+
ed.commands.undo()
|
|
188
|
+
expect(inline(ed)).toEqual([{ type: 'text', text: 'See ' }])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('makes a pasted URL answered later its own undo step too', async () => {
|
|
192
|
+
const known = new Map<string, string>()
|
|
193
|
+
const ed = make(para({ type: 'text', text: 'See ' }), known)
|
|
194
|
+
await tick()
|
|
195
|
+
ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, ed.state.doc.content.size - 1)))
|
|
196
|
+
ed.view.pasteText(URL_, new Event('paste') as ClipboardEvent)
|
|
197
|
+
expect(inline(ed)).toEqual([{ type: 'text', text: `See ${URL_}` }])
|
|
198
|
+
known.set(URL_, V)
|
|
199
|
+
refreshLinkChips(ed)
|
|
200
|
+
expect(inline(ed)[1].type).toBe('reference')
|
|
201
|
+
ed.commands.undo()
|
|
202
|
+
expect(inline(ed)).toEqual([{ type: 'text', text: `See ${URL_}` }])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('turns a link whose text is its own URL into its chip, and leaves a link on words', async () => {
|
|
206
|
+
editor = new Editor({
|
|
207
|
+
// A composer's link mark: an autolink, or its paste rule, links a bare URL to itself.
|
|
208
|
+
extensions: [StarterKit.configure({ link: { autolink: false, openOnClick: false } }), BlockId, ReferenceNode, LinkChips.configure({ referenceOf: (url) => (url === URL_ ? V : undefined), ask: null })],
|
|
209
|
+
content: para(
|
|
210
|
+
{ type: 'text', text: URL_, marks: [{ type: 'link', attrs: { href: URL_ } }, { type: 'bold' }] },
|
|
211
|
+
{ type: 'text', text: ' and ' },
|
|
212
|
+
{ type: 'text', text: 'the words', marks: [{ type: 'link', attrs: { href: URL_ } }] },
|
|
213
|
+
),
|
|
214
|
+
})
|
|
215
|
+
await tick()
|
|
216
|
+
const [chip, , words] = inline(editor)
|
|
217
|
+
expect(chip.type).toBe('reference')
|
|
218
|
+
expect(chip.attrs).toMatchObject({ uri: V, href: URL_, marks: ['bold'] })
|
|
219
|
+
expect(words).toMatchObject({ type: 'text', text: 'the words' })
|
|
220
|
+
})
|
|
221
|
+
})
|
package/src/LinkChips.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app URL in a document reads as the chip of what it names — SPEC §13.3,
|
|
3
|
+
* "A reference's card", and §7.7.
|
|
4
|
+
*
|
|
5
|
+
* The URL stays the URL: the chip is the `reference` node with `href` set (a
|
|
6
|
+
* URL chip, `ReferenceNode`), which a document writes back as the URL it was,
|
|
7
|
+
* plain text with no card and a `link` mark with one. Never a `nostr:`
|
|
8
|
+
* reference in its place: the URL is how a person addresses the object.
|
|
9
|
+
*
|
|
10
|
+
* What a URL names is the app's to say — its manifests and its relay — so the
|
|
11
|
+
* app hands this extension two functions: `referenceOf`, what it has resolved
|
|
12
|
+
* so far, and `ask`, to look up the URLs it has not. A URL that names nothing,
|
|
13
|
+
* or whose answer has not come back, stays text; when `ask` settles the
|
|
14
|
+
* document is looked at again.
|
|
15
|
+
*
|
|
16
|
+
* The URL being typed stays text while the caret is in it or at its end, so a
|
|
17
|
+
* half-typed address never turns into a chip under the person's fingers. A
|
|
18
|
+
* pasted URL is whole, so it is asked about and made a chip at once, the caret
|
|
19
|
+
* still at its end — where `ReferenceMenu` opens its menu.
|
|
20
|
+
*/
|
|
21
|
+
import { Extension, type Editor } from '@tiptap/core'
|
|
22
|
+
import { closeHistory } from '@tiptap/pm/history'
|
|
23
|
+
import type { Node as PmNode } from '@tiptap/pm/model'
|
|
24
|
+
import { Plugin, PluginKey, type EditorState, type Selection, type Transaction } from '@tiptap/pm/state'
|
|
25
|
+
|
|
26
|
+
export interface LinkChipsOptions {
|
|
27
|
+
/**
|
|
28
|
+
* The `nostr:` reference an app URL names, from what the app has resolved
|
|
29
|
+
* (§7.7) — protocol's `referenceOfLink(url, resolveLink)`. Undefined while it
|
|
30
|
+
* names nothing, or its answer has not come back.
|
|
31
|
+
*/
|
|
32
|
+
referenceOf: ((url: string) => string | undefined) | null
|
|
33
|
+
/** Look up what these URLs name. When it settles, the document is looked at again. */
|
|
34
|
+
ask: ((urls: string[]) => Promise<unknown> | void) | null
|
|
35
|
+
/**
|
|
36
|
+
* How many URLs one editor asks about in its life, the first ones; the rest
|
|
37
|
+
* stay text. An app's answer can cost a relay query, and a document pasted
|
|
38
|
+
* full of links must not spend the writer's quota. Default 24.
|
|
39
|
+
*/
|
|
40
|
+
limit: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LinkChipsState {
|
|
44
|
+
options: LinkChipsOptions
|
|
45
|
+
/** The last few pastes, mapped through every edit since. */
|
|
46
|
+
pasted: PastedRange[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const key = new PluginKey<LinkChipsState>('linkChips')
|
|
50
|
+
|
|
51
|
+
/** The meta on the transaction that made chips: `{ pasted }`. */
|
|
52
|
+
export const CHIPS_MADE = 'linkChipsMade'
|
|
53
|
+
|
|
54
|
+
/** How many pastes are remembered; one is what the person just did. */
|
|
55
|
+
const PASTES_KEPT = 8
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A link on words in marker text (skipped), or a bare URL — the grammar of
|
|
59
|
+
* protocol's `urlRunsIn`, which a reader turns into chips by, so what an editor
|
|
60
|
+
* makes a chip is what a reader would.
|
|
61
|
+
*/
|
|
62
|
+
const LINK_OR_URL = /(\[[^[\]\n]+\]\((?:https?:\/\/|mailto:)[^\s)]+\))|(https?:\/\/[^\s<>"'`]+)/g
|
|
63
|
+
const TRAILING = new Set(['.', ',', ';', ':', '!', '?', ')', ']'])
|
|
64
|
+
|
|
65
|
+
/** Each URL and where it sits; `end` is where its run ends before trailing punctuation is trimmed, which typing may yet continue. */
|
|
66
|
+
function urlRuns(text: string): { url: string; from: number; to: number; end: number }[] {
|
|
67
|
+
if (!text.includes('://')) return []
|
|
68
|
+
const out: { url: string; from: number; to: number; end: number }[] = []
|
|
69
|
+
for (const m of text.matchAll(LINK_OR_URL)) {
|
|
70
|
+
if (m[1]) continue
|
|
71
|
+
const start = m.index
|
|
72
|
+
if (text.slice(Math.max(0, start - 2), start) === '](' || text[start - 1] === '[') continue
|
|
73
|
+
let end = m[0].length
|
|
74
|
+
while (end > 0 && TRAILING.has(m[0][end - 1])) end--
|
|
75
|
+
out.push({ url: m[0].slice(0, end), from: start, to: start + end, end: start + m[0].length })
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Found {
|
|
81
|
+
/** Text URLs that name something: made chips. */
|
|
82
|
+
chips: { from: number; to: number; url: string; uri: string; marks: string[]; pasted: boolean }[]
|
|
83
|
+
/** URL chips whose URL is answered now: given their reference. */
|
|
84
|
+
fills: { pos: number; uri: string }[]
|
|
85
|
+
/** Every URL still waiting for an answer, or naming nothing. */
|
|
86
|
+
open: string[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Where something was pasted: a URL wholly inside one was not typed, so the caret at its end does not hold it as text. */
|
|
90
|
+
export interface PastedRange {
|
|
91
|
+
from: number
|
|
92
|
+
to: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What to change in a document, and what to ask. `selection` null treats no
|
|
97
|
+
* URL as being typed; a URL inside one of `pasted` is never being typed.
|
|
98
|
+
*/
|
|
99
|
+
export function findLinkChips(doc: PmNode, selection: Selection | null, referenceOf: LinkChipsOptions['referenceOf'], pasted: readonly PastedRange[] = []): Found {
|
|
100
|
+
const found: Found = { chips: [], fills: [], open: [] }
|
|
101
|
+
const open = new Set<string>()
|
|
102
|
+
doc.descendants((node, pos) => {
|
|
103
|
+
// A fence is literal (§13.1): its URLs are text.
|
|
104
|
+
if (node.type.spec.code) return false
|
|
105
|
+
if (node.type.name === 'reference') {
|
|
106
|
+
const href = typeof node.attrs.href === 'string' ? node.attrs.href : ''
|
|
107
|
+
if (href && !node.attrs.uri) {
|
|
108
|
+
const uri = referenceOf?.(href)
|
|
109
|
+
if (uri) found.fills.push({ pos, uri })
|
|
110
|
+
else open.add(href)
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
if (!node.isText || !node.text) return true
|
|
115
|
+
// A URL in code is literal.
|
|
116
|
+
if (node.marks.some((m) => m.type.spec.code || m.type.name === 'code')) return false
|
|
117
|
+
// A link on words is the link's. A link whose text is its own URL — an editor's autolink, or its paste rule — is
|
|
118
|
+
// that URL, as a document reads one (protocol's `inlineToEditor`): its chip, with the link mark left behind.
|
|
119
|
+
const link = node.marks.find((m) => m.type.name === 'link')
|
|
120
|
+
if (link && !(link.attrs.href === node.text && /^https?:\/\//.test(node.text))) return false
|
|
121
|
+
const runs = link ? [{ url: node.text, from: 0, to: node.text.length, end: node.text.length }] : urlRuns(node.text)
|
|
122
|
+
for (const run of runs) {
|
|
123
|
+
const from = pos + run.from
|
|
124
|
+
const to = pos + run.to
|
|
125
|
+
// Being typed or selected: the caret inside it or at its end (`https://a.` is still being typed), or a
|
|
126
|
+
// selection over any of it. A caret before it is not.
|
|
127
|
+
// A pasted URL is whole: it becomes its chip with the caret still at its end, so its menu can open at once.
|
|
128
|
+
const wasPasted = pasted.some((range) => range.from <= from && pos + run.end <= range.to)
|
|
129
|
+
const typing = selection && selection.to > from && selection.from <= pos + run.end
|
|
130
|
+
if (typing && !(selection.empty && wasPasted)) continue
|
|
131
|
+
const uri = referenceOf?.(run.url)
|
|
132
|
+
if (uri) found.chips.push({ from, to, url: run.url, uri, marks: node.marks.filter((m) => m !== link).map((m) => m.type.name), pasted: wasPasted })
|
|
133
|
+
else open.add(run.url)
|
|
134
|
+
}
|
|
135
|
+
return false
|
|
136
|
+
})
|
|
137
|
+
found.open = [...open]
|
|
138
|
+
return found
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function apply(state: EditorState, found: Found): Transaction | null {
|
|
142
|
+
if (!found.chips.length && !found.fills.length) return null
|
|
143
|
+
const type = state.schema.nodes.reference
|
|
144
|
+
if (!type) return null
|
|
145
|
+
const tr = state.tr
|
|
146
|
+
// A fill keeps the node's size, so the chips' positions still hold; the chips go last first.
|
|
147
|
+
for (const { pos, uri } of found.fills) {
|
|
148
|
+
const node = tr.doc.nodeAt(pos)
|
|
149
|
+
if (node) tr.setNodeMarkup(pos, undefined, { ...node.attrs, uri })
|
|
150
|
+
}
|
|
151
|
+
for (const chip of [...found.chips].reverse()) {
|
|
152
|
+
tr.replaceWith(chip.from, chip.to, type.create({ uri: chip.uri, href: chip.url, marks: chip.marks }))
|
|
153
|
+
}
|
|
154
|
+
// Whether a pasted URL became its chip: `ReferenceMenu` opens on that one, never on a URL typed by hand.
|
|
155
|
+
const pasted = found.chips.some((chip) => chip.pasted)
|
|
156
|
+
tr.setMeta(CHIPS_MADE, { pasted })
|
|
157
|
+
// A pasted URL's chip is its own undo step, so Ctrl+Z gives the URL back rather than taking the paste away (OPT-19,
|
|
158
|
+
// as Peek's pasted link did). Any other chip is not a step of the person's: undo takes back what they typed.
|
|
159
|
+
return pasted ? closeHistory(tr) : tr.setMeta('addToHistory', false)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Look at the document again — after the app learned what more URLs name some
|
|
164
|
+
* other way. Dispatches only when there is a chip to make: an empty
|
|
165
|
+
* transaction would wake every other plugin (a trailing paragraph appears).
|
|
166
|
+
*/
|
|
167
|
+
export function refreshLinkChips(editor: Editor): void {
|
|
168
|
+
if (editor.isDestroyed) return
|
|
169
|
+
const chips = key.getState(editor.state)
|
|
170
|
+
if (!chips) return
|
|
171
|
+
const tr = apply(editor.state, findLinkChips(editor.state.doc, editor.state.selection, chips.options.referenceOf, chips.pasted))
|
|
172
|
+
if (tr) editor.view.dispatch(tr)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Turns the app URLs in a document into URL chips. Pair it with `ReferenceNode`;
|
|
177
|
+
* `ReferenceCards` then draws a card a URL chip asks for.
|
|
178
|
+
*/
|
|
179
|
+
export const LinkChips = Extension.create<LinkChipsOptions>({
|
|
180
|
+
name: 'linkChips',
|
|
181
|
+
addOptions() {
|
|
182
|
+
return { referenceOf: null, ask: null, limit: 24 }
|
|
183
|
+
},
|
|
184
|
+
addProseMirrorPlugins() {
|
|
185
|
+
const options = this.options
|
|
186
|
+
const editor = this.editor
|
|
187
|
+
return [
|
|
188
|
+
new Plugin<LinkChipsState>({
|
|
189
|
+
key,
|
|
190
|
+
// The options, where `refreshLinkChips` can read them from the state alone, and where the last pastes landed.
|
|
191
|
+
state: {
|
|
192
|
+
init: () => ({ options, pasted: [] }),
|
|
193
|
+
apply: (tr, value, before, after) => {
|
|
194
|
+
if (!tr.docChanged) return value
|
|
195
|
+
// A paste edited inside is being typed again: forgotten, so each keystroke in it is not an ask (security review).
|
|
196
|
+
const pasted = value.pasted
|
|
197
|
+
.map((range) => ({ from: tr.mapping.map(range.from, 1), to: tr.mapping.map(range.to, -1), size: range.to - range.from }))
|
|
198
|
+
.filter((range) => range.from < range.to && range.to - range.from === range.size)
|
|
199
|
+
.map(({ from, to }) => ({ from, to }))
|
|
200
|
+
if (tr.getMeta('uiEvent') === 'paste') {
|
|
201
|
+
const from = tr.mapping.map(before.selection.from, -1)
|
|
202
|
+
if (from < after.selection.to) pasted.push({ from, to: after.selection.to })
|
|
203
|
+
}
|
|
204
|
+
return { options, pasted: pasted.slice(-PASTES_KEPT) }
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
appendTransaction: (trs, _old, state) => {
|
|
208
|
+
if (!trs.some((tr) => tr.docChanged || tr.selectionSet)) return null
|
|
209
|
+
return apply(state, findLinkChips(state.doc, state.selection, options.referenceOf, key.getState(state)?.pasted))
|
|
210
|
+
},
|
|
211
|
+
view: (view) => {
|
|
212
|
+
// Each URL is asked once; one whose ask failed is forgotten, so a later look asks again.
|
|
213
|
+
const asked = new Set<string>()
|
|
214
|
+
// The URL being typed is not asked about: each prefix of it would spend one of `limit`, and a query.
|
|
215
|
+
const look = (state: EditorState) => {
|
|
216
|
+
if (!options.ask) return
|
|
217
|
+
const { open } = findLinkChips(state.doc, state.selection, options.referenceOf, key.getState(state)?.pasted)
|
|
218
|
+
const fresh = open.filter((url) => !asked.has(url)).slice(0, Math.max(0, options.limit - asked.size))
|
|
219
|
+
if (!fresh.length) return
|
|
220
|
+
for (const url of fresh) asked.add(url)
|
|
221
|
+
const forget = () => {
|
|
222
|
+
for (const url of fresh) asked.delete(url)
|
|
223
|
+
}
|
|
224
|
+
let answer: Promise<unknown> | void
|
|
225
|
+
try {
|
|
226
|
+
answer = options.ask(fresh)
|
|
227
|
+
} catch {
|
|
228
|
+
forget()
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
void Promise.resolve(answer).then(() => refreshLinkChips(editor), forget)
|
|
232
|
+
}
|
|
233
|
+
look(view.state)
|
|
234
|
+
// Once the view is up, so a dispatch lands: what the app already knows turns into chips on open.
|
|
235
|
+
queueMicrotask(() => {
|
|
236
|
+
try {
|
|
237
|
+
refreshLinkChips(editor)
|
|
238
|
+
} catch {
|
|
239
|
+
// Unmounted before it ran.
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
return {
|
|
243
|
+
update: (next, prev) => {
|
|
244
|
+
// A caret leaving a URL finishes it, so a move is looked at as an edit is.
|
|
245
|
+
if (next.state.doc !== prev.doc || !next.state.selection.eq(prev.selection)) look(next.state)
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
]
|
|
251
|
+
},
|
|
252
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ReferenceCardsStories from './ReferenceCards.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ReferenceCardsStories} />
|
|
5
|
+
|
|
6
|
+
# ReferenceCards
|
|
7
|
+
|
|
8
|
+
A chip in a document can ask for a card after its line: compact, detailed or
|
|
9
|
+
none. The choice is the chip's own `embed`, so the card moves and goes with it.
|
|
10
|
+
This draws no card itself. It places a slot after each line that asks for
|
|
11
|
+
one, and the app draws its own card into it.
|
|
12
|
+
|
|
13
|
+
<Canvas of={ReferenceCardsStories.InAnEditor} />
|
|
14
|
+
|
|
15
|
+
## When
|
|
16
|
+
|
|
17
|
+
- **An editor of a document whose chips can carry a card**: pair it with
|
|
18
|
+
**ReferenceNode**, which holds the choice, and **SelectionToolbar**, where
|
|
19
|
+
the app offers it for the selected chip.
|
|
20
|
+
|
|
21
|
+
## When not
|
|
22
|
+
|
|
23
|
+
- A document that is only read → **RichText**, with each block's cards drawn
|
|
24
|
+
after it from the text's own description of them.
|
|
25
|
+
- A text whose cards travel beside it rather than on its chips → the app keeps
|
|
26
|
+
that choice itself, and this has nothing to read.
|
|
27
|
+
|
|
28
|
+
## How
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { createPortal } from 'react-dom'
|
|
32
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
33
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
34
|
+
import { ToolbarButton } from '@estiva-app/ui'
|
|
35
|
+
import {
|
|
36
|
+
BlockId,
|
|
37
|
+
ReferenceCards,
|
|
38
|
+
ReferenceNode,
|
|
39
|
+
SelectionToolbar,
|
|
40
|
+
referenceAtSelection,
|
|
41
|
+
setReferenceCard,
|
|
42
|
+
useReferenceCards,
|
|
43
|
+
type ReferenceCard,
|
|
44
|
+
type ReferenceViewProps,
|
|
45
|
+
} from '@estiva-app/ui/editor'
|
|
46
|
+
|
|
47
|
+
const Reference = ({ uri }: ReferenceViewProps) => <span>{uri}</span>
|
|
48
|
+
const Cards = ({ cards }: { cards: ReferenceCard[] }) => <div>{cards.map((card) => card.layout).join(', ')}</div>
|
|
49
|
+
|
|
50
|
+
const editor = useEditor({ extensions: [StarterKit, BlockId, ReferenceNode.configure({ view: Reference }), ReferenceCards] })
|
|
51
|
+
const slots = useReferenceCards(editor)
|
|
52
|
+
const chip = editor ? referenceAtSelection(editor.state) : null
|
|
53
|
+
|
|
54
|
+
;<>
|
|
55
|
+
<EditorContent editor={editor} />
|
|
56
|
+
{slots.map((slot) => createPortal(<Cards cards={slot.cards} />, slot.element, slot.key))}
|
|
57
|
+
<SelectionToolbar editor={editor}>
|
|
58
|
+
{chip && editor && (
|
|
59
|
+
<ToolbarButton aria-label="Detailed" pressed={chip.layout === 'detailed'} onMouseDown={(e) => e.preventDefault()} onClick={() => setReferenceCard(editor, chip.layout === 'detailed' ? 'none' : 'detailed')}>
|
|
60
|
+
D
|
|
61
|
+
</ToolbarButton>
|
|
62
|
+
)}
|
|
63
|
+
</SelectionToolbar>
|
|
64
|
+
</>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **A slot keeps its element while its line lives.** It is keyed by the
|
|
68
|
+
line's block id, so typing above a card does not make it load again.
|
|
69
|
+
- **The slot is not text.** It cannot be typed into or selected, and a click
|
|
70
|
+
on a card stays the card's.
|
|
71
|
+
- **A layout this version does not know draws as compact.** The writer asked
|
|
72
|
+
for a card, and the smallest is the safe reading.
|
|
73
|
+
- **Press without taking the focus.** A button in the toolbar prevents its
|
|
74
|
+
press's default, or the editor loses the chip's selection and the toolbar
|
|
75
|
+
goes.
|
|
76
|
+
|
|
77
|
+
## What it owns
|
|
78
|
+
|
|
79
|
+
Nothing. It only draws. Clicks and keys are yours.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
// @estiva-escape(no-hand-rolled-behaviour): the slots ReferenceCards places inside the document are drawn into by a portal, which keeps the app's providers around its card
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
4
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
5
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
6
|
+
import { BlockId, ReferenceNode } from './BlockNodes'
|
|
7
|
+
import { Card } from './Card'
|
|
8
|
+
import { ReferenceCards, useReferenceCards } from './ReferenceCards'
|
|
9
|
+
import { richTextClassName } from './RichText'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A chip that asks for a card gets a slot after its line, and the app draws
|
|
13
|
+
* its card there. Here the "card" is a stand-in box naming the layout: the real
|
|
14
|
+
* one is the app's, which knows how to resolve what the chip points at.
|
|
15
|
+
*/
|
|
16
|
+
const meta = {
|
|
17
|
+
title: 'Primitives/ReferenceCards',
|
|
18
|
+
parameters: { layout: 'padded' },
|
|
19
|
+
} satisfies Meta
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj
|
|
23
|
+
|
|
24
|
+
const ref = (uri: string, embed?: string) => ({ type: 'reference', attrs: { uri, ...(embed ? { embed } : {}) } })
|
|
25
|
+
|
|
26
|
+
function Box() {
|
|
27
|
+
const editor = useEditor({
|
|
28
|
+
extensions: [StarterKit, BlockId, ReferenceNode, ReferenceCards],
|
|
29
|
+
content: {
|
|
30
|
+
type: 'doc',
|
|
31
|
+
content: [
|
|
32
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'Blocked by ' }, ref('nostr:naddr1first', 'detailed'), { type: 'text', text: ' until it ships.' }] },
|
|
33
|
+
{ type: 'paragraph', attrs: { blockId: 'p2' }, content: [{ type: 'text', text: 'See also ' }, ref('nostr:naddr1second')] },
|
|
34
|
+
{ type: 'paragraph', attrs: { blockId: 'p3' }, content: [ref('nostr:naddr1third', 'compact')] },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
38
|
+
})
|
|
39
|
+
const slots = useReferenceCards(editor)
|
|
40
|
+
// @estiva-escape(no-hand-rolled-behaviour): the app's card goes into the slot ReferenceCards placed after its line, inside the document; no Base UI part owns a place in an editor's text
|
|
41
|
+
return (
|
|
42
|
+
<Card fill="surface" className="flex w-[520px] flex-col gap-2 p-3">
|
|
43
|
+
<EditorContent editor={editor} />
|
|
44
|
+
{slots.map((slot) =>
|
|
45
|
+
createPortal(
|
|
46
|
+
<div className="mt-2 flex flex-col gap-2">
|
|
47
|
+
{slot.cards.map((card, i) => (
|
|
48
|
+
<Card key={`${card.uri}-${i}`} fill="surface" className="p-3">
|
|
49
|
+
<span className="text-body-2 text-text-secondary">
|
|
50
|
+
{card.layout} card for {card.uri}
|
|
51
|
+
</span>
|
|
52
|
+
</Card>
|
|
53
|
+
))}
|
|
54
|
+
</div>,
|
|
55
|
+
slot.element,
|
|
56
|
+
slot.key,
|
|
57
|
+
),
|
|
58
|
+
)}
|
|
59
|
+
</Card>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Three chips: a detailed card after the first line, none for the second, a compact one after the third. */
|
|
64
|
+
export const InAnEditor: Story = { render: () => <Box /> }
|