@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.
- package/README.md +6 -0
- package/dist/BlockNodes.d.ts +73 -0
- package/dist/BlockNodes.d.ts.map +1 -0
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +141 -0
- package/dist/editor.js.map +4 -4
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +84 -25
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-field-outside-a-form.d.ts +35 -0
- package/dist/eslint/no-field-outside-a-form.d.ts.map +1 -0
- package/dist/eslint/rule-ids.d.ts +1 -1
- package/dist/eslint/rule-ids.d.ts.map +1 -1
- package/dist/gates/{chunk-DZVDOJGV.js → chunk-FIBZLYQI.js} +3 -2
- package/dist/gates/{chunk-DZVDOJGV.js.map → chunk-FIBZLYQI.js.map} +2 -2
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/registry.json +79 -3
- package/src/BlockNodes.mdx +82 -0
- package/src/BlockNodes.stories.tsx +79 -0
- package/src/BlockNodes.test.tsx +117 -0
- package/src/BlockNodes.tsx +242 -0
- package/src/DialogShell.stories.tsx +18 -0
- package/src/DialogShell.tsx +4 -2
- package/src/Form.mdx +4 -0
- package/src/editor.ts +10 -0
- package/src/eslint/index.test.ts +6 -4
- package/src/eslint/index.ts +4 -0
- package/src/eslint/no-field-outside-a-form.test.ts +63 -0
- package/src/eslint/no-field-outside-a-form.ts +92 -0
- package/src/eslint/rule-ids.ts +1 -0
- package/src/gates/create-app.test.ts +1 -1
- package/src/gates/gates.test.ts +1 -1
- package/src/registry/registry.test.ts +10 -8
- 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.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`
|
package/src/DialogShell.tsx
CHANGED
|
@@ -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
|
)
|