@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.
@@ -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
- /** Turns the block the caret is in. The handle puts the caret there first. */
13
- run: (editor: Editor) => void
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
- /** Turn into's rows. Offered for a block of text — a paragraph, a heading, code. */
22
- turnInto?: readonly BlockTurnInto[]
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 = [], actions, children, className }: EditorBlockHandleProps) {
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
- {node.isTextblock && turnInto.length > 0 && (
146
+ {turnRows.length > 0 && (
136
147
  <MenuSub label="Turn into">
137
- {turnInto.map((row) => (
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 the block's text, so the row's command turns this block and no other.
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 { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
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 162 names over 80 files: 114 components, 46 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)', () => {
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: 114, helper: 46, hook: 2 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(80)
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(76)
64
- expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(86)
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', () => {
@@ -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 |