@estiva-app/ui 0.69.2 → 0.70.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/PastedMarkdown.d.ts +24 -0
- package/dist/PastedMarkdown.d.ts.map +1 -0
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +72 -30
- package/dist/editor.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +22 -3
- 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 +1 -0
- package/src/pages.test.ts +1 -0
- package/src/registry/registry.test.ts +5 -5
- package/stories/Choosing.mdx +1 -0
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
7
|
+
"packageVersion": "0.70.0",
|
|
8
|
+
"exports": 163,
|
|
9
|
+
"typeExports": 153,
|
|
10
10
|
"files": null
|
|
11
11
|
},
|
|
12
12
|
"storybook": {
|
|
@@ -4819,6 +4819,25 @@
|
|
|
4819
4819
|
"app": null,
|
|
4820
4820
|
"looks": []
|
|
4821
4821
|
},
|
|
4822
|
+
{
|
|
4823
|
+
"name": "PastedMarkdown",
|
|
4824
|
+
"repo": "estiva-ui",
|
|
4825
|
+
"kind": "component",
|
|
4826
|
+
"importPath": "@estiva-app/ui/editor",
|
|
4827
|
+
"sourceFile": "src/PastedMarkdown.ts",
|
|
4828
|
+
"purpose": "Markdown pasted into an editor as plain text lands as what it spells: a heading, a list, a checklist, a quote or callout, a table.",
|
|
4829
|
+
"purposeFrom": "page",
|
|
4830
|
+
"props": [],
|
|
4831
|
+
"variants": [],
|
|
4832
|
+
"ownsBehaviours": [],
|
|
4833
|
+
"status": "stable",
|
|
4834
|
+
"migrationStage": null,
|
|
4835
|
+
"docsId": "primitives-pastedmarkdown--docs",
|
|
4836
|
+
"storyId": "primitives-pastedmarkdown--in-an-editor",
|
|
4837
|
+
"docPage": "src/PastedMarkdown.mdx",
|
|
4838
|
+
"app": null,
|
|
4839
|
+
"looks": []
|
|
4840
|
+
},
|
|
4822
4841
|
{
|
|
4823
4842
|
"name": "peopleMenu",
|
|
4824
4843
|
"repo": "estiva-ui",
|
|
@@ -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,6 +9,7 @@
|
|
|
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
15
|
export { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
|
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,19 +48,19 @@ 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 163 names over 81 files: 115 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; PastedMarkdown, markdown pasted as the blocks it spells (LEAF-10), 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: 46, 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(
|
|
63
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(77)
|
|
64
64
|
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(86)
|
|
65
65
|
})
|
|
66
66
|
|
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 |
|