@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.69.2",
3
+ "version": "0.70.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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.69.2",
8
- "exports": 162,
9
- "typeExports": 152,
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 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 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: 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: 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(76)
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
 
@@ -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 |