@estiva-app/ui 0.47.0 → 0.49.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.
Files changed (41) hide show
  1. package/README.md +6 -0
  2. package/dist/BlockNodes.d.ts +73 -0
  3. package/dist/BlockNodes.d.ts.map +1 -0
  4. package/dist/DialogShell.d.ts.map +1 -1
  5. package/dist/editor.d.ts +1 -0
  6. package/dist/editor.d.ts.map +1 -1
  7. package/dist/editor.js +141 -0
  8. package/dist/editor.js.map +4 -4
  9. package/dist/eslint/index.d.ts +2 -0
  10. package/dist/eslint/index.d.ts.map +1 -1
  11. package/dist/eslint/index.js +84 -25
  12. package/dist/eslint/index.js.map +4 -4
  13. package/dist/eslint/no-field-outside-a-form.d.ts +35 -0
  14. package/dist/eslint/no-field-outside-a-form.d.ts.map +1 -0
  15. package/dist/eslint/rule-ids.d.ts +1 -1
  16. package/dist/eslint/rule-ids.d.ts.map +1 -1
  17. package/dist/gates/{chunk-DZVDOJGV.js → chunk-FIBZLYQI.js} +3 -2
  18. package/dist/gates/{chunk-DZVDOJGV.js.map → chunk-FIBZLYQI.js.map} +2 -2
  19. package/dist/gates/create-app.js +1 -1
  20. package/dist/gates/index.js +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +2 -2
  23. package/package.json +1 -1
  24. package/registry.json +79 -3
  25. package/src/BlockNodes.mdx +82 -0
  26. package/src/BlockNodes.stories.tsx +79 -0
  27. package/src/BlockNodes.test.tsx +117 -0
  28. package/src/BlockNodes.tsx +242 -0
  29. package/src/DialogShell.stories.tsx +18 -0
  30. package/src/DialogShell.tsx +4 -2
  31. package/src/Form.mdx +4 -0
  32. package/src/editor.ts +10 -0
  33. package/src/eslint/index.test.ts +6 -4
  34. package/src/eslint/index.ts +4 -0
  35. package/src/eslint/no-field-outside-a-form.test.ts +63 -0
  36. package/src/eslint/no-field-outside-a-form.ts +92 -0
  37. package/src/eslint/rule-ids.ts +1 -0
  38. package/src/gates/create-app.test.ts +1 -1
  39. package/src/gates/gates.test.ts +1 -1
  40. package/src/registry/registry.test.ts +10 -8
  41. package/stories/Choosing.mdx +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.47.0",
3
+ "version": "0.49.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.47.0",
8
- "exports": 100,
9
- "typeExports": 109,
7
+ "packageVersion": "0.49.0",
8
+ "exports": 104,
9
+ "typeExports": 113,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -250,6 +250,25 @@
250
250
  "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity"
251
251
  ]
252
252
  },
253
+ {
254
+ "name": "AttachmentNode",
255
+ "repo": "estiva-ui",
256
+ "kind": "component",
257
+ "importPath": "@estiva-app/ui/editor",
258
+ "sourceFile": "src/BlockNodes.tsx",
259
+ "purpose": "A file placed in a document — SPEC §13.3's `attachment` block (SHI-2).",
260
+ "purposeFrom": "comment",
261
+ "props": [],
262
+ "variants": [],
263
+ "ownsBehaviours": [],
264
+ "status": "stable",
265
+ "migrationStage": null,
266
+ "docsId": "primitives-blocknodes--docs",
267
+ "storyId": "primitives-blocknodes--default",
268
+ "docPage": null,
269
+ "app": null,
270
+ "looks": []
271
+ },
253
272
  {
254
273
  "name": "Avatar",
255
274
  "repo": "estiva-ui",
@@ -420,6 +439,25 @@
420
439
  "app": null,
421
440
  "looks": []
422
441
  },
442
+ {
443
+ "name": "BlockId",
444
+ "repo": "estiva-ui",
445
+ "kind": "component",
446
+ "importPath": "@estiva-app/ui/editor",
447
+ "sourceFile": "src/BlockNodes.tsx",
448
+ "purpose": "`id` on every block, carried as the `blockId` attribute.",
449
+ "purposeFrom": "comment",
450
+ "props": [],
451
+ "variants": [],
452
+ "ownsBehaviours": [],
453
+ "status": "stable",
454
+ "migrationStage": null,
455
+ "docsId": "primitives-blocknodes--docs",
456
+ "storyId": "primitives-blocknodes--default",
457
+ "docPage": null,
458
+ "app": null,
459
+ "looks": []
460
+ },
423
461
  {
424
462
  "name": "Breadcrumb",
425
463
  "repo": "estiva-ui",
@@ -4274,6 +4312,25 @@
4274
4312
  "app": null,
4275
4313
  "looks": []
4276
4314
  },
4315
+ {
4316
+ "name": "ReferenceNode",
4317
+ "repo": "estiva-ui",
4318
+ "kind": "component",
4319
+ "importPath": "@estiva-app/ui/editor",
4320
+ "sourceFile": "src/BlockNodes.tsx",
4321
+ "purpose": "A `nostr:` reference, drawn as the thing it points at — SPEC §13.1.",
4322
+ "purposeFrom": "comment",
4323
+ "props": [],
4324
+ "variants": [],
4325
+ "ownsBehaviours": [],
4326
+ "status": "stable",
4327
+ "migrationStage": null,
4328
+ "docsId": "primitives-blocknodes--docs",
4329
+ "storyId": "primitives-blocknodes--default",
4330
+ "docPage": null,
4331
+ "app": null,
4332
+ "looks": []
4333
+ },
4277
4334
  {
4278
4335
  "name": "RichText",
4279
4336
  "repo": "estiva-ui",
@@ -6008,6 +6065,25 @@
6008
6065
  "app": null,
6009
6066
  "looks": []
6010
6067
  },
6068
+ {
6069
+ "name": "UnknownBlock",
6070
+ "repo": "estiva-ui",
6071
+ "kind": "component",
6072
+ "importPath": "@estiva-app/ui/editor",
6073
+ "sourceFile": "src/BlockNodes.tsx",
6074
+ "purpose": "A block this editor has no design for, kept whole.",
6075
+ "purposeFrom": "comment",
6076
+ "props": [],
6077
+ "variants": [],
6078
+ "ownsBehaviours": [],
6079
+ "status": "stable",
6080
+ "migrationStage": null,
6081
+ "docsId": "primitives-blocknodes--docs",
6082
+ "storyId": "primitives-blocknodes--default",
6083
+ "docPage": null,
6084
+ "app": null,
6085
+ "looks": []
6086
+ },
6011
6087
  {
6012
6088
  "name": "useToast",
6013
6089
  "repo": "estiva-ui",
@@ -0,0 +1,82 @@
1
+ import { Meta, Canvas } from '@storybook/addon-docs/blocks'
2
+ import * as BlockNodesStories from './BlockNodes.stories'
3
+
4
+ <Meta of={BlockNodesStories} />
5
+
6
+ # BlockNodes
7
+
8
+ The Tiptap nodes an editor of a block document needs beside StarterKit: an id
9
+ on every block, a block it cannot edit kept whole, a reference, and a file.
10
+
11
+ <Canvas of={BlockNodesStories.Default} />
12
+
13
+ ## When
14
+
15
+ - **An editor that saves a block document** (SPEC §13.3) — anywhere a comment
16
+ can be anchored to one of its blocks. `BlockId` keeps each block's id across
17
+ every edit, so the anchor still finds its block after a save.
18
+ - Always with `@estiva-app/protocol`'s `toEditorDocument` to load the document
19
+ and `fromEditorDocument` (or `publishableFromEditor`) to save it. The node
20
+ names and the `blockId` attribute are that translation's.
21
+
22
+ ## When not
23
+
24
+ - Reading a document → **RichText**. Nothing here draws a document that is not
25
+ being edited.
26
+ - A message composer that writes marker text. It has no block ids to keep.
27
+
28
+ ## How
29
+
30
+ ```tsx
31
+ import type { JSONContent } from '@tiptap/core'
32
+ import { EditorContent, useEditor } from '@tiptap/react'
33
+ import StarterKit from '@tiptap/starter-kit'
34
+ import {
35
+ AttachmentNode,
36
+ BlockId,
37
+ ReferenceNode,
38
+ UnknownBlock,
39
+ type AttachmentViewProps,
40
+ type ReferenceViewProps,
41
+ } from '@estiva-app/ui/editor'
42
+
43
+ // toEditorDocument(documentForEditing(value, format)), from @estiva-app/protocol
44
+ const opened: JSONContent = { type: 'doc', content: [] }
45
+
46
+ const Reference = ({ uri }: ReferenceViewProps) => <span>{uri}</span>
47
+ const File = ({ attrs }: AttachmentViewProps) => <span>{String(attrs.filename)}</span>
48
+
49
+ const editor = useEditor({
50
+ extensions: [
51
+ StarterKit,
52
+ BlockId,
53
+ UnknownBlock,
54
+ ReferenceNode.configure({ view: Reference }),
55
+ AttachmentNode.configure({ view: File }),
56
+ ],
57
+ content: opened,
58
+ })
59
+
60
+ <EditorContent editor={editor} />
61
+ ```
62
+
63
+ - **Load and save through protocol.** `toEditorDocument(documentForEditing(value, format))`
64
+ opens the stored value; `publishableFromEditor(editor.getJSON())` is what a
65
+ save writes, with its `contentFormat`.
66
+ - **Add TableKit** (`@tiptap/extension-table`) beside StarterKit. The
67
+ translation opens a table as `table`, `tableRow`, `tableHeader` and
68
+ `tableCell` nodes, and ProseMirror drops a node its schema does not have —
69
+ the save would delete the table.
70
+ - `ReferenceNode`'s `view` gets the `uri` and draws the thing it points at,
71
+ resolved by the app. Without one, the URI is drawn as text.
72
+ - `AttachmentNode`'s `view` gets the block's `imeta` fields as `attrs`.
73
+ Without one, the file's name is drawn as text.
74
+ - `UnknownBlock` draws a block the editor has no design for as its text, in a
75
+ box. It cannot be changed here, only moved or deleted, and it is saved back
76
+ exactly as it arrived.
77
+
78
+ <Canvas of={BlockNodesStories.WithoutViews} />
79
+
80
+ ## What it owns
81
+
82
+ Nothing. It only draws. Clicks and keys are yours.
@@ -0,0 +1,79 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { EditorContent, useEditor } from '@tiptap/react'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import { AttachmentCard } from './AttachmentCard'
5
+ import { InlineChip } from './InlineChip'
6
+ import { richTextClassName } from './RichText'
7
+ import { AttachmentNode, BlockId, ReferenceNode, UnknownBlock, type AttachmentViewProps, type ReferenceViewProps } from './BlockNodes'
8
+
9
+ /**
10
+ * The nodes an editor of a block document adds to StarterKit: an id on every
11
+ * block, a block it cannot edit kept whole, a reference and a file. The app
12
+ * draws the reference and the file with its own views.
13
+ */
14
+ const meta = {
15
+ title: 'Primitives/BlockNodes',
16
+ parameters: { layout: 'padded' },
17
+ } satisfies Meta
18
+
19
+ export default meta
20
+ type Story = StoryObj
21
+
22
+ /** What an app hands `ReferenceNode`: here a chip with a fixed label. */
23
+ function Chip({ uri }: ReferenceViewProps) {
24
+ return <InlineChip data-uri={uri}>Item one</InlineChip>
25
+ }
26
+
27
+ /** What an app hands `AttachmentNode`: here the package's card. */
28
+ function File({ attrs }: AttachmentViewProps) {
29
+ return <AttachmentCard name={String(attrs.filename ?? 'File')} size={Number(attrs.size ?? 0)} contentType={String(attrs.m ?? '')} />
30
+ }
31
+
32
+ /*
33
+ The editor's own JSON, as `@estiva-app/protocol`'s `toEditorDocument` hands
34
+ it over: an id on each block, and an unknown block carrying its text.
35
+ */
36
+ const DOCUMENT = {
37
+ type: 'doc',
38
+ content: [
39
+ {
40
+ type: 'paragraph',
41
+ attrs: { blockId: 'b1' },
42
+ content: [
43
+ { type: 'text', text: 'A paragraph, with a reference in it: ' },
44
+ { type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: [] } },
45
+ ],
46
+ },
47
+ {
48
+ type: 'unknownBlock',
49
+ attrs: { blockId: 'b2', source: { type: 'widget', id: 'b2' }, text: 'A block this editor has no design for, kept as it is.' },
50
+ },
51
+ { type: 'attachment', attrs: { blockId: 'b3', url: '', m: 'application/pdf', x: '', size: 20480, filename: 'Label.pdf' } },
52
+ { type: 'paragraph', attrs: { blockId: 'b4' }, content: [{ type: 'text', text: 'Type here: every block keeps its id.' }] },
53
+ ],
54
+ }
55
+
56
+ function WithAnEditor({ views }: { views: boolean }) {
57
+ const editor = useEditor({
58
+ extensions: [
59
+ StarterKit,
60
+ BlockId,
61
+ UnknownBlock,
62
+ ReferenceNode.configure({ view: views ? Chip : null }),
63
+ AttachmentNode.configure({ view: views ? File : null }),
64
+ ],
65
+ content: DOCUMENT,
66
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
67
+ })
68
+ return <EditorContent editor={editor} className="max-w-[560px]" />
69
+ }
70
+
71
+ /** A reference and a file drawn by the app's views, and a block kept whole. */
72
+ export const Default: Story = {
73
+ render: () => <WithAnEditor views />,
74
+ }
75
+
76
+ /** No views given: the reference is its URI and the file its name, as text. */
77
+ export const WithoutViews: Story = {
78
+ render: () => <WithAnEditor views={false} />,
79
+ }
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The block nodes (MAN-9, moved from Ship's `editorSchema.tsx`): what the
4
+ * editor must hand back unchanged for a save to keep every anchored comment.
5
+ *
6
+ * The JSON loaded here is what `@estiva-app/protocol`'s `toEditorDocument`
7
+ * produces, and what `getJSON()` returns is what its `fromEditorDocument`
8
+ * reads — so each test is a load, an edit or none, and a read back.
9
+ */
10
+ import { afterEach, describe, expect, it } from 'vitest'
11
+ import { render, screen, cleanup } from '@testing-library/react'
12
+ import { Editor } from '@tiptap/core'
13
+ import { EditorContent, useEditor } from '@tiptap/react'
14
+ import StarterKit from '@tiptap/starter-kit'
15
+ import { AttachmentNode, BlockId, ReferenceNode, UnknownBlock, type ReferenceViewProps } from './BlockNodes'
16
+
17
+ const EXTENSIONS = [StarterKit, BlockId, UnknownBlock, ReferenceNode, AttachmentNode]
18
+
19
+ const SOURCE = { type: 'widget', id: 'w1', content: [{ type: 'text', text: 'kept' }] }
20
+ const FILE = { url: '/media/f.png', m: 'image/png', x: 'f'.repeat(64), size: 4096 }
21
+
22
+ const DOCUMENT = {
23
+ type: 'doc',
24
+ content: [
25
+ { type: 'heading', attrs: { level: 1, blockId: 'h1' }, content: [{ type: 'text', text: 'Title' }] },
26
+ {
27
+ type: 'paragraph',
28
+ attrs: { blockId: 'p1' },
29
+ content: [
30
+ { type: 'text', text: 'see ' },
31
+ { type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: ['bold'] } },
32
+ ],
33
+ },
34
+ { type: 'unknownBlock', attrs: { blockId: 'w1', source: SOURCE, text: 'kept' } },
35
+ { type: 'attachment', attrs: { blockId: 'a1', ...FILE, filename: 'shot.png' } },
36
+ { type: 'bulletList', attrs: { blockId: 'l1' }, content: [{ type: 'listItem', attrs: { blockId: 'i1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }] }] },
37
+ ],
38
+ }
39
+
40
+ type Json = { type: string; attrs?: Record<string, unknown>; content?: Json[] }
41
+
42
+ let editor: Editor | undefined
43
+ afterEach(() => {
44
+ editor?.destroy()
45
+ editor = undefined
46
+ cleanup()
47
+ })
48
+
49
+ describe('what the editor hands back', () => {
50
+ it('keeps every block id through a load and a read', () => {
51
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
52
+ const back = editor.getJSON() as Json
53
+ expect(back.content?.map((n) => n.attrs?.blockId)).toEqual(['h1', 'p1', 'w1', 'a1', 'l1'])
54
+ expect(back.content?.[4].content?.[0].attrs?.blockId).toBe('i1')
55
+ })
56
+
57
+ it('keeps a block id when its text is typed into', () => {
58
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
59
+ // The end of the heading's text: 1 opens the heading, 5 characters of "Title".
60
+ editor.chain().setTextSelection(6).insertContent(', edited').run()
61
+ const heading = (editor.getJSON() as Json).content?.[0]
62
+ expect(heading?.attrs?.blockId).toBe('h1')
63
+ expect(JSON.stringify(heading)).toContain('Title, edited')
64
+ })
65
+
66
+ it('carries an unknown block whole, source and all', () => {
67
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
68
+ const kept = (editor.getJSON() as Json).content?.[2]
69
+ expect(kept?.type).toBe('unknownBlock')
70
+ expect(kept?.attrs?.source).toEqual(SOURCE)
71
+ })
72
+
73
+ it('keeps a reference an atom, with its uri and the marks it carries', () => {
74
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
75
+ const reference = (editor.getJSON() as Json).content?.[1].content?.[1]
76
+ expect(reference).toEqual({ type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: ['bold'] } })
77
+ })
78
+
79
+ it('keeps every imeta field on a file, which ProseMirror drops when undeclared', () => {
80
+ const rich = { ...FILE, dim: '800x600', thumb: '/media/f.thumb.jpg', alt: 'a picture', filename: 'shot.png' }
81
+ editor = new Editor({ extensions: EXTENSIONS, content: { type: 'doc', content: [{ type: 'attachment', attrs: { blockId: 'a1', ...rich } }] } })
82
+ expect((editor.getJSON() as Json).content?.[0].attrs).toEqual({ blockId: 'a1', ...rich })
83
+ })
84
+ })
85
+
86
+ describe('what it draws', () => {
87
+ function WithAnEditor({ view }: { view: ((props: ReferenceViewProps) => React.ReactNode) | null }) {
88
+ const e = useEditor({
89
+ extensions: [StarterKit, BlockId, UnknownBlock, ReferenceNode.configure({ view }), AttachmentNode],
90
+ content: DOCUMENT,
91
+ })
92
+ return <EditorContent editor={e} />
93
+ }
94
+
95
+ it('draws a reference with the view it is given', async () => {
96
+ render(<WithAnEditor view={({ uri }) => <span data-testid="chip">{uri}</span>} />)
97
+ expect((await screen.findByTestId('chip')).textContent).toBe('nostr:naddr1example')
98
+ })
99
+
100
+ it('draws a reference as its uri without a view', async () => {
101
+ const { container } = render(<WithAnEditor view={null} />)
102
+ await screen.findByText('Title')
103
+ expect(container.querySelector('[data-reference]')?.textContent).toBe('nostr:naddr1example')
104
+ })
105
+
106
+ it('draws an unknown block as its text, addressed by its id', async () => {
107
+ const { container } = render(<WithAnEditor view={null} />)
108
+ expect((await screen.findByText('kept')).closest('[data-block-id]')?.getAttribute('data-block-id')).toBe('w1')
109
+ expect(container.querySelector('[data-block-type="widget"]')).not.toBeNull()
110
+ })
111
+
112
+ it('puts data-block-id on each block, as RichText does', async () => {
113
+ const { container } = render(<WithAnEditor view={null} />)
114
+ await screen.findByText('Title')
115
+ expect(container.querySelector('h1')?.getAttribute('data-block-id')).toBe('h1')
116
+ })
117
+ })
@@ -0,0 +1,242 @@
1
+ import type { ComponentType } from 'react'
2
+ import { Extension, Node } from '@tiptap/core'
3
+ import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react'
4
+
5
+ /*
6
+ * The four nodes an editor of a block document needs that a stock schema does
7
+ * not have (SPEC §13.3) — moved here from Ship's `editorSchema.tsx` (RIC-14,
8
+ * SHI-2) when Peek became the second editor of the same documents (MAN-9).
9
+ *
10
+ * They are one half of a pair. The other half is `@estiva-app/protocol`'s
11
+ * `toEditorDocument` / `fromEditorDocument`, which turn a stored document into
12
+ * the JSON these nodes read and back: the node names (`unknownBlock`,
13
+ * `reference`, `attachment`) and the `blockId` attribute are that translation's,
14
+ * and must not drift from it. This package does not import protocol, as
15
+ * `RichText` does not: the translation hands over everything a view draws.
16
+ *
17
+ * Everything else a document can contain is already a ProseMirror node of the
18
+ * same name — StarterKit's and TableKit's — which is why this file is short.
19
+ */
20
+
21
+ /**
22
+ * The block types §13.3 puts an id on, as the editor's node names —
23
+ * protocol's `EDITOR_BLOCK_TYPES`, less `unknownBlock`, which declares its own.
24
+ */
25
+ const BLOCK_TYPES = [
26
+ 'paragraph',
27
+ 'heading',
28
+ 'bulletList',
29
+ 'orderedList',
30
+ 'listItem',
31
+ 'blockquote',
32
+ 'codeBlock',
33
+ 'horizontalRule',
34
+ // A table and everything inside it (UIG-18): a row or a cell whose id was
35
+ // re-minted on each save would detach any comment anchored to it.
36
+ 'table',
37
+ 'tableRow',
38
+ 'tableHeader',
39
+ 'tableCell',
40
+ // Without this an attachment's id is re-minted on every save, and every
41
+ // comment anchored to it stops resolving.
42
+ 'attachment',
43
+ ]
44
+
45
+ /**
46
+ * `id` on every block, carried as the `blockId` attribute.
47
+ *
48
+ * A global attribute rather than one per node type, because §13.3 puts an id
49
+ * on **every** block and a type that quietly lacked one would mint a fresh id
50
+ * on every save — detaching each anchored comment on it (RFC 0.4 §6) with
51
+ * nothing on screen to say so.
52
+ *
53
+ * Rendered as `data-block-id`, which is what `RichText` already emits, so the
54
+ * reading and the editing surface address a block the same way.
55
+ */
56
+ export const BlockId = Extension.create({
57
+ name: 'blockId',
58
+ addGlobalAttributes() {
59
+ return [
60
+ {
61
+ types: BLOCK_TYPES,
62
+ attributes: {
63
+ blockId: {
64
+ default: null,
65
+ parseHTML: (element: HTMLElement) => element.getAttribute('data-block-id'),
66
+ renderHTML: (attributes: Record<string, unknown>) =>
67
+ attributes.blockId ? { 'data-block-id': String(attributes.blockId) } : {},
68
+ },
69
+ },
70
+ },
71
+ ]
72
+ },
73
+ })
74
+
75
+ /** What a reference's view is handed: the `nostr:` URI it stands for. */
76
+ export interface ReferenceViewProps {
77
+ uri: string
78
+ }
79
+
80
+ export interface ReferenceNodeOptions {
81
+ /**
82
+ * Draws the reference as the thing it points at — the app's own chip, which
83
+ * knows how to resolve it. Without one, the URI is drawn as text.
84
+ */
85
+ view: ComponentType<ReferenceViewProps> | null
86
+ }
87
+
88
+ /**
89
+ * A `nostr:` reference, drawn as the thing it points at — SPEC §13.1.
90
+ *
91
+ * An **atom**: it has no editable interior, because its text is the URI and
92
+ * nobody edits half a `naddr`. Selecting it selects the whole reference, which
93
+ * is also how deleting one works. `marks` carries the run's other marks, so a
94
+ * bold reference comes back bold.
95
+ */
96
+ export const ReferenceNode = Node.create<ReferenceNodeOptions>({
97
+ name: 'reference',
98
+ group: 'inline',
99
+ inline: true,
100
+ atom: true,
101
+ selectable: true,
102
+ draggable: false,
103
+ addOptions() {
104
+ return { view: null }
105
+ },
106
+ addAttributes() {
107
+ return {
108
+ uri: { default: '' },
109
+ marks: { default: [] as string[] },
110
+ }
111
+ },
112
+ parseHTML() {
113
+ return [{ tag: 'span[data-reference]' }]
114
+ },
115
+ renderHTML({ node }) {
116
+ // A copy to the clipboard as HTML, and the whole drawing when no view is given.
117
+ return ['span', { 'data-reference': String(node.attrs.uri) }, String(node.attrs.uri)]
118
+ },
119
+ addNodeView() {
120
+ const View = this.options.view
121
+ if (!View) return null
122
+ return ReactNodeViewRenderer(({ node }: ReactNodeViewProps) => (
123
+ <NodeViewWrapper as="span" className="inline">
124
+ <View uri={String(node.attrs.uri ?? '')} />
125
+ </NodeViewWrapper>
126
+ ))
127
+ },
128
+ })
129
+
130
+ /**
131
+ * A block this editor has no design for, kept whole.
132
+ *
133
+ * §13.3: a reader MUST render an unknown block's inline text and MUST NOT drop
134
+ * it silently. **An editor is the dangerous case** — ProseMirror discards a
135
+ * node its schema does not know, and the save after that would delete somebody
136
+ * else's block with no error anywhere on the way. So the block's own JSON
137
+ * rides in `source`, and protocol's `fromEditorDocument` hands it back
138
+ * untouched; `text` is its inline text, worked out by `toEditorDocument`.
139
+ *
140
+ * Not editable, deliberately: an affordance that lets a person change
141
+ * something they cannot see is worse than one that says the content is there
142
+ * and belongs to another surface.
143
+ */
144
+ export const UnknownBlock = Node.create({
145
+ name: 'unknownBlock',
146
+ group: 'block',
147
+ atom: true,
148
+ selectable: true,
149
+ addAttributes() {
150
+ return {
151
+ blockId: { default: null },
152
+ source: { default: null },
153
+ text: { default: '' },
154
+ }
155
+ },
156
+ parseHTML() {
157
+ return [{ tag: 'div[data-unknown-block]' }]
158
+ },
159
+ renderHTML({ node }) {
160
+ return ['div', { 'data-unknown-block': String(node.attrs.blockId ?? '') }, String(node.attrs.text ?? '')]
161
+ },
162
+ addNodeView() {
163
+ return ReactNodeViewRenderer(UnknownBlockView)
164
+ },
165
+ })
166
+
167
+ function UnknownBlockView({ node }: ReactNodeViewProps) {
168
+ const source = node.attrs.source as { id?: string; type?: string } | null
169
+ const text = String(node.attrs.text ?? '')
170
+ return (
171
+ <NodeViewWrapper
172
+ data-block-id={source?.id}
173
+ data-block-type={source?.type}
174
+ className="rounded-md border border-border-default bg-bg-inset px-2 py-1 text-text-secondary"
175
+ >
176
+ {text || `A ${source?.type ?? 'block'} this app cannot edit yet.`}
177
+ </NodeViewWrapper>
178
+ )
179
+ }
180
+
181
+ /** What an attachment's view is handed: the block's `imeta` fields. */
182
+ export interface AttachmentViewProps {
183
+ attrs: Record<string, unknown>
184
+ }
185
+
186
+ export interface AttachmentNodeOptions {
187
+ /** Draws the file — the app's own attachment card. Without one, its name is drawn as text. */
188
+ view: ComponentType<AttachmentViewProps> | null
189
+ }
190
+
191
+ /**
192
+ * A file placed in a document — SPEC §13.3's `attachment` block (SHI-2).
193
+ *
194
+ * An atom: the file is the block, there is nothing inside it to edit, and a
195
+ * caret that could sit "inside" an image is a way to produce documents no
196
+ * reader has a rule for. Selectable and deletable, so removing one is Backspace
197
+ * like anything else.
198
+ *
199
+ * Its attributes are the `imeta` fields exactly, because that is what §13.3
200
+ * specifies. Each is declared, and that is not boilerplate: ProseMirror **drops
201
+ * an attribute its schema does not name**, so an undeclared `thumb` would
202
+ * survive the editor and vanish on save — the silent deletion `UnknownBlock`
203
+ * exists to prevent.
204
+ */
205
+ export const AttachmentNode = Node.create<AttachmentNodeOptions>({
206
+ name: 'attachment',
207
+ group: 'block',
208
+ atom: true,
209
+ selectable: true,
210
+ draggable: false,
211
+ addOptions() {
212
+ return { view: null }
213
+ },
214
+ addAttributes() {
215
+ return {
216
+ url: { default: '' },
217
+ m: { default: '' },
218
+ x: { default: '' },
219
+ size: { default: 0 },
220
+ dim: { default: null },
221
+ thumb: { default: null },
222
+ alt: { default: null },
223
+ filename: { default: null },
224
+ }
225
+ },
226
+ parseHTML() {
227
+ return [{ tag: 'div[data-attachment-block]' }]
228
+ },
229
+ renderHTML({ node }) {
230
+ // A copy to the clipboard, and the whole drawing when no view is given.
231
+ return ['div', { 'data-attachment-block': String(node.attrs.x ?? '') }, String(node.attrs.filename ?? 'attachment')]
232
+ },
233
+ addNodeView() {
234
+ const View = this.options.view
235
+ if (!View) return null
236
+ return ReactNodeViewRenderer(({ node }: ReactNodeViewProps) => (
237
+ <NodeViewWrapper className="my-2">
238
+ <View attrs={node.attrs} />
239
+ </NodeViewWrapper>
240
+ ))
241
+ },
242
+ })
@@ -53,6 +53,24 @@ export const Default: Story = {
53
53
  },
54
54
  }
55
55
 
56
+ /**
57
+ * **A long title keeps to its one line** and ends in "…", so the bar stays
58
+ * 48px and the ✕ stays where it is. A title that names something a person
59
+ * typed — a file's name in "Archive “…”?" — is the one that gets long.
60
+ */
61
+ export const ALongTitle: Story = {
62
+ args: {
63
+ title: 'Archive “Interesting Claude-generated docs - Renamed 4, with notes from the second review”?',
64
+ footer: (
65
+ <>
66
+ <Button variant="muted">Cancel</Button>
67
+ <Button variant="primary">Archive topic</Button>
68
+ </>
69
+ ),
70
+ children: <p className="text-body-2 text-text-primary">This hides the topic from every list.</p>,
71
+ },
72
+ }
73
+
56
74
  /**
57
75
  * **`headerContent` replaces the title text** — a back button beside it, a
58
76
  * count after it — and the ✕ stays. The dialog is then named by its `title`
@@ -115,11 +115,13 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
115
115
  className="bg-bg-elevated border border-border-subtle rounded-lg shadow-lg pointer-events-auto flex flex-col overflow-hidden outline-none"
116
116
  style={{ width }}
117
117
  >
118
- {/* Header: the package's one header bar, with the dialog's own title in it (UIG-22). */}
118
+ {/* Header: the package's one header bar, with the dialog's own title in it (UIG-22).
119
+ The title keeps to its one line and ends in "…": a long name wrapped to
120
+ two lines in the 48px bar and lost its top (Katerina, 1 October). */}
119
121
  <ContainerHeader
120
122
  title={
121
123
  headerContent ?? (
122
- <Parts.Title className="text-h4 text-text-primary" render={<span />}>
124
+ <Parts.Title className="block truncate text-h4 text-text-primary" render={<span />}>
123
125
  {title}
124
126
  </Parts.Title>
125
127
  )