@estiva-app/ui 0.69.2 → 0.71.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 +2 -0
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +8 -0
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/EditorBlockHandle.d.ts +53 -4
- package/dist/EditorBlockHandle.d.ts.map +1 -1
- package/dist/LinkChips.d.ts +2 -0
- package/dist/LinkChips.d.ts.map +1 -1
- package/dist/PastedMarkdown.d.ts +24 -0
- package/dist/PastedMarkdown.d.ts.map +1 -0
- package/dist/editor.d.ts +2 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +554 -403
- package/dist/editor.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +81 -5
- package/src/BlockHandle.test.tsx +1 -1
- package/src/BlockNodes.tsx +2 -0
- package/src/ComposerTriggers.tsx +3 -1
- package/src/EditorBlockHandle.mdx +14 -9
- package/src/EditorBlockHandle.stories.tsx +5 -11
- package/src/EditorBlockHandle.test.tsx +200 -0
- package/src/EditorBlockHandle.tsx +181 -10
- package/src/LinkChips.ts +2 -0
- package/src/PastedMarkdown.mdx +59 -0
- package/src/PastedMarkdown.stories.tsx +62 -0
- package/src/PastedMarkdown.test.ts +134 -0
- package/src/PastedMarkdown.ts +68 -0
- package/src/editor.ts +16 -1
- package/src/pages.test.ts +1 -0
- package/src/registry/registry.test.ts +6 -6
- package/stories/Choosing.mdx +1 -0
|
@@ -4,13 +4,19 @@ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
|
4
4
|
import type { EditorView } from '@tiptap/pm/view'
|
|
5
5
|
import { IconCopy, IconTrash } from '@tabler/icons-react'
|
|
6
6
|
import { BlockHandle, blockAtY, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
|
|
7
|
+
import { Selection } from '@tiptap/pm/state'
|
|
7
8
|
import { MenuItem, MenuSub } from './Menu'
|
|
9
|
+
import { DOCUMENT_FORMATS, FORMATS, applyFormat, type FormatId } from './ComposerTriggers'
|
|
8
10
|
|
|
9
11
|
/** A row of the block menu's Turn into: what the block becomes. */
|
|
10
12
|
export interface BlockTurnInto {
|
|
11
13
|
label: string
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Turns the block. The handle puts the caret in a block of text first, so a
|
|
16
|
+
* row for one can act at the caret; `pos` is where the block starts, for a
|
|
17
|
+
* list or a quote, whose row acts on the whole block.
|
|
18
|
+
*/
|
|
19
|
+
run: (editor: Editor, pos: number) => void
|
|
14
20
|
/** Whether the block already is this — drawn as the selected row. */
|
|
15
21
|
active?: (node: ProseMirrorNode) => boolean
|
|
16
22
|
}
|
|
@@ -18,8 +24,12 @@ export interface BlockTurnInto {
|
|
|
18
24
|
export interface EditorBlockHandleProps {
|
|
19
25
|
/** The editor. Not editable, or not there yet, and the handle reads: the caller's rows only. */
|
|
20
26
|
editor: Editor | null
|
|
21
|
-
/**
|
|
22
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Turn into's rows for a block. By default `turnIntoRows`, the same in every
|
|
29
|
+
* app: a block of text, a list and a quote each get theirs. A list of rows
|
|
30
|
+
* instead is offered for a block of text only; `[]` offers none.
|
|
31
|
+
*/
|
|
32
|
+
turnInto?: readonly BlockTurnInto[] | ((node: ProseMirrorNode, editor: Editor) => readonly BlockTurnInto[])
|
|
23
33
|
/** The caller's own rows, after the editing ones. */
|
|
24
34
|
actions?: (block: HandleBlock) => ReactNode
|
|
25
35
|
/** The `EditorContent`. */
|
|
@@ -116,7 +126,7 @@ const NOTHING_YET: BlockHandleEditing = { find: () => null, rows: () => null, st
|
|
|
116
126
|
* `BlockHandle` in an editor: drag the handle to move the block, and the menu
|
|
117
127
|
* gains Turn into, Duplicate and Delete before the caller's rows.
|
|
118
128
|
*/
|
|
119
|
-
export function EditorBlockHandle({ editor, turnInto =
|
|
129
|
+
export function EditorBlockHandle({ editor, turnInto = turnIntoRows, actions, children, className }: EditorBlockHandleProps) {
|
|
120
130
|
const editing = useMemo<BlockHandleEditing>(() => {
|
|
121
131
|
// Before the editor exists there is no block to find — not the editor's whole element read as one.
|
|
122
132
|
if (!editor) return NOTHING_YET
|
|
@@ -130,19 +140,20 @@ export function EditorBlockHandle({ editor, turnInto = [], actions, children, cl
|
|
|
130
140
|
if (!editor.isEditable) return null
|
|
131
141
|
const node = pos === undefined ? null : editor.state.doc.nodeAt(pos)
|
|
132
142
|
if (pos === undefined || !node) return null
|
|
143
|
+
const turnRows = typeof turnInto === 'function' ? turnInto(node, editor) : node.isTextblock ? turnInto : []
|
|
133
144
|
return (
|
|
134
145
|
<>
|
|
135
|
-
{
|
|
146
|
+
{turnRows.length > 0 && (
|
|
136
147
|
<MenuSub label="Turn into">
|
|
137
|
-
{
|
|
148
|
+
{turnRows.map((row) => (
|
|
138
149
|
<MenuItem
|
|
139
150
|
key={row.label}
|
|
140
151
|
label={row.label}
|
|
141
152
|
selected={row.active?.(node) ?? false}
|
|
142
153
|
onClick={() => {
|
|
143
|
-
// Inside
|
|
144
|
-
editor.chain().focus().setTextSelection(pos + 1).run()
|
|
145
|
-
row.run(editor)
|
|
154
|
+
// Inside a block of text, so a row's command at the caret turns this block and no other.
|
|
155
|
+
if (node.isTextblock) editor.chain().focus().setTextSelection(pos + 1).run()
|
|
156
|
+
row.run(editor, pos)
|
|
146
157
|
}}
|
|
147
158
|
/>
|
|
148
159
|
))}
|
|
@@ -179,3 +190,163 @@ export function EditorBlockHandle({ editor, turnInto = [], actions, children, cl
|
|
|
179
190
|
</BlockHandle>
|
|
180
191
|
)
|
|
181
192
|
}
|
|
193
|
+
|
|
194
|
+
const LISTS = ['bulletList', 'orderedList']
|
|
195
|
+
const isList = (node: ProseMirrorNode) => LISTS.includes(node.type.name)
|
|
196
|
+
|
|
197
|
+
/** Every item of a list, the ones nested under an item too — the editor nests what §13.3 keeps flat (MAN-13). */
|
|
198
|
+
function listItems(list: ProseMirrorNode, at: number): { node: ProseMirrorNode; pos: number }[] {
|
|
199
|
+
const out: { node: ProseMirrorNode; pos: number }[] = []
|
|
200
|
+
list.descendants((node, pos) => {
|
|
201
|
+
if (node.type.name === 'listItem') out.push({ node, pos: at + 1 + pos })
|
|
202
|
+
})
|
|
203
|
+
return out
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const isChecklistItem = ({ node }: { node: ProseMirrorNode }) => typeof node.attrs.checked === 'boolean'
|
|
207
|
+
/** Whether every item of the list is a checklist item. */
|
|
208
|
+
const isChecklist = (list: ProseMirrorNode) => listItems(list, 0).every(isChecklistItem)
|
|
209
|
+
/** A list of that type with no checklist item in it — a list partly ticked is neither, so no row reads as "nothing changes". */
|
|
210
|
+
const isPlainList = (list: ProseMirrorNode, type: string) => list.type.name === type && !listItems(list, 0).some(isChecklistItem)
|
|
211
|
+
|
|
212
|
+
const hasExtension = (editor: Editor, name: string) => editor.extensionManager.extensions.some((e) => e.name === name)
|
|
213
|
+
|
|
214
|
+
/** The caret back in the block, the editor still focused — as Duplicate and Delete leave it, so a save on leaving saves this too. */
|
|
215
|
+
function caretInto(editor: Editor, pos: number) {
|
|
216
|
+
const { state } = editor
|
|
217
|
+
editor.view.dispatch(state.tr.setSelection(Selection.near(state.doc.resolve(Math.min(pos + 1, state.doc.content.size)))))
|
|
218
|
+
editor.commands.focus()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type ListKind = 'bulletList' | 'orderedList' | 'checklist' | 'text'
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Turns the whole list at `pos`, every item, the nested ones too.
|
|
225
|
+
*
|
|
226
|
+
* - **Bulleted, Numbered:** the list's own type changes and its attributes
|
|
227
|
+
* stay, so it keeps its id; every item's tick goes. Not Tiptap's
|
|
228
|
+
* `toggleList`, which makes the list anew — a fresh id, detaching every
|
|
229
|
+
* comment anchored to it — and then joins it into a list of that type next
|
|
230
|
+
* to it.
|
|
231
|
+
* - **Checklist:** every item a checklist item, the list's type as it was; an
|
|
232
|
+
* item already one keeps its tick.
|
|
233
|
+
*
|
|
234
|
+
* The kind it already is changes nothing — no edit, no undo step.
|
|
235
|
+
* - **Text:** each item becomes a paragraph that takes the item's id, as
|
|
236
|
+
* lifting an item out does (MAN-12). The list's own id goes with the list.
|
|
237
|
+
*
|
|
238
|
+
* `false` when there is no list at `pos`.
|
|
239
|
+
*/
|
|
240
|
+
export function turnListInto(editor: Editor, pos: number, kind: ListKind): boolean {
|
|
241
|
+
const { state } = editor
|
|
242
|
+
const list = state.doc.nodeAt(pos)
|
|
243
|
+
if (!list || !isList(list)) return false
|
|
244
|
+
const tr = state.tr
|
|
245
|
+
if (kind === 'text') {
|
|
246
|
+
const blocks: ProseMirrorNode[] = []
|
|
247
|
+
for (const { node: item } of listItems(list, pos)) {
|
|
248
|
+
item.forEach((child, _offset, index) => {
|
|
249
|
+
// Its nested list's items are in `listItems` already.
|
|
250
|
+
if (isList(child)) return
|
|
251
|
+
// The item's first block takes its id; any other gets one of its own from `BlockId`.
|
|
252
|
+
blocks.push(child.type.create({ ...child.attrs, blockId: index === 0 ? item.attrs.blockId : null }, child.content, child.marks))
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
tr.replaceWith(pos, pos + list.nodeSize, blocks)
|
|
256
|
+
} else {
|
|
257
|
+
// A checklist stays the list type it is: §13.3 ticks items of either.
|
|
258
|
+
const type = kind === 'checklist' ? list.type : state.schema.nodes[kind]
|
|
259
|
+
// The list and the lists nested in it, one type throughout: §13.3's indented items are their list's type.
|
|
260
|
+
if (list.type !== type) tr.setNodeMarkup(pos, type, list.attrs)
|
|
261
|
+
list.descendants((node, at) => {
|
|
262
|
+
if (isList(node) && node.type !== type) tr.setNodeMarkup(pos + 1 + at, type, node.attrs)
|
|
263
|
+
})
|
|
264
|
+
for (const { node, pos: at } of listItems(list, pos)) {
|
|
265
|
+
const checked = kind === 'checklist' ? (typeof node.attrs.checked === 'boolean' ? node.attrs.checked : false) : null
|
|
266
|
+
if (node.attrs.checked !== checked) tr.setNodeAttribute(at, 'checked', checked)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// The row it already is changes nothing: no edit to save, no step to undo.
|
|
270
|
+
if (tr.docChanged) editor.view.dispatch(tr.scrollIntoView())
|
|
271
|
+
caretInto(editor, pos)
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export type QuoteKind = 'quote' | 'note' | 'warning' | 'text'
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Turns the quote at `pos`: a plain quote, a Note or Warning callout (the
|
|
279
|
+
* tone changes, the id stays), or its text, whose first paragraph takes the
|
|
280
|
+
* quote's id, as lifting it out does (MAN-12). `false` when there is no quote
|
|
281
|
+
* at `pos`.
|
|
282
|
+
*/
|
|
283
|
+
export function turnQuoteInto(editor: Editor, pos: number, kind: QuoteKind): boolean {
|
|
284
|
+
const { state } = editor
|
|
285
|
+
const quote = state.doc.nodeAt(pos)
|
|
286
|
+
if (!quote || quote.type.name !== 'blockquote') return false
|
|
287
|
+
const tr = state.tr
|
|
288
|
+
if (kind === 'text') {
|
|
289
|
+
const blocks: ProseMirrorNode[] = []
|
|
290
|
+
quote.forEach((child) => blocks.push(blocks.length === 0 ? child.type.create({ ...child.attrs, blockId: quote.attrs.blockId }, child.content, child.marks) : child))
|
|
291
|
+
tr.replaceWith(pos, pos + quote.nodeSize, blocks)
|
|
292
|
+
} else {
|
|
293
|
+
const tone = kind === 'quote' ? null : kind
|
|
294
|
+
if ((quote.attrs.tone ?? null) !== tone) tr.setNodeAttribute(pos, 'tone', tone)
|
|
295
|
+
}
|
|
296
|
+
if (tr.docChanged) editor.view.dispatch(tr.scrollIntoView())
|
|
297
|
+
caretInto(editor, pos)
|
|
298
|
+
return true
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isFormat(node: ProseMirrorNode, id: FormatId): boolean {
|
|
302
|
+
if (id === 'paragraph') return node.type.name === 'paragraph'
|
|
303
|
+
if (id === 'heading') return node.type.name === 'heading' && node.attrs.level === 1
|
|
304
|
+
if (id === 'subheading') return node.type.name === 'heading' && node.attrs.level === 2
|
|
305
|
+
if (id === 'code') return node.type.name === 'codeBlock'
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The block menu's Turn into for one block, the same in every app (Miky,
|
|
311
|
+
* LEAF-11's kickoff, 15 October):
|
|
312
|
+
*
|
|
313
|
+
* - **A block of text** — a paragraph, a heading, code: the `/` menu's Format
|
|
314
|
+
* rows, code as a block, and Checklist, Note and Warning where the editor
|
|
315
|
+
* has `Checklist` and `Callout`.
|
|
316
|
+
* - **A list:** Bulleted list, Numbered list, Checklist (with `Checklist`), Text.
|
|
317
|
+
* - **A quote:** Quote, Note, Warning (with `Callout`), Text.
|
|
318
|
+
*
|
|
319
|
+
* Anything else — a table, an attachment — none. Each keeps the block's text
|
|
320
|
+
* and its id, and so the comments anchored to it.
|
|
321
|
+
*/
|
|
322
|
+
export function turnIntoRows(node: ProseMirrorNode, editor: Editor): readonly BlockTurnInto[] {
|
|
323
|
+
const checklist = hasExtension(editor, 'checklist')
|
|
324
|
+
const callout = hasExtension(editor, 'callout')
|
|
325
|
+
if (node.isTextblock) {
|
|
326
|
+
const document = DOCUMENT_FORMATS.filter((f) => (f.id === 'checklist' ? checklist : callout))
|
|
327
|
+
return [...FORMATS, ...document].map((format) => ({
|
|
328
|
+
label: format.label,
|
|
329
|
+
run: (e) => void applyFormat(e, format.id, 'block'),
|
|
330
|
+
active: (n) => isFormat(n, format.id),
|
|
331
|
+
}))
|
|
332
|
+
}
|
|
333
|
+
if (isList(node)) {
|
|
334
|
+
const rows: BlockTurnInto[] = [
|
|
335
|
+
{ label: 'Bulleted list', run: (e, pos) => void turnListInto(e, pos, 'bulletList'), active: (n) => isPlainList(n, 'bulletList') },
|
|
336
|
+
{ label: 'Numbered list', run: (e, pos) => void turnListInto(e, pos, 'orderedList'), active: (n) => isPlainList(n, 'orderedList') },
|
|
337
|
+
]
|
|
338
|
+
if (checklist) rows.push({ label: 'Checklist', run: (e, pos) => void turnListInto(e, pos, 'checklist'), active: isChecklist })
|
|
339
|
+
rows.push({ label: 'Text', run: (e, pos) => void turnListInto(e, pos, 'text') })
|
|
340
|
+
return rows
|
|
341
|
+
}
|
|
342
|
+
if (node.type.name === 'blockquote') {
|
|
343
|
+
const rows: BlockTurnInto[] = [{ label: 'Quote', run: (e, pos) => void turnQuoteInto(e, pos, 'quote'), active: (n) => !n.attrs.tone }]
|
|
344
|
+
if (callout) {
|
|
345
|
+
rows.push({ label: 'Note', run: (e, pos) => void turnQuoteInto(e, pos, 'note'), active: (n) => n.attrs.tone === 'note' })
|
|
346
|
+
rows.push({ label: 'Warning', run: (e, pos) => void turnQuoteInto(e, pos, 'warning'), active: (n) => n.attrs.tone === 'warning' })
|
|
347
|
+
}
|
|
348
|
+
rows.push({ label: 'Text', run: (e, pos) => void turnQuoteInto(e, pos, 'text') })
|
|
349
|
+
return rows
|
|
350
|
+
}
|
|
351
|
+
return []
|
|
352
|
+
}
|
package/src/LinkChips.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* half-typed address never turns into a chip under the person's fingers. A
|
|
18
18
|
* pasted URL is whole, so it is asked about and made a chip at once, the caret
|
|
19
19
|
* still at its end — where `ReferenceMenu` opens its menu.
|
|
20
|
+
*
|
|
21
|
+
* why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-link-is-stored-as-the-url-a-person-wrote.md
|
|
20
22
|
*/
|
|
21
23
|
import { Extension, type Editor } from '@tiptap/core'
|
|
22
24
|
import { closeHistory } from '@tiptap/pm/history'
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as PastedMarkdownStories from './PastedMarkdown.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={PastedMarkdownStories} />
|
|
5
|
+
|
|
6
|
+
# PastedMarkdown
|
|
7
|
+
|
|
8
|
+
Markdown pasted into an editor as plain text lands as what it spells: a
|
|
9
|
+
heading, a list, a checklist, a quote or callout, a table. A README's source or
|
|
10
|
+
an answer copied from a chat arrives formatted, not full of `#` and `|`.
|
|
11
|
+
|
|
12
|
+
<Canvas of={PastedMarkdownStories.InAnEditor} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- **An editor that saves a block document**: a page, a description. Give it
|
|
17
|
+
the protocol's reader, so a paste reads text the way everything else that
|
|
18
|
+
writes a block document does.
|
|
19
|
+
|
|
20
|
+
## When not
|
|
21
|
+
|
|
22
|
+
No alternative: a message box or a comment box leaves it out and pastes text
|
|
23
|
+
as typed, because a message's text has no table.
|
|
24
|
+
|
|
25
|
+
- A pasted picture → `PastedImages`.
|
|
26
|
+
- A pasted URL that names something in the app → `LinkChips`.
|
|
27
|
+
|
|
28
|
+
## How
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
32
|
+
import { TableKit } from '@tiptap/extension-table'
|
|
33
|
+
import { PastedMarkdown } from '@estiva-app/ui/editor'
|
|
34
|
+
|
|
35
|
+
useEditor({
|
|
36
|
+
extensions: [
|
|
37
|
+
StarterKit,
|
|
38
|
+
TableKit,
|
|
39
|
+
PastedMarkdown.configure({
|
|
40
|
+
// Both from @estiva-app/protocol, which this package does not depend on.
|
|
41
|
+
read: (text) => toEditorDocument(descriptionTextToBlockDocument(text)),
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **Only plain text is read.** A paste that carries HTML, a copy from a web
|
|
48
|
+
page, keeps the browser's own structure.
|
|
49
|
+
- **Text that spells only paragraphs pastes exactly as before**, into the line
|
|
50
|
+
the caret is on.
|
|
51
|
+
- **Ctrl/Cmd+Shift+V keeps the text as typed**, and so does a paste into code.
|
|
52
|
+
- A table pasted inside a table cell stays text, and so does any block the
|
|
53
|
+
editor cannot hold (a table, in an editor without `TableKit`).
|
|
54
|
+
- How text is read is the app's reader. When it lands, and when it stays
|
|
55
|
+
text, is this part's.
|
|
56
|
+
|
|
57
|
+
## What it owns
|
|
58
|
+
|
|
59
|
+
Nothing. It draws nothing; it hands pasted text to the app's reader and puts back what that reads.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import type { JSONContent } from '@tiptap/core'
|
|
3
|
+
import { TableKit } from '@tiptap/extension-table'
|
|
4
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
5
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
6
|
+
import { Button } from './Button'
|
|
7
|
+
import { Card } from './Card'
|
|
8
|
+
import { PastedMarkdown } from './PastedMarkdown'
|
|
9
|
+
import { richTextClassName } from './RichText'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* "Copy as plain text" puts the lines below the box on the clipboard as plain
|
|
13
|
+
* text; paste them in and they land as a table. Paste them with
|
|
14
|
+
* Ctrl/Cmd+Shift+V: they stay text.
|
|
15
|
+
*/
|
|
16
|
+
const meta = {
|
|
17
|
+
title: 'Primitives/PastedMarkdown',
|
|
18
|
+
parameters: { layout: 'padded' },
|
|
19
|
+
} satisfies Meta
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj
|
|
23
|
+
|
|
24
|
+
const SAMPLE = '| Item | Value |\n| --- | --- |\n| One | 1 |\n| Two | 2 |'
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
A stand-in for the app's reader, which is the protocol's and reads every
|
|
28
|
+
block text spells. This one reads a table and nothing else, enough to show
|
|
29
|
+
the paste.
|
|
30
|
+
*/
|
|
31
|
+
function readTable(text: string): JSONContent | undefined {
|
|
32
|
+
const lines = text.trim().split('\n')
|
|
33
|
+
if (lines.length < 2 || !/^\|( *:?-+:? *\|)+$/.test(lines[1].trim())) return undefined
|
|
34
|
+
const cells = (line: string) => line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim())
|
|
35
|
+
const cell = (type: string, text: string): JSONContent => ({ type, content: [{ type: 'paragraph', content: text ? [{ type: 'text', text }] : [] }] })
|
|
36
|
+
const rows = [lines[0], ...lines.slice(2)].map((line, r) => ({ type: 'tableRow', content: cells(line).map((text) => cell(r === 0 ? 'tableHeader' : 'tableCell', text)) }))
|
|
37
|
+
return { type: 'doc', content: [{ type: 'table', content: rows }] }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Box() {
|
|
41
|
+
const editor = useEditor({
|
|
42
|
+
extensions: [StarterKit, TableKit, PastedMarkdown.configure({ read: readTable })],
|
|
43
|
+
content: '<p>Paste here.</p>',
|
|
44
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
45
|
+
})
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex w-[520px] flex-col gap-3">
|
|
48
|
+
<Card fill="surface" className="p-3">
|
|
49
|
+
<EditorContent editor={editor} />
|
|
50
|
+
</Card>
|
|
51
|
+
<pre className="text-caption text-text-secondary">{SAMPLE}</pre>
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<Button variant="outlined" size="small" onClick={() => void navigator.clipboard.writeText(SAMPLE)}>
|
|
54
|
+
Copy as plain text
|
|
55
|
+
</Button>
|
|
56
|
+
<span className="text-caption text-text-secondary">then paste in the box. Selecting the lines above copies them with HTML, which pastes as before.</span>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const InAnEditor: Story = { render: () => <Box /> }
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { Editor, type JSONContent } from '@tiptap/core'
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
5
|
+
import { TableKit } from '@tiptap/extension-table'
|
|
6
|
+
import { PastedMarkdown } from './PastedMarkdown'
|
|
7
|
+
|
|
8
|
+
const TABLE_TEXT = '| Name | Since |\n| --- | --- |\n| Ada | 2026 |'
|
|
9
|
+
const paragraph = (text: string): JSONContent => ({ type: 'paragraph', content: [{ type: 'text', text }] })
|
|
10
|
+
const cell = (type: string, text: string): JSONContent => ({ type, content: [paragraph(text)] })
|
|
11
|
+
const TABLE: JSONContent = {
|
|
12
|
+
type: 'table',
|
|
13
|
+
content: [
|
|
14
|
+
{ type: 'tableRow', content: [cell('tableHeader', 'Name'), cell('tableHeader', 'Since')] },
|
|
15
|
+
{ type: 'tableRow', content: [cell('tableCell', 'Ada'), cell('tableCell', '2026')] },
|
|
16
|
+
],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
The reader an app passes — the protocol's, in Leaf and Ship. Its own reading
|
|
21
|
+
of text is the protocol package's to test; here it answers the texts these
|
|
22
|
+
tests paste, as that reader does.
|
|
23
|
+
*/
|
|
24
|
+
const READ: Record<string, JSONContent[]> = {
|
|
25
|
+
[TABLE_TEXT]: [TABLE],
|
|
26
|
+
[`Before\n\n${TABLE_TEXT}\n\nAfter`]: [paragraph('Before'), TABLE, paragraph('After')],
|
|
27
|
+
'# Plan\n\n- one': [{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Plan' }] }, { type: 'bulletList', content: [{ type: 'listItem', content: [paragraph('one')] }] }],
|
|
28
|
+
world: [paragraph('world')],
|
|
29
|
+
'A **bold** word': [{ type: 'paragraph', content: [{ type: 'text', text: 'A ' }, { type: 'text', text: 'bold', marks: [{ type: 'bold' }] }, { type: 'text', text: ' word' }] }],
|
|
30
|
+
}
|
|
31
|
+
const read = vi.fn((text: string) => {
|
|
32
|
+
if (text === 'reader breaks') throw new RangeError('Maximum call stack size exceeded')
|
|
33
|
+
return READ[text] ? { type: 'doc', content: READ[text] } : undefined
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
let editor: Editor | undefined
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
editor?.destroy()
|
|
39
|
+
read.mockClear()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
function make(content: JSONContent, tables = true) {
|
|
43
|
+
editor = new Editor({
|
|
44
|
+
element: document.createElement('div'),
|
|
45
|
+
extensions: [StarterKit, ...(tables ? [TableKit] : []), PastedMarkdown.configure({ read })],
|
|
46
|
+
content,
|
|
47
|
+
})
|
|
48
|
+
return editor
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A paste as the browser sends it, through ProseMirror's own handler. */
|
|
52
|
+
function paste(ed: Editor, text: string, html = '', { shift = false } = {}) {
|
|
53
|
+
if (shift) ed.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift', keyCode: 16, shiftKey: true, bubbles: true }))
|
|
54
|
+
const event = new Event('paste', { bubbles: true, cancelable: true })
|
|
55
|
+
Object.defineProperty(event, 'clipboardData', {
|
|
56
|
+
value: { types: html ? ['text/plain', 'text/html'] : ['text/plain'], files: [], getData: (type: string) => (type === 'text/plain' ? text : type === 'text/html' ? html : '') },
|
|
57
|
+
})
|
|
58
|
+
ed.view.dom.dispatchEvent(event)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const shapeOf = (ed: Editor) => (ed.getJSON().content ?? []).map((block) => block.type)
|
|
62
|
+
const textOf = (ed: Editor) => ed.state.doc.textBetween(0, ed.state.doc.content.size, '\n')
|
|
63
|
+
|
|
64
|
+
describe('PastedMarkdown', () => {
|
|
65
|
+
it('pastes a markdown table copied as plain text as a table, not pipes', () => {
|
|
66
|
+
const ed = make({ type: 'doc', content: [{ type: 'paragraph' }] })
|
|
67
|
+
paste(ed, TABLE_TEXT)
|
|
68
|
+
expect(shapeOf(ed)).toContain('table')
|
|
69
|
+
expect(textOf(ed)).not.toContain('|')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('pastes the prose around a table as paragraphs, and the table between them', () => {
|
|
73
|
+
const ed = make({ type: 'doc', content: [{ type: 'paragraph' }] })
|
|
74
|
+
paste(ed, `Before\n\n${TABLE_TEXT}\n\nAfter`)
|
|
75
|
+
expect(shapeOf(ed)).toEqual(['paragraph', 'table', 'paragraph'])
|
|
76
|
+
expect(textOf(ed)).toMatch(/^Before\n[\s\S]*Ada[\s\S]*\nAfter$/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('pastes a heading and a list as a heading and a list', () => {
|
|
80
|
+
const ed = make({ type: 'doc', content: [{ type: 'paragraph' }] })
|
|
81
|
+
paste(ed, '# Plan\n\n- one')
|
|
82
|
+
// StarterKit keeps an empty paragraph after a last block that is not one.
|
|
83
|
+
expect(shapeOf(ed)).toEqual(['heading', 'bulletList', 'paragraph'])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('pastes text that spells only paragraphs exactly as before, into the line it lands in', () => {
|
|
87
|
+
const ed = make({ type: 'doc', content: [paragraph('Hello ')] })
|
|
88
|
+
ed.commands.focus('end')
|
|
89
|
+
paste(ed, 'world')
|
|
90
|
+
expect(ed.getJSON().content).toEqual([paragraph('Hello world')])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('pastes a paragraph whose text spells bold with the word bold, not with the asterisks', () => {
|
|
94
|
+
const ed = make({ type: 'doc', content: [{ type: 'paragraph' }] })
|
|
95
|
+
paste(ed, 'A **bold** word')
|
|
96
|
+
expect(textOf(ed)).toBe('A bold word')
|
|
97
|
+
expect(ed.getJSON().content?.[0].content?.[1]).toEqual({ type: 'text', text: 'bold', marks: [{ type: 'bold' }] })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('pastes the text as typed when the reader throws, so the paste is never lost', () => {
|
|
101
|
+
const ed = make({ type: 'doc', content: [{ type: 'paragraph' }] })
|
|
102
|
+
paste(ed, 'reader breaks')
|
|
103
|
+
expect(textOf(ed)).toBe('reader breaks')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('leaves a paste that carries HTML to the HTML, without reading the text', () => {
|
|
107
|
+
const ed = make({ type: 'doc', content: [{ type: 'paragraph' }] })
|
|
108
|
+
paste(ed, TABLE_TEXT, '<p>| Name | Since |</p>')
|
|
109
|
+
expect(read).not.toHaveBeenCalled()
|
|
110
|
+
expect(shapeOf(ed)).not.toContain('table')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('keeps the pipes as text on the plain-text paste, Ctrl/Cmd+Shift+V', () => {
|
|
114
|
+
const ed = make({ type: 'doc', content: [{ type: 'paragraph' }] })
|
|
115
|
+
paste(ed, TABLE_TEXT, '', { shift: true })
|
|
116
|
+
expect(shapeOf(ed)).not.toContain('table')
|
|
117
|
+
expect(textOf(ed)).toContain('| Name | Since |')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('keeps a table pasted inside a table cell as text, never a table in a table', () => {
|
|
121
|
+
const ed = make({ type: 'doc', content: [TABLE] })
|
|
122
|
+
ed.commands.setTextSelection(4)
|
|
123
|
+
paste(ed, TABLE_TEXT)
|
|
124
|
+
expect(JSON.stringify(ed.getJSON().content?.[0].content)).not.toContain('"type":"table"')
|
|
125
|
+
expect(textOf(ed)).toContain('| Name | Since |')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps the text in an editor that holds no tables', () => {
|
|
129
|
+
const ed = make({ type: 'doc', content: [{ type: 'paragraph' }] }, false)
|
|
130
|
+
paste(ed, TABLE_TEXT)
|
|
131
|
+
expect(shapeOf(ed)).toEqual(['paragraph', 'paragraph', 'paragraph'])
|
|
132
|
+
expect(textOf(ed)).toContain('| Ada | 2026 |')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Extension, type JSONContent } from '@tiptap/core'
|
|
2
|
+
import { Slice, type ResolvedPos } from '@tiptap/pm/model'
|
|
3
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
4
|
+
|
|
5
|
+
export interface PastedMarkdownOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Plain text read as the blocks it spells, as editor JSON (`{ type: 'doc',
|
|
8
|
+
* content }`), or `undefined` to paste it as text. The app passes its
|
|
9
|
+
* protocol's reader — for a block document, `@estiva-app/protocol`'s
|
|
10
|
+
* `descriptionTextToBlockDocument` through `toEditorDocument` — so a paste
|
|
11
|
+
* reads text exactly as the agent's write of the same text does.
|
|
12
|
+
*/
|
|
13
|
+
read: (text: string) => JSONContent | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Inside a table cell: a pasted table there stays text, never a table in a table. */
|
|
17
|
+
function inTableCell($context: ResolvedPos): boolean {
|
|
18
|
+
for (let depth = $context.depth; depth > 0; depth--) {
|
|
19
|
+
const role = $context.node(depth).type.spec.tableRole
|
|
20
|
+
if (role === 'cell' || role === 'header_cell') return true
|
|
21
|
+
}
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hasTable = (blocks: JSONContent[]): boolean => blocks.some((block) => block.type === 'table' || hasTable(block.content ?? []))
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Markdown pasted as plain text lands as what it spells: a heading, a list, a
|
|
29
|
+
* checklist, a quote or callout, a table (LEAF-10; Miky, 2026-10-15). Only a
|
|
30
|
+
* paste with no HTML on the clipboard is read — a copy from a web page already
|
|
31
|
+
* pastes its own structure — and a paste that spells nothing but paragraphs
|
|
32
|
+
* pastes exactly as before. Ctrl/Cmd+Shift+V, the plain-text paste, keeps the
|
|
33
|
+
* text as typed, and so does a paste into code.
|
|
34
|
+
*
|
|
35
|
+
* For an editor that saves a block document. A message box leaves it out: a
|
|
36
|
+
* message's text has no table (SPEC §13.2), so a pasted table stays text there.
|
|
37
|
+
*/
|
|
38
|
+
export const PastedMarkdown = Extension.create<PastedMarkdownOptions>({
|
|
39
|
+
name: 'pastedMarkdown',
|
|
40
|
+
addOptions() {
|
|
41
|
+
return { read: () => undefined }
|
|
42
|
+
},
|
|
43
|
+
addProseMirrorPlugins() {
|
|
44
|
+
const options = this.options
|
|
45
|
+
return [
|
|
46
|
+
new Plugin({
|
|
47
|
+
key: new PluginKey('pastedMarkdown'),
|
|
48
|
+
props: {
|
|
49
|
+
// Reached only for a text paste outside code; `plain` is the Shift paste.
|
|
50
|
+
clipboardTextParser: (text, $context, plain, view) => {
|
|
51
|
+
if (plain) return undefined as unknown as Slice
|
|
52
|
+
try {
|
|
53
|
+
const blocks = options.read(text.replace(/\r\n?/g, '\n'))?.content ?? []
|
|
54
|
+
// Paragraphs only: the editor's own paste keeps each line a paragraph, and its paste rules make `**bold**` bold.
|
|
55
|
+
if (blocks.every((block) => block.type === 'paragraph')) return undefined as unknown as Slice
|
|
56
|
+
if (inTableCell($context) && hasTable(blocks)) return undefined as unknown as Slice
|
|
57
|
+
return Slice.maxOpen(view.state.schema.nodeFromJSON({ type: 'doc', content: blocks }).content)
|
|
58
|
+
} catch {
|
|
59
|
+
// A reader that throws, or a block this editor's schema does not
|
|
60
|
+
// hold (a table without TableKit): paste the text.
|
|
61
|
+
return undefined as unknown as Slice
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
})
|
package/src/editor.ts
CHANGED
|
@@ -9,9 +9,24 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
|
|
11
11
|
export { PastedImages, pastedImages, pastedImageName, type PastedImagesOptions, type ClipboardSource } from './PastedImages'
|
|
12
|
+
export { PastedMarkdown, type PastedMarkdownOptions } from './PastedMarkdown'
|
|
12
13
|
export { CodeBlockColours } from './CodeBlockColours'
|
|
13
14
|
export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
|
|
14
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
EditorBlockHandle,
|
|
17
|
+
blockDropCursor,
|
|
18
|
+
deleteBlock,
|
|
19
|
+
moveBlock,
|
|
20
|
+
duplicateBlock,
|
|
21
|
+
startBlockDrag,
|
|
22
|
+
turnIntoRows,
|
|
23
|
+
turnListInto,
|
|
24
|
+
turnQuoteInto,
|
|
25
|
+
type EditorBlockHandleProps,
|
|
26
|
+
type BlockTurnInto,
|
|
27
|
+
type ListKind,
|
|
28
|
+
type QuoteKind,
|
|
29
|
+
} from './EditorBlockHandle'
|
|
15
30
|
export { EditorTableControls, appendTableRow, appendTableColumn, deleteTableRow, deleteTableColumn, type EditorTableControlsProps } from './EditorTableControls'
|
|
16
31
|
export {
|
|
17
32
|
PersonMention,
|
package/src/pages.test.ts
CHANGED
|
@@ -41,6 +41,7 @@ const NOTHING = 'Nothing. It only draws. Clicks and keys are yours.'
|
|
|
41
41
|
const HOLDS_ONLY: Record<string, string> = {
|
|
42
42
|
AppProvider: 'Nothing. It draws nothing; it holds two answers for the parts below it.',
|
|
43
43
|
PastedImages: 'Nothing. It draws nothing; it hands a pasted picture to the app, named.',
|
|
44
|
+
PastedMarkdown: 'Nothing. It draws nothing; it hands pasted text to the app\'s reader and puts back what that reads.',
|
|
44
45
|
CodeBlockColours: 'Nothing. It names each word of a code block; the RichText class list colours them.',
|
|
45
46
|
LinkChips: 'Nothing. It draws nothing; it turns the URLs the app recognises into chips.',
|
|
46
47
|
// Its box is the Popover's, which owns what a box does; it holds which chip the menu is on.
|
|
@@ -26,7 +26,7 @@ import { SCHEMA_VERSION, validateRegistry, type Registry, type RegistryEntry } f
|
|
|
26
26
|
const root = process.cwd()
|
|
27
27
|
const registry = buildRegistry({ root })
|
|
28
28
|
/** Editor extensions: named like components by the catalogue, and not React components. */
|
|
29
|
-
const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands', 'PastedImages', 'CodeBlockColours', 'Checklist', 'Callout', 'ReferenceCards', 'LinkChips', 'ReferenceMenu']
|
|
29
|
+
const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands', 'PastedImages', 'PastedMarkdown', 'CodeBlockColours', 'Checklist', 'Callout', 'ReferenceCards', 'LinkChips', 'ReferenceMenu']
|
|
30
30
|
const entry = (name: string): RegistryEntry => {
|
|
31
31
|
const found = registry.entries.find((candidate) => candidate.name === name)
|
|
32
32
|
if (!found) throw new Error(`no entry for ${name}`)
|
|
@@ -48,20 +48,20 @@ describe('the registry builds', () => {
|
|
|
48
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('counts
|
|
51
|
+
it('counts 166 names over 81 files: 115 components, 49 helpers and 2 hooks (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, AppProvider on 8 October, and KindIcon, thingKindOf and PastedImages with its two helpers, moved from Peek, on 8 October; CodeBlockColours, the coloured code block of the editor, on 9 October; Checklist, Callout, isChecklistTick and calloutLook (RIC-19) on 13 October; EditorTableControls and its four actions (RIC-21) on 13 October; ReferenceCards, useReferenceCards and its three helpers, the card a chip in a document asks for, on 14 October; LinkChips and its two helpers, a URL in a document read as its chip, on 14 October; ReferenceMenu, ReferenceMenuView and three helpers, the menu of a chip while writing, on 14 October; PastedMarkdown, markdown pasted as the blocks it spells (LEAF-10), on 15 October; turnIntoRows, turnListInto and turnQuoteInto, the block menu’s Turn into for a list and a quote (LEAF-11), on 15 October)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
53
|
// The `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
54
|
// editor extensions, documented on SelectionToolbar's, BlockNodes' and
|
|
55
55
|
// ComposerTriggers' pages.
|
|
56
56
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
57
|
-
expect(kinds).toEqual({ component:
|
|
58
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
57
|
+
expect(kinds).toEqual({ component: 115, helper: 49, hook: 2 })
|
|
58
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(81)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
62
62
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
63
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
64
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
63
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(77)
|
|
64
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(89)
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|
package/stories/Choosing.mdx
CHANGED
|
@@ -98,6 +98,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
98
98
|
| A link — in text, on a title, beside a note, or a whole box | **Link** — `text`, `quiet`, `underlined`, `plain`; `external` for a new tab |
|
|
99
99
|
| A document or an image attached to something, posted or waiting to be sent | **AttachmentCard** — the file decides the shape; `pending` in a composer |
|
|
100
100
|
| A picture pasted into an editor, attached rather than pasted | **PastedImages** — from `@estiva-app/ui/editor`; the app hands it its upload |
|
|
101
|
+
| Markdown pasted as plain text, landing as the headings, lists and tables it spells | **PastedMarkdown** — from `@estiva-app/ui/editor`, in an editor that saves a block document; the app hands it the protocol's reader |
|
|
101
102
|
| A card a chip in a document asks for, drawn after its line | **ReferenceCards** — from `@estiva-app/ui/editor`, beside `ReferenceNode`; the app draws its card into each slot |
|
|
102
103
|
| A URL in a document that reads as the chip of what it points at | **LinkChips** — from `@estiva-app/ui/editor`, beside `ReferenceNode`; the app says what each URL names |
|
|
103
104
|
| Open a chip in a document, or choose its card, while writing | **ReferenceMenu** — from `@estiva-app/ui/editor`, beside `ReferenceCards`; a click on the chip, or putting one in, opens it |
|