@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,143 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A reference's card in a block document (SPEC §13.3): the choice is `embed` on
|
|
4
|
+
* the `reference` node, kept through `getJSON`, and the slot it asks for sits
|
|
5
|
+
* after its line and keeps its element while the line lives.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { act, cleanup, render } from '@testing-library/react'
|
|
9
|
+
import { createPortal } from 'react-dom'
|
|
10
|
+
import { Editor } from '@tiptap/core'
|
|
11
|
+
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
|
|
12
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
13
|
+
import { BlockId, ReferenceNode } from './BlockNodes'
|
|
14
|
+
import { ReferenceCards, referenceAtSelection, referenceCardPlacements, setReferenceCard, useReferenceCards } from './ReferenceCards'
|
|
15
|
+
|
|
16
|
+
const EXTENSIONS = [StarterKit, BlockId, ReferenceNode, ReferenceCards]
|
|
17
|
+
const V = 'nostr:naddr1example'
|
|
18
|
+
const W = 'nostr:naddr1other'
|
|
19
|
+
|
|
20
|
+
type Json = { type: string; attrs?: Record<string, unknown>; content?: Json[] }
|
|
21
|
+
const json = (ed: Editor) => ed.getJSON() as Json
|
|
22
|
+
|
|
23
|
+
const ref = (uri: string, attrs: Record<string, unknown> = {}) => ({ type: 'reference', attrs: { uri, ...attrs } })
|
|
24
|
+
const DOCUMENT = {
|
|
25
|
+
type: 'doc',
|
|
26
|
+
content: [
|
|
27
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'Blocked by ' }, ref(V, { embed: 'detailed' }), { type: 'text', text: ' for now' }] },
|
|
28
|
+
{ type: 'paragraph', attrs: { blockId: 'p2' }, content: [ref(W)] },
|
|
29
|
+
{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [{ type: 'listItem', attrs: { blockId: 'i1' }, content: [{ type: 'paragraph', content: [ref(W, { embed: 'huge' })] }] }] },
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let editor: Editor | undefined
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
cleanup()
|
|
36
|
+
editor?.destroy()
|
|
37
|
+
editor = undefined
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const select = (ed: Editor, uri: string) => {
|
|
41
|
+
let at = -1
|
|
42
|
+
ed.state.doc.descendants((node, pos) => {
|
|
43
|
+
if (at < 0 && node.type.name === 'reference' && node.attrs.uri === uri) at = pos
|
|
44
|
+
})
|
|
45
|
+
ed.view.dispatch(ed.state.tr.setSelection(NodeSelection.create(ed.state.doc, at)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('ReferenceNode keeps the card', () => {
|
|
49
|
+
it('holds embed and extra through getJSON', () => {
|
|
50
|
+
editor = new Editor({ extensions: EXTENSIONS, content: { type: 'doc', content: [{ type: 'paragraph', content: [ref(V, { embed: 'compact', extra: { later: 1 } })] }] } })
|
|
51
|
+
const node = json(editor).content?.[0].content?.[0]
|
|
52
|
+
expect(node?.attrs?.embed).toBe('compact')
|
|
53
|
+
expect(node?.attrs?.extra).toEqual({ later: 1 })
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('referenceCardPlacements', () => {
|
|
58
|
+
it('asks for a slot after each line whose chips chose a card, keyed by block id', () => {
|
|
59
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
60
|
+
const placements = referenceCardPlacements(editor.state.doc)
|
|
61
|
+
expect(placements.map((p) => [p.key, p.cards])).toEqual([
|
|
62
|
+
['b:p1', [{ uri: V, layout: 'detailed' }]],
|
|
63
|
+
// A list item's paragraph carries no id of its own: the item's is used. An unknown layout is compact.
|
|
64
|
+
['b:i1', [{ uri: W, layout: 'compact' }]],
|
|
65
|
+
])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('draws an empty, non-editable slot inside the line', () => {
|
|
69
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
70
|
+
const slot = editor.view.dom.querySelector('[data-reference-cards="b:p1"]')
|
|
71
|
+
expect(slot?.getAttribute('contenteditable')).toBe('false')
|
|
72
|
+
expect(slot?.closest('p')?.textContent).toContain('for now')
|
|
73
|
+
expect(editor.view.dom.querySelectorAll('[data-reference-cards]')).toHaveLength(2)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('choosing a card', () => {
|
|
78
|
+
it('reads and sets the selected chip\'s card, and none removes it', () => {
|
|
79
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
80
|
+
select(editor, W)
|
|
81
|
+
expect(referenceAtSelection(editor.state)).toMatchObject({ uri: W, layout: 'none' })
|
|
82
|
+
|
|
83
|
+
expect(setReferenceCard(editor, 'detailed')).toBe(true)
|
|
84
|
+
expect(referenceAtSelection(editor.state)).toMatchObject({ uri: W, layout: 'detailed' })
|
|
85
|
+
expect(referenceCardPlacements(editor.state.doc).map((p) => p.key)).toEqual(['b:p1', 'b:p2', 'b:i1'])
|
|
86
|
+
|
|
87
|
+
setReferenceCard(editor, 'none')
|
|
88
|
+
expect(json(editor).content?.[1].content?.[0].attrs?.embed).toBeNull()
|
|
89
|
+
expect(referenceCardPlacements(editor.state.doc).map((p) => p.key)).toEqual(['b:p1', 'b:i1'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('finds the one chip in a selected range of spaces and that chip, and keeps the range', () => {
|
|
93
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
94
|
+
// The second paragraph is the chip alone: select across all of it, as Shift+arrow does.
|
|
95
|
+
let start = -1
|
|
96
|
+
editor.state.doc.forEach((node, offset, index) => {
|
|
97
|
+
if (index === 1) start = offset + 1
|
|
98
|
+
})
|
|
99
|
+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, start, start + 1)))
|
|
100
|
+
expect(referenceAtSelection(editor.state)).toMatchObject({ uri: W, layout: 'none' })
|
|
101
|
+
setReferenceCard(editor, 'compact')
|
|
102
|
+
expect(editor.state.selection.from).toBe(start)
|
|
103
|
+
expect(editor.state.selection.to).toBe(start + 1)
|
|
104
|
+
expect(referenceAtSelection(editor.state)).toMatchObject({ uri: W, layout: 'compact' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('offers nothing for a range with words in it, or with two chips', () => {
|
|
108
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
109
|
+
// "Blocked by " and the chip after it.
|
|
110
|
+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1, 13)))
|
|
111
|
+
expect(referenceAtSelection(editor.state)).toBeNull()
|
|
112
|
+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1, editor.state.doc.content.size - 1)))
|
|
113
|
+
expect(referenceAtSelection(editor.state)).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('offers nothing when no chip is selected', () => {
|
|
117
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
118
|
+
expect(referenceAtSelection(editor.state)).toBeNull()
|
|
119
|
+
expect(setReferenceCard(editor, 'compact')).toBe(false)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('useReferenceCards', () => {
|
|
124
|
+
function Cards({ ed }: { ed: Editor }) {
|
|
125
|
+
const slots = useReferenceCards(ed)
|
|
126
|
+
return <>{slots.map((s) => createPortal(<span data-card={s.cards.map((c) => c.layout).join(',')} />, s.element, s.key))}</>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it('portals the app\'s cards into the slots, and typing keeps the slot\'s element', () => {
|
|
130
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
131
|
+
const ed = editor
|
|
132
|
+
render(<Cards ed={ed} />)
|
|
133
|
+
const before = ed.view.dom.querySelector('[data-reference-cards="b:p1"]')
|
|
134
|
+
expect(before?.querySelector('[data-card="detailed"]')).not.toBeNull()
|
|
135
|
+
|
|
136
|
+
act(() => {
|
|
137
|
+
ed.commands.insertContentAt(1, 'Still ')
|
|
138
|
+
})
|
|
139
|
+
const after = ed.view.dom.querySelector('[data-reference-cards="b:p1"]')
|
|
140
|
+
expect(after).toBe(before)
|
|
141
|
+
expect(after?.querySelector('[data-card="detailed"]')).not.toBeNull()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cards a block document's writer chose, drawn after the line that holds
|
|
3
|
+
* each reference — SPEC §13.3, "A reference's card".
|
|
4
|
+
*
|
|
5
|
+
* The choice is `embed` on the `reference` node (`ReferenceNode`), so the card
|
|
6
|
+
* moves and goes with its chip. What a card looks like is the app's — it knows
|
|
7
|
+
* how to resolve an object and draw its row or its card — so this extension
|
|
8
|
+
* draws nothing itself: after every textblock whose chips ask for a card it
|
|
9
|
+
* places one empty, non-editable slot, and `useReferenceCards` hands the app
|
|
10
|
+
* those slots to portal its cards into. A portal, rather than a React root per
|
|
11
|
+
* slot, keeps the app's providers around the card.
|
|
12
|
+
*
|
|
13
|
+
* **A slot keeps its element while its block lives.** It is keyed by the
|
|
14
|
+
* nearest block id (the `blockId` attribute), so typing above a card does not
|
|
15
|
+
* re-make its element, and the card does not reload on every keystroke.
|
|
16
|
+
*
|
|
17
|
+
* The slot sits at the end of the textblock, inside it: a paragraph's card is
|
|
18
|
+
* under the paragraph, a list item's under the item at its indent, a table
|
|
19
|
+
* cell's in the cell.
|
|
20
|
+
*/
|
|
21
|
+
import { Extension, type Editor } from '@tiptap/core'
|
|
22
|
+
import type { Node as PmNode } from '@tiptap/pm/model'
|
|
23
|
+
import { NodeSelection, Plugin, PluginKey, type EditorState } from '@tiptap/pm/state'
|
|
24
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
25
|
+
import { useEffect, useState } from 'react'
|
|
26
|
+
|
|
27
|
+
/** A layout a writer can choose; `none` is no card. */
|
|
28
|
+
export type ReferenceCardLayout = 'none' | 'compact' | 'detailed'
|
|
29
|
+
|
|
30
|
+
/** One card to draw: the reference's `nostr:` URI and how much to show. */
|
|
31
|
+
export interface ReferenceCard {
|
|
32
|
+
uri: string
|
|
33
|
+
layout: Exclude<ReferenceCardLayout, 'none'>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One slot: where an app draws the cards of one line. */
|
|
37
|
+
export interface ReferenceCardSlot {
|
|
38
|
+
key: string
|
|
39
|
+
element: HTMLElement
|
|
40
|
+
cards: ReferenceCard[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Placement {
|
|
44
|
+
key: string
|
|
45
|
+
/** The end of the textblock's content, where the slot goes. */
|
|
46
|
+
pos: number
|
|
47
|
+
cards: ReferenceCard[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `embed` as a node holds it: an unknown string draws as compact (§13.3), anything else is none. */
|
|
51
|
+
function layoutOf(raw: unknown): ReferenceCard['layout'] | undefined {
|
|
52
|
+
if (typeof raw !== 'string' || raw === '' || raw === 'none') return undefined
|
|
53
|
+
return raw === 'detailed' ? 'detailed' : 'compact'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every line that asks for a card, in document order. */
|
|
57
|
+
export function referenceCardPlacements(doc: PmNode): Placement[] {
|
|
58
|
+
const out: Placement[] = []
|
|
59
|
+
const seen = new Map<string, number>()
|
|
60
|
+
doc.descendants((node, pos, parent) => {
|
|
61
|
+
if (!node.isTextblock) return true
|
|
62
|
+
const cards: ReferenceCard[] = []
|
|
63
|
+
node.forEach((child) => {
|
|
64
|
+
if (child.type.name !== 'reference') return
|
|
65
|
+
const layout = layoutOf(child.attrs.embed)
|
|
66
|
+
// A URL chip whose URL is not resolved yet (`LinkChips`) has nothing to draw a card of, yet.
|
|
67
|
+
if (layout && child.attrs.uri) cards.push({ uri: String(child.attrs.uri), layout })
|
|
68
|
+
})
|
|
69
|
+
if (cards.length) {
|
|
70
|
+
// The textblock's own id, or its list item's or cell's: the nearest one.
|
|
71
|
+
const id = node.attrs.blockId ?? parent?.attrs.blockId
|
|
72
|
+
const base = id ? `b:${String(id)}` : `p:${pos}`
|
|
73
|
+
const n = seen.get(base) ?? 0
|
|
74
|
+
seen.set(base, n + 1)
|
|
75
|
+
out.push({ key: n ? `${base}:${n}` : base, pos: pos + node.nodeSize - 1, cards })
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
})
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ReferenceCardsStorage {
|
|
83
|
+
/** One element per slot key, made once and reused, so the app's portal stays put. */
|
|
84
|
+
elements: Map<string, HTMLElement>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const key = new PluginKey<DecorationSet>('referenceCards')
|
|
88
|
+
|
|
89
|
+
function elementFor(storage: ReferenceCardsStorage, slotKey: string): HTMLElement {
|
|
90
|
+
let element = storage.elements.get(slotKey)
|
|
91
|
+
if (!element) {
|
|
92
|
+
element = document.createElement('div')
|
|
93
|
+
element.setAttribute('data-reference-cards', slotKey)
|
|
94
|
+
element.setAttribute('contenteditable', 'false')
|
|
95
|
+
storage.elements.set(slotKey, element)
|
|
96
|
+
}
|
|
97
|
+
return element
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function storageOf(editor: Editor): ReferenceCardsStorage | undefined {
|
|
101
|
+
return (editor.storage as unknown as { referenceCards?: ReferenceCardsStorage }).referenceCards
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Places a slot after each line whose chips ask for a card. Pair it with
|
|
106
|
+
* `ReferenceNode`, and draw the cards with `useReferenceCards`.
|
|
107
|
+
*/
|
|
108
|
+
export const ReferenceCards = Extension.create<Record<string, never>, ReferenceCardsStorage>({
|
|
109
|
+
name: 'referenceCards',
|
|
110
|
+
addStorage() {
|
|
111
|
+
return { elements: new Map() }
|
|
112
|
+
},
|
|
113
|
+
addProseMirrorPlugins() {
|
|
114
|
+
const storage = this.storage
|
|
115
|
+
const decorate = (doc: PmNode) =>
|
|
116
|
+
DecorationSet.create(
|
|
117
|
+
doc,
|
|
118
|
+
referenceCardPlacements(doc).map((p) =>
|
|
119
|
+
Decoration.widget(p.pos, () => elementFor(storage, p.key), {
|
|
120
|
+
key: p.key,
|
|
121
|
+
side: 1,
|
|
122
|
+
ignoreSelection: true,
|
|
123
|
+
// A click on a card is the card's, not a caret placement in the line.
|
|
124
|
+
stopEvent: () => true,
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
return [
|
|
129
|
+
new Plugin<DecorationSet>({
|
|
130
|
+
key,
|
|
131
|
+
state: {
|
|
132
|
+
init: (_, state) => decorate(state.doc),
|
|
133
|
+
apply: (tr, set) => (tr.docChanged ? decorate(tr.doc) : set),
|
|
134
|
+
},
|
|
135
|
+
props: { decorations: (state) => key.getState(state) },
|
|
136
|
+
}),
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The slots to draw cards into, kept current as the document changes. Draw
|
|
143
|
+
* each with `createPortal(<cards/>, slot.element)`, keyed by `slot.key`.
|
|
144
|
+
*/
|
|
145
|
+
export function useReferenceCards(editor: Editor | null): ReferenceCardSlot[] {
|
|
146
|
+
const [slots, setSlots] = useState<ReferenceCardSlot[]>([])
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!editor) return
|
|
149
|
+
const storage = storageOf(editor)
|
|
150
|
+
if (!storage) return
|
|
151
|
+
let last = ''
|
|
152
|
+
const read = () => {
|
|
153
|
+
if (editor.isDestroyed) return
|
|
154
|
+
const next = referenceCardPlacements(editor.state.doc).map((p) => ({ key: p.key, element: elementFor(storage, p.key), cards: p.cards }))
|
|
155
|
+
// Only a change in what is drawn re-renders the app; typing does not.
|
|
156
|
+
const signature = JSON.stringify(next.map((s) => [s.key, s.cards]))
|
|
157
|
+
if (signature === last) return
|
|
158
|
+
last = signature
|
|
159
|
+
const live = new Set(next.map((s) => s.key))
|
|
160
|
+
for (const k of storage.elements.keys()) if (!live.has(k)) storage.elements.delete(k)
|
|
161
|
+
setSlots(next)
|
|
162
|
+
}
|
|
163
|
+
read()
|
|
164
|
+
editor.on('transaction', read)
|
|
165
|
+
return () => {
|
|
166
|
+
editor.off('transaction', read)
|
|
167
|
+
}
|
|
168
|
+
}, [editor])
|
|
169
|
+
return slots
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The chip the person has selected, with what it names and its card — or null.
|
|
174
|
+
*
|
|
175
|
+
* Selected as a node (a click, where a click on the chip does not follow its
|
|
176
|
+
* link), or inside a selected range that holds that one chip and nothing but
|
|
177
|
+
* spaces besides (Shift+arrow over it, or a drag across it): an app whose chip
|
|
178
|
+
* is a link has no other way to select it.
|
|
179
|
+
*/
|
|
180
|
+
export function referenceAtSelection(state: EditorState): { pos: number; uri: string; layout: ReferenceCardLayout } | null {
|
|
181
|
+
const { selection } = state
|
|
182
|
+
let found: { pos: number; node: PmNode } | null = null
|
|
183
|
+
if (selection instanceof NodeSelection) {
|
|
184
|
+
if (selection.node.type.name === 'reference') found = { pos: selection.from, node: selection.node }
|
|
185
|
+
} else if (!selection.empty) {
|
|
186
|
+
let only = true
|
|
187
|
+
state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
|
|
188
|
+
if (node.type.name === 'reference') {
|
|
189
|
+
if (found) only = false
|
|
190
|
+
found = { pos, node }
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
if (node.isText) {
|
|
194
|
+
const from = Math.max(selection.from, pos) - pos
|
|
195
|
+
const to = Math.min(selection.to, pos + node.nodeSize) - pos
|
|
196
|
+
if ((node.text ?? '').slice(from, to).trim() !== '') only = false
|
|
197
|
+
}
|
|
198
|
+
return true
|
|
199
|
+
})
|
|
200
|
+
if (!only) found = null
|
|
201
|
+
}
|
|
202
|
+
if (!found) return null
|
|
203
|
+
const { pos, node } = found as { pos: number; node: PmNode }
|
|
204
|
+
// A URL chip that names nothing yet has no card to choose.
|
|
205
|
+
if (!node.attrs.uri) return null
|
|
206
|
+
return { pos, uri: String(node.attrs.uri ?? ''), layout: layoutOf(node.attrs.embed) ?? 'none' }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Sets the card of the selected chip. `none` removes it. The selection stays as it was. */
|
|
210
|
+
export function setReferenceCard(editor: Editor, layout: ReferenceCardLayout): boolean {
|
|
211
|
+
const at = referenceAtSelection(editor.state)
|
|
212
|
+
return at ? setReferenceCardAt(editor, at.pos, layout) : false
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Sets the card of the chip at `pos` — one that names something. `none` removes it. The selection stays as it was. */
|
|
216
|
+
export function setReferenceCardAt(editor: Editor, pos: number, layout: ReferenceCardLayout): boolean {
|
|
217
|
+
const node = editor.state.doc.nodeAt(pos)
|
|
218
|
+
if (!node || node.type.name !== 'reference' || !node.attrs.uri) return false
|
|
219
|
+
const wasNode = editor.state.selection instanceof NodeSelection && editor.state.selection.from === pos
|
|
220
|
+
// Same size, so a range maps onto itself; a node selection is made again, since the node is replaced.
|
|
221
|
+
const tr = editor.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, embed: layout === 'none' ? null : layout })
|
|
222
|
+
if (wasNode) tr.setSelection(NodeSelection.create(tr.doc, pos))
|
|
223
|
+
editor.view.dispatch(tr)
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ReferenceMenuStories from './ReferenceMenu.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ReferenceMenuStories} />
|
|
5
|
+
|
|
6
|
+
# ReferenceMenu
|
|
7
|
+
|
|
8
|
+
What a chip in a document offers whoever is writing it: **Open**, and its
|
|
9
|
+
**Card** — none, compact or detailed. The card draws after the chip's line.
|
|
10
|
+
|
|
11
|
+
<Canvas of={ReferenceMenuStories.InAnEditor} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **An editor of a document whose chips can ask for a card**: pair it with
|
|
16
|
+
**ReferenceNode**, which draws the chip, and **ReferenceCards**, which places
|
|
17
|
+
the card. With **LinkChips**, a pasted URL that becomes its chip opens the
|
|
18
|
+
menu too.
|
|
19
|
+
|
|
20
|
+
## When not
|
|
21
|
+
|
|
22
|
+
- A document that is only read → **RichText**: a click on a chip follows it.
|
|
23
|
+
- An editor whose chips take no card → leave it out, and a click on a chip
|
|
24
|
+
follows it as when reading.
|
|
25
|
+
|
|
26
|
+
## How
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
30
|
+
import { ReferenceCards, ReferenceMenu, ReferenceMenuView, ReferenceNode, isReferenceMenuOpen } from '@estiva-app/ui/editor'
|
|
31
|
+
|
|
32
|
+
const editor = useEditor({
|
|
33
|
+
extensions: [/* … */ ReferenceNode.configure({ view: Chip }), ReferenceCards, ReferenceMenu],
|
|
34
|
+
editorProps: {
|
|
35
|
+
handleKeyDown: (view, event) => {
|
|
36
|
+
// While the menu is open, Escape is its own: it closes the menu.
|
|
37
|
+
if (event.key === 'Escape' && isReferenceMenuOpen(view.state)) return false
|
|
38
|
+
// …
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
<EditorContent editor={editor} />
|
|
44
|
+
<ReferenceMenuView editor={editor} />
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **A click on a chip while editing** opens the menu under it, instead of
|
|
48
|
+
following the chip. Not editing, the click follows it.
|
|
49
|
+
- **A chip just put in** — pasted, picked with `[`, or a pasted URL becoming its
|
|
50
|
+
chip — opens the menu by itself. It takes no focus: typing on closes it, and
|
|
51
|
+
the chip keeps no card.
|
|
52
|
+
- **Open** does what a click on the chip does when reading: the app's chip gets
|
|
53
|
+
the click. A chip with nothing to click opens its URL in a new tab.
|
|
54
|
+
- Open is offered only when there is something to open: the chip's own link
|
|
55
|
+
or button, or its URL. A chip whose URL names nothing yet offers Open only;
|
|
56
|
+
one with neither has no menu, and a click goes on to it.
|
|
57
|
+
- The menu closes when the editor loses focus, so a card is never chosen after
|
|
58
|
+
the editor has saved on leaving.
|
|
59
|
+
- Choosing a card is an undo step; the menu closes.
|
|
60
|
+
|
|
61
|
+
## What it owns
|
|
62
|
+
|
|
63
|
+
Nothing. Its box is the Popover; it holds which chip the menu is open on, and its keys.
|
|
64
|
+
|
|
65
|
+
## Keys
|
|
66
|
+
|
|
67
|
+
| Key | Does |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| Enter, on a selected chip | Opens the menu, its first row highlighted |
|
|
70
|
+
| ↓ / ↑ | Moves through the rows; nothing is highlighted until then |
|
|
71
|
+
| Enter | Chooses the highlighted row; with none, the editor's own Enter |
|
|
72
|
+
| Escape | Closes the menu |
|
|
73
|
+
|
|
74
|
+
The panel takes no focus, so it is reached only through these keys. A key with
|
|
75
|
+
Shift, Ctrl, Alt or Cmd stays the editor's: Shift+↓ extends the selection.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
4
|
+
import { BlockId, ReferenceNode, type ReferenceViewProps } from './BlockNodes'
|
|
5
|
+
import { Card } from './Card'
|
|
6
|
+
import { InlineChip } from './InlineChip'
|
|
7
|
+
import { ReferenceCards, useReferenceCards } from './ReferenceCards'
|
|
8
|
+
import { ReferenceMenu, ReferenceMenuView } from './ReferenceMenu'
|
|
9
|
+
import { richTextClassName } from './RichText'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Click the chip: its menu opens under it — Open, and a card of none, compact
|
|
13
|
+
* or detailed. The chip is a placeholder naming what it points at, and the
|
|
14
|
+
* card a line naming its layout: the real ones are the app's.
|
|
15
|
+
*/
|
|
16
|
+
const meta = {
|
|
17
|
+
title: 'Primitives/ReferenceMenu',
|
|
18
|
+
parameters: { layout: 'padded' },
|
|
19
|
+
} satisfies Meta
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj
|
|
23
|
+
|
|
24
|
+
function Placeholder({ uri }: ReferenceViewProps) {
|
|
25
|
+
return <InlineChip>Item for {uri}</InlineChip>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Box() {
|
|
29
|
+
const editor = useEditor({
|
|
30
|
+
extensions: [StarterKit.configure({ link: false }), BlockId, ReferenceNode.configure({ view: Placeholder }), ReferenceCards, ReferenceMenu],
|
|
31
|
+
content: {
|
|
32
|
+
type: 'doc',
|
|
33
|
+
content: [
|
|
34
|
+
{
|
|
35
|
+
type: 'paragraph',
|
|
36
|
+
attrs: { blockId: 'p1' },
|
|
37
|
+
content: [
|
|
38
|
+
{ type: 'text', text: 'See ' },
|
|
39
|
+
{ type: 'reference', attrs: { uri: 'nostr:naddr1one' } },
|
|
40
|
+
{ type: 'text', text: ' for the details.' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
46
|
+
})
|
|
47
|
+
const slots = useReferenceCards(editor)
|
|
48
|
+
const cards = slots.flatMap((slot) => slot.cards.map((card) => `${card.layout} card for ${card.uri}`))
|
|
49
|
+
return (
|
|
50
|
+
<Card fill="surface" className="flex w-[520px] flex-col gap-2 p-3">
|
|
51
|
+
<EditorContent editor={editor} />
|
|
52
|
+
<ReferenceMenuView editor={editor} />
|
|
53
|
+
<p className="text-caption text-text-secondary">{cards.length ? cards.join(', ') : 'No card'}</p>
|
|
54
|
+
</Card>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A chip in a sentence: click it to open its menu, and choose a card. */
|
|
59
|
+
export const InAnEditor: Story = { render: () => <Box /> }
|