@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.
Files changed (41) hide show
  1. package/dist/BlockNodes.d.ts +12 -1
  2. package/dist/BlockNodes.d.ts.map +1 -1
  3. package/dist/LinkChips.d.ts +82 -0
  4. package/dist/LinkChips.d.ts.map +1 -0
  5. package/dist/ReferenceCards.d.ts +77 -0
  6. package/dist/ReferenceCards.d.ts.map +1 -0
  7. package/dist/ReferenceMenu.d.ts +18 -0
  8. package/dist/ReferenceMenu.d.ts.map +1 -0
  9. package/dist/RichText.d.ts +9 -2
  10. package/dist/RichText.d.ts.map +1 -1
  11. package/dist/SelectionToolbar.d.ts +9 -1
  12. package/dist/SelectionToolbar.d.ts.map +1 -1
  13. package/dist/editor.d.ts +3 -0
  14. package/dist/editor.d.ts.map +1 -1
  15. package/dist/editor.js +595 -20
  16. package/dist/editor.js.map +4 -4
  17. package/dist/index.js +39 -19
  18. package/dist/index.js.map +4 -4
  19. package/package.json +1 -1
  20. package/registry.json +290 -3
  21. package/src/BlockNodes.test.tsx +2 -1
  22. package/src/BlockNodes.tsx +51 -9
  23. package/src/LinkChips.mdx +59 -0
  24. package/src/LinkChips.stories.tsx +67 -0
  25. package/src/LinkChips.test.tsx +221 -0
  26. package/src/LinkChips.ts +252 -0
  27. package/src/ReferenceCards.mdx +79 -0
  28. package/src/ReferenceCards.stories.tsx +64 -0
  29. package/src/ReferenceCards.test.tsx +143 -0
  30. package/src/ReferenceCards.ts +225 -0
  31. package/src/ReferenceMenu.mdx +75 -0
  32. package/src/ReferenceMenu.stories.tsx +59 -0
  33. package/src/ReferenceMenu.test.tsx +289 -0
  34. package/src/ReferenceMenu.tsx +346 -0
  35. package/src/RichText.test.tsx +17 -0
  36. package/src/RichText.tsx +34 -3
  37. package/src/SelectionToolbar.tsx +10 -2
  38. package/src/editor.ts +13 -0
  39. package/src/pages.test.ts +3 -0
  40. package/src/registry/registry.test.ts +6 -6
  41. 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 /> }