@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,255 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
import { TextSelection, type Command } from '@tiptap/pm/state'
|
|
4
|
+
import { addColumnAfter, addColumnBefore, addRowAfter, addRowBefore, deleteColumn, deleteRow, deleteTable, toggleHeader } from '@tiptap/pm/tables'
|
|
5
|
+
import { IconColumnInsertLeft, IconColumnInsertRight, IconGripHorizontal, IconGripVertical, IconPlus, IconRowInsertBottom, IconRowInsertTop, IconTrash } from '@tabler/icons-react'
|
|
6
|
+
import { cn } from './cn'
|
|
7
|
+
import { IconButton } from './IconButton'
|
|
8
|
+
import { FLOATING_SURFACE_CLASSES } from './looks'
|
|
9
|
+
import { Menu, MenuItem, MenuSeparator } from './Menu'
|
|
10
|
+
|
|
11
|
+
export interface EditorTableControlsProps {
|
|
12
|
+
/** The editor, with TableKit (`@tiptap/extension-table`) in its extensions. Not editable, or not there yet: no controls. */
|
|
13
|
+
editor: Editor | null
|
|
14
|
+
/** The `EditorContent`. */
|
|
15
|
+
children: ReactNode
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The cell the pointer is on, and the table and row it is in. */
|
|
20
|
+
interface Hovered {
|
|
21
|
+
table: HTMLTableElement
|
|
22
|
+
row: HTMLTableRowElement
|
|
23
|
+
cell: HTMLTableCellElement
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where the controls go, measured from the frame: they depend on the document, not on a size of ours. */
|
|
27
|
+
interface Placed {
|
|
28
|
+
tableBox: { top: number; left: number; width: number; height: number }
|
|
29
|
+
rowBox: { top: number; height: number }
|
|
30
|
+
columnBox: { left: number; width: number }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
Every row runs the table command with the caret in a cell, as the command
|
|
35
|
+
expects — the hovered cell's, or for a ➕ bar the last row's or the last
|
|
36
|
+
column's — and leaves the editor focused there, as the block menu does: the
|
|
37
|
+
person is still in the field, so an editor that saves when they leave it
|
|
38
|
+
saves this too.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Puts the caret in `cell` and runs `command` there. */
|
|
42
|
+
function inCell(editor: Editor, cell: HTMLTableCellElement, command: Command): boolean {
|
|
43
|
+
const { view } = editor
|
|
44
|
+
if (!view.dom.contains(cell)) return false
|
|
45
|
+
const at = view.posAtDOM(cell, 0)
|
|
46
|
+
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(at))))
|
|
47
|
+
const done = command(view.state, view.dispatch, view)
|
|
48
|
+
editor.commands.focus()
|
|
49
|
+
return done
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A row of header cells only: the table's header row. */
|
|
53
|
+
function isHeaderRow(row: HTMLTableRowElement): boolean {
|
|
54
|
+
return row.cells.length > 0 && Array.from(row.cells).every((c) => c.tagName === 'TH')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The cell's row, or the whole table when it is its only one: a table holds at
|
|
59
|
+
* least one row (prosemirror-tables refuses to delete it). Deleting the header
|
|
60
|
+
* row makes the next row the header, so the table still opens with one.
|
|
61
|
+
*/
|
|
62
|
+
export function deleteTableRow(editor: Editor, cell: HTMLTableCellElement): boolean {
|
|
63
|
+
const row = cell.closest('tr')
|
|
64
|
+
const table = cell.closest('table')
|
|
65
|
+
if (!row || !table || table.rows.length <= 1) return inCell(editor, cell, deleteTable)
|
|
66
|
+
const header = isHeaderRow(row)
|
|
67
|
+
return inCell(editor, cell, header ? (state, dispatch, view) => deleteRow(state, dispatch) && (!view || toggleHeader('row')(view.state, view.dispatch)) : deleteRow)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The same for a column: the last one deletes the table. */
|
|
71
|
+
export function deleteTableColumn(editor: Editor, cell: HTMLTableCellElement): boolean {
|
|
72
|
+
const row = cell.closest('tr')
|
|
73
|
+
return inCell(editor, cell, row && row.cells.length > 1 ? deleteColumn : deleteTable)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A row after the last one: the ➕ under the table. */
|
|
77
|
+
export function appendTableRow(editor: Editor, table: HTMLTableElement): boolean {
|
|
78
|
+
const last = table.rows[table.rows.length - 1]?.cells[0]
|
|
79
|
+
return !!last && inCell(editor, last, addRowAfter)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A column after the last one: the ➕ right of the table. */
|
|
83
|
+
export function appendTableColumn(editor: Editor, table: HTMLTableElement): boolean {
|
|
84
|
+
const first = table.rows[0]
|
|
85
|
+
const last = first?.cells[first.cells.length - 1]
|
|
86
|
+
return !!last && inCell(editor, last, addColumnAfter)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function place(frame: HTMLElement, { table, row, cell }: Hovered): Placed {
|
|
90
|
+
const at = frame.getBoundingClientRect()
|
|
91
|
+
const t = table.getBoundingClientRect()
|
|
92
|
+
const r = row.getBoundingClientRect()
|
|
93
|
+
const c = cell.getBoundingClientRect()
|
|
94
|
+
return {
|
|
95
|
+
tableBox: { top: t.top - at.top, left: t.left - at.left, width: t.width, height: t.height },
|
|
96
|
+
rowBox: { top: r.top - at.top, height: r.height },
|
|
97
|
+
columnBox: { left: c.left - at.left, width: c.width },
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ICON = { size: 16, stroke: 1.5, className: 'text-text-secondary' } as const
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A ➕ press keeps the focus where it is, as the selection toolbar's buttons do:
|
|
105
|
+
* the person never left the field, so an editor that saves when they leave it
|
|
106
|
+
* does not save half-way, and History gets one line for the edit, not one per
|
|
107
|
+
* press. (A handle's menu takes the focus, as every menu does.)
|
|
108
|
+
*/
|
|
109
|
+
const keepFocus = (e: MouseEvent) => e.preventDefault()
|
|
110
|
+
|
|
111
|
+
/** What a handle and a ➕ bar sit in: the elevated surface's border and fill, opaque over the table's lines, with no shadow at this size. */
|
|
112
|
+
const HANDLE_BOX = cn(FLOATING_SURFACE_CLASSES, 'shadow-none')
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A table's own controls in an editor, as in Notion: point at a cell and a
|
|
116
|
+
* handle sits on its row's left edge and on its column's top edge; press one
|
|
117
|
+
* for Insert above and below (left and right), Delete, and Delete table. On
|
|
118
|
+
* the last row a ➕ bar under the table adds a row, and on the last column one
|
|
119
|
+
* right of it adds a column. Tab in
|
|
120
|
+
* the last cell adds a row too — that is TableKit's own.
|
|
121
|
+
*
|
|
122
|
+
* Deleting a table's only row or only column deletes the table, which cannot
|
|
123
|
+
* be left with none.
|
|
124
|
+
*
|
|
125
|
+
* Built on the editor library's own table commands; the controls are ours.
|
|
126
|
+
* Wrap the `EditorContent` in it, inside an `EditorBlockHandle` if there is
|
|
127
|
+
* one: the block handle stays the table's, in the gutter.
|
|
128
|
+
*/
|
|
129
|
+
export function EditorTableControls({ editor, children, className }: EditorTableControlsProps) {
|
|
130
|
+
const frame = useRef<HTMLDivElement>(null)
|
|
131
|
+
// The cell pointed at and where its controls go, as one: the boxes mean nothing without their elements.
|
|
132
|
+
const [hovered, setHovered] = useState<(Hovered & Placed) | null>(null)
|
|
133
|
+
// A menu that is up keeps its row and column, wherever the pointer goes.
|
|
134
|
+
const [menu, setMenu] = useState<'row' | 'column' | null>(null)
|
|
135
|
+
const editable = !!editor && editor.isEditable
|
|
136
|
+
|
|
137
|
+
const follow = useCallback(
|
|
138
|
+
(target: EventTarget | null) => {
|
|
139
|
+
if (!editor || !frame.current || !(target instanceof Element)) return
|
|
140
|
+
// On a control of ours: keep what it was drawn for.
|
|
141
|
+
if (target.closest('[data-table-control]')) return
|
|
142
|
+
const cell = target.closest<HTMLTableCellElement>('td, th')
|
|
143
|
+
const row = cell?.closest('tr')
|
|
144
|
+
const table = row?.closest('table')
|
|
145
|
+
if (!cell || !row || !table || !editor.view.dom.contains(table)) {
|
|
146
|
+
setHovered(null)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
// Measured on every move, the same cell too: a panel resized or a cell's text wrapping moves the edges under a still pointer.
|
|
150
|
+
const next = { table, row, cell, ...place(frame.current, { table, row, cell }) }
|
|
151
|
+
setHovered((was) => (was && was.cell === cell && JSON.stringify([was.tableBox, was.rowBox, was.columnBox]) === JSON.stringify([next.tableBox, next.rowBox, next.columnBox]) ? was : next))
|
|
152
|
+
},
|
|
153
|
+
[editor],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
Any change to the document moves everything under the controls, so they go
|
|
158
|
+
until the pointer moves again: a row added or deleted here, and someone
|
|
159
|
+
else's edit loaded into the field — which an app does without an `update`
|
|
160
|
+
(`emitUpdate: false`), and which redraws the cells the controls point at.
|
|
161
|
+
*/
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!editor) return
|
|
164
|
+
const changed = ({ transaction }: { transaction: { docChanged: boolean } }) => {
|
|
165
|
+
if (transaction.docChanged && !menu) setHovered(null)
|
|
166
|
+
}
|
|
167
|
+
editor.on('transaction', changed)
|
|
168
|
+
return () => {
|
|
169
|
+
editor.off('transaction', changed)
|
|
170
|
+
}
|
|
171
|
+
}, [editor, menu])
|
|
172
|
+
|
|
173
|
+
const shown = editable ? hovered : null
|
|
174
|
+
const run = (act: () => boolean) => () => {
|
|
175
|
+
if (editor) act()
|
|
176
|
+
setMenu(null)
|
|
177
|
+
setHovered(null)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
ref={frame}
|
|
183
|
+
className={cn('relative', className)}
|
|
184
|
+
onMouseMove={(e) => {
|
|
185
|
+
if (!menu) follow(e.target)
|
|
186
|
+
}}
|
|
187
|
+
onMouseLeave={() => {
|
|
188
|
+
if (!menu) setHovered(null)
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
{children}
|
|
192
|
+
{shown && editor && (
|
|
193
|
+
<>
|
|
194
|
+
{/* The row's handle, centred on the table's left edge: the box is ours, so the table's line does not show through it. */}
|
|
195
|
+
<div data-table-control className={cn(HANDLE_BOX, 'absolute -translate-x-1/2 -translate-y-1/2')} style={{ left: shown.tableBox.left, top: shown.rowBox.top + shown.rowBox.height / 2 }}>
|
|
196
|
+
<Menu
|
|
197
|
+
open={menu === 'row'}
|
|
198
|
+
onOpenChange={(open) => setMenu(open ? 'row' : null)}
|
|
199
|
+
trigger={
|
|
200
|
+
<IconButton aria-label="Row menu" tooltip="Row" className="h-6 w-4 p-0">
|
|
201
|
+
<IconGripVertical size={16} stroke={1.5} />
|
|
202
|
+
</IconButton>
|
|
203
|
+
}
|
|
204
|
+
>
|
|
205
|
+
{/* Nothing goes above a header row: it is the table's first row, and a header anywhere else is no header (RichText, the agent's text). */}
|
|
206
|
+
{!isHeaderRow(shown.row) && <MenuItem label="Insert above" leading={<IconRowInsertTop {...ICON} />} onClick={run(() => inCell(editor, shown.cell, addRowBefore))} />}
|
|
207
|
+
<MenuItem label="Insert below" leading={<IconRowInsertBottom {...ICON} />} onClick={run(() => inCell(editor, shown.cell, addRowAfter))} />
|
|
208
|
+
<MenuSeparator />
|
|
209
|
+
<MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={run(() => deleteTableRow(editor, shown.cell))} />
|
|
210
|
+
<MenuItem label="Delete table" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={run(() => inCell(editor, shown.cell, deleteTable))} />
|
|
211
|
+
</Menu>
|
|
212
|
+
</div>
|
|
213
|
+
{/* The column's handle, centred on the table's top edge. */}
|
|
214
|
+
<div data-table-control className={cn(HANDLE_BOX, 'absolute -translate-x-1/2 -translate-y-1/2')} style={{ left: shown.columnBox.left + shown.columnBox.width / 2, top: shown.tableBox.top }}>
|
|
215
|
+
<Menu
|
|
216
|
+
open={menu === 'column'}
|
|
217
|
+
onOpenChange={(open) => setMenu(open ? 'column' : null)}
|
|
218
|
+
trigger={
|
|
219
|
+
<IconButton aria-label="Column menu" tooltip="Column" className="h-4 w-6 p-0">
|
|
220
|
+
<IconGripHorizontal size={16} stroke={1.5} />
|
|
221
|
+
</IconButton>
|
|
222
|
+
}
|
|
223
|
+
>
|
|
224
|
+
<MenuItem label="Insert left" leading={<IconColumnInsertLeft {...ICON} />} onClick={run(() => inCell(editor, shown.cell, addColumnBefore))} />
|
|
225
|
+
<MenuItem label="Insert right" leading={<IconColumnInsertRight {...ICON} />} onClick={run(() => inCell(editor, shown.cell, addColumnAfter))} />
|
|
226
|
+
<MenuSeparator />
|
|
227
|
+
<MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={run(() => deleteTableColumn(editor, shown.cell))} />
|
|
228
|
+
<MenuItem label="Delete table" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={run(() => inCell(editor, shown.cell, deleteTable))} />
|
|
229
|
+
</Menu>
|
|
230
|
+
</div>
|
|
231
|
+
{/*
|
|
232
|
+
The ➕ bars, flush with the table so the pointer reaches them from a
|
|
233
|
+
cell without leaving it — the row's from the last row, the column's
|
|
234
|
+
from the last column. Anywhere else the one under the table would
|
|
235
|
+
lie over the top of the next block for nothing.
|
|
236
|
+
*/}
|
|
237
|
+
{shown.row === shown.table.rows[shown.table.rows.length - 1] && (
|
|
238
|
+
<div data-table-control className={cn(HANDLE_BOX, 'absolute flex h-4')} style={{ left: shown.tableBox.left, top: shown.tableBox.top + shown.tableBox.height, width: shown.tableBox.width }}>
|
|
239
|
+
<IconButton aria-label="Add row" tooltip="Add row" className="h-full w-full p-0" onMouseDown={keepFocus} onClick={run(() => appendTableRow(editor, shown.table))}>
|
|
240
|
+
<IconPlus size={12} stroke={1.5} />
|
|
241
|
+
</IconButton>
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
{shown.cell === shown.row.cells[shown.row.cells.length - 1] && (
|
|
245
|
+
<div data-table-control className={cn(HANDLE_BOX, 'absolute flex w-4')} style={{ left: shown.tableBox.left + shown.tableBox.width, top: shown.tableBox.top, height: shown.tableBox.height }}>
|
|
246
|
+
<IconButton aria-label="Add column" tooltip="Add column" className="h-full w-full p-0" onMouseDown={keepFocus} onClick={run(() => appendTableColumn(editor, shown.table))}>
|
|
247
|
+
<IconPlus size={12} stroke={1.5} />
|
|
248
|
+
</IconButton>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as LinkChipsStories from './LinkChips.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={LinkChipsStories} />
|
|
5
|
+
|
|
6
|
+
# LinkChips
|
|
7
|
+
|
|
8
|
+
A URL in a document that the app recognises reads as a chip of what it points
|
|
9
|
+
at, and stays that URL when the document is saved. A URL the app does not
|
|
10
|
+
recognise, or has not answered for yet, stays text.
|
|
11
|
+
|
|
12
|
+
<Canvas of={LinkChipsStories.InAnEditor} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- **An editor of a document whose URLs can name things**: pair it with
|
|
17
|
+
**ReferenceNode**, which draws the chip, and **ReferenceCards**, which places
|
|
18
|
+
the card a chip can ask for.
|
|
19
|
+
|
|
20
|
+
## When not
|
|
21
|
+
|
|
22
|
+
- A document that is only read → **RichText**, whose text already says which
|
|
23
|
+
runs are chips.
|
|
24
|
+
- An editor with a link mark (a composer's Link button) → its links are the
|
|
25
|
+
author's words, not chips. Turn the link mark off in a document editor
|
|
26
|
+
(`StarterKit.configure({ link: false })`), or URLs are linked before this
|
|
27
|
+
sees them.
|
|
28
|
+
|
|
29
|
+
## How
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { LinkChips, ReferenceCards, ReferenceNode } from '@estiva-app/ui/editor'
|
|
33
|
+
|
|
34
|
+
LinkChips.configure({
|
|
35
|
+
// What the app has resolved so far: the reference a URL names, or undefined.
|
|
36
|
+
referenceOf: (url) => known(url),
|
|
37
|
+
// Look up the rest. When it settles, the document is looked at again.
|
|
38
|
+
ask: (urls) => lookUp(urls),
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- A chip made from a URL is a `reference` node with `href` set to the URL;
|
|
43
|
+
`uri` is what it names. A document saves it as the URL.
|
|
44
|
+
- A chip read from a document before its URL is answered draws as the URL, and
|
|
45
|
+
offers no card choice until it is answered.
|
|
46
|
+
- The URL the caret is in, or at the end of, stays text: a half-typed address
|
|
47
|
+
never turns into a chip while it is being typed. A pasted URL is whole: it
|
|
48
|
+
is asked about and becomes its chip at once, with the caret at its end, so
|
|
49
|
+
**ReferenceMenu** can open its menu.
|
|
50
|
+
- A URL in code, in a code block, or under a link mark stays text.
|
|
51
|
+
- Each URL is asked about once, and one editor asks about at most `limit`
|
|
52
|
+
(24 by default), the first ones: an answer can cost the app a query.
|
|
53
|
+
- Making a chip is not an undo step: undo takes back what was typed.
|
|
54
|
+
|
|
55
|
+
## What it owns
|
|
56
|
+
|
|
57
|
+
Nothing. It draws nothing; it turns the URLs the app recognises into chips.
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { LinkChips } from './LinkChips'
|
|
8
|
+
import { ReferenceCards } from './ReferenceCards'
|
|
9
|
+
import { richTextClassName } from './RichText'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A URL the app recognises reads as a chip; one it does not stays text. Here
|
|
13
|
+
* the app is a stand-in that recognises one address, after a short wait, and
|
|
14
|
+
* the chip is a placeholder naming what it points at: the real one is the
|
|
15
|
+
* app's, which knows how to resolve it.
|
|
16
|
+
*/
|
|
17
|
+
const meta = {
|
|
18
|
+
title: 'Primitives/LinkChips',
|
|
19
|
+
parameters: { layout: 'padded' },
|
|
20
|
+
} satisfies Meta
|
|
21
|
+
|
|
22
|
+
export default meta
|
|
23
|
+
type Story = StoryObj
|
|
24
|
+
|
|
25
|
+
const KNOWN = 'https://example.com/item/one'
|
|
26
|
+
const answered = new Map<string, string>()
|
|
27
|
+
|
|
28
|
+
function Placeholder({ uri }: ReferenceViewProps) {
|
|
29
|
+
return <InlineChip>Item for {uri}</InlineChip>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Box() {
|
|
33
|
+
const editor = useEditor({
|
|
34
|
+
extensions: [
|
|
35
|
+
StarterKit.configure({ link: false }),
|
|
36
|
+
BlockId,
|
|
37
|
+
ReferenceNode.configure({ view: Placeholder }),
|
|
38
|
+
ReferenceCards,
|
|
39
|
+
LinkChips.configure({
|
|
40
|
+
referenceOf: (url) => answered.get(url),
|
|
41
|
+
ask: (urls) =>
|
|
42
|
+
new Promise<void>((resolve) =>
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
if (urls.includes(KNOWN)) answered.set(KNOWN, 'nostr:naddr1one')
|
|
45
|
+
resolve()
|
|
46
|
+
}, 600),
|
|
47
|
+
),
|
|
48
|
+
}),
|
|
49
|
+
],
|
|
50
|
+
content: {
|
|
51
|
+
type: 'doc',
|
|
52
|
+
content: [
|
|
53
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: `Recognised: ${KNOWN}` }] },
|
|
54
|
+
{ type: 'paragraph', attrs: { blockId: 'p2' }, content: [{ type: 'text', text: 'Not recognised: https://example.com/elsewhere' }] },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
58
|
+
})
|
|
59
|
+
return (
|
|
60
|
+
<Card fill="surface" className="flex w-[520px] flex-col gap-2 p-3">
|
|
61
|
+
<EditorContent editor={editor} />
|
|
62
|
+
</Card>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Two URLs: the first turns into its chip once the stand-in answers, the second stays text. */
|
|
67
|
+
export const InAnEditor: Story = { render: () => <Box /> }
|
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
})
|