@estiva-app/ui 0.70.0 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.70.0",
3
+ "version": "0.71.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.70.0",
8
- "exports": 163,
9
- "typeExports": 153,
7
+ "packageVersion": "0.71.0",
8
+ "exports": 166,
9
+ "typeExports": 155,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -2335,9 +2335,9 @@
2335
2335
  },
2336
2336
  {
2337
2337
  "name": "turnInto",
2338
- "takes": "readonly BlockTurnInto[]",
2338
+ "takes": "a handler",
2339
2339
  "required": false,
2340
- "note": "Turn into's rows. Offered for a block of text — a paragraph, a heading, code."
2340
+ "note": "Turn into's rows for a block. By default `turnIntoRows`, the same in every app: a block of text, a list and a quote each get theirs."
2341
2341
  },
2342
2342
  {
2343
2343
  "name": "actions",
@@ -7786,6 +7786,63 @@
7786
7786
  "truncate text-left text-input-value text-text-muted"
7787
7787
  ]
7788
7788
  },
7789
+ {
7790
+ "name": "turnIntoRows",
7791
+ "repo": "estiva-ui",
7792
+ "kind": "helper",
7793
+ "importPath": "@estiva-app/ui/editor",
7794
+ "sourceFile": "src/EditorBlockHandle.tsx",
7795
+ "purpose": "The block menu's Turn into for one block, the same in every app (Miky, LEAF-11's kickoff, 15 October):",
7796
+ "purposeFrom": "comment",
7797
+ "props": [],
7798
+ "variants": [],
7799
+ "ownsBehaviours": [],
7800
+ "status": "stable",
7801
+ "migrationStage": null,
7802
+ "docsId": "primitives-editorblockhandle--docs",
7803
+ "storyId": "primitives-editorblockhandle--default",
7804
+ "docPage": null,
7805
+ "app": null,
7806
+ "looks": []
7807
+ },
7808
+ {
7809
+ "name": "turnListInto",
7810
+ "repo": "estiva-ui",
7811
+ "kind": "helper",
7812
+ "importPath": "@estiva-app/ui/editor",
7813
+ "sourceFile": "src/EditorBlockHandle.tsx",
7814
+ "purpose": "Turns the whole list at `pos`, every item, the nested ones too.",
7815
+ "purposeFrom": "comment",
7816
+ "props": [],
7817
+ "variants": [],
7818
+ "ownsBehaviours": [],
7819
+ "status": "stable",
7820
+ "migrationStage": null,
7821
+ "docsId": "primitives-editorblockhandle--docs",
7822
+ "storyId": "primitives-editorblockhandle--default",
7823
+ "docPage": null,
7824
+ "app": null,
7825
+ "looks": []
7826
+ },
7827
+ {
7828
+ "name": "turnQuoteInto",
7829
+ "repo": "estiva-ui",
7830
+ "kind": "helper",
7831
+ "importPath": "@estiva-app/ui/editor",
7832
+ "sourceFile": "src/EditorBlockHandle.tsx",
7833
+ "purpose": "Turns the quote at `pos`: a plain quote, a Note or Warning callout (the tone changes, the id stays), or its text, whose first paragraph takes the quote's id, as lifting it out does (MAN-12).",
7834
+ "purposeFrom": "comment",
7835
+ "props": [],
7836
+ "variants": [],
7837
+ "ownsBehaviours": [],
7838
+ "status": "stable",
7839
+ "migrationStage": null,
7840
+ "docsId": "primitives-editorblockhandle--docs",
7841
+ "storyId": "primitives-editorblockhandle--default",
7842
+ "docPage": null,
7843
+ "app": null,
7844
+ "looks": []
7845
+ },
7789
7846
  {
7790
7847
  "name": "typeTrigger",
7791
7848
  "repo": "estiva-ui",
@@ -178,7 +178,7 @@ describe('BlockHandle', () => {
178
178
  // jsdom lays nothing out: every block's top is 0, so the pointer is on the last.
179
179
  await act(async () => user.hover(screen.getByText('two')))
180
180
  await press(user, screen.getByRole('button', { name: 'Block menu' }))
181
- expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Duplicate', 'Delete'])
181
+ expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Turn into', 'Duplicate', 'Delete'])
182
182
  await user.click(screen.getByRole('menuitem', { name: 'Delete' }))
183
183
  // The editor takes the focus back a frame later and measures the caret then: wait for it, so it is part of the test.
184
184
  await act(() => new Promise<void>((done) => requestAnimationFrame(() => done())))
@@ -474,6 +474,8 @@ export const ReferenceNode = Node.create<ReferenceNodeOptions>({
474
474
  * Not editable, deliberately: an affordance that lets a person change
475
475
  * something they cannot see is worse than one that says the content is there
476
476
  * and belongs to another surface.
477
+ *
478
+ * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/the-block-editor-kit-lives-in-protocol-and-ui.md
477
479
  */
478
480
  export const UnknownBlock = Node.create({
479
481
  name: 'unknownBlock',
@@ -21,6 +21,8 @@ import { suggestionPopup, type SuggestionPopupOptions } from './suggestionPopup'
21
21
  * `messageReference`, `urgentTagsFor`), so a client without React writes the
22
22
  * same bytes. A chip here carries what that needs: a person's `pubkey`, a
23
23
  * reference's `uri`.
24
+ *
25
+ * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/composer-triggers-are-one-shared-kit.md
24
26
  */
25
27
 
26
28
  // ─── @ and !@ ───
@@ -482,7 +484,7 @@ export const FORMATS: readonly FormatDef[] = [
482
484
  * `Callout` extensions. Note and Warning are the two callouts offered (Miky,
483
485
  * 2026-10-13); the other tones are read and kept, not offered.
484
486
  */
485
- const DOCUMENT_FORMATS: readonly FormatDef[] = [
487
+ export const DOCUMENT_FORMATS: readonly FormatDef[] = [
486
488
  { id: 'checklist', label: 'Checklist', trigger: '[]' },
487
489
  { id: 'note', label: 'Note' },
488
490
  { id: 'warning', label: 'Warning' },
@@ -28,24 +28,29 @@ for Turn into, Duplicate and Delete, then the app's rows.
28
28
  ```tsx
29
29
  import { EditorContent, useEditor } from '@tiptap/react'
30
30
  import StarterKit from '@tiptap/starter-kit'
31
- import { BlockId, EditorBlockHandle, type BlockTurnInto } from '@estiva-app/ui/editor'
31
+ import { BlockId, Callout, Checklist, EditorBlockHandle } from '@estiva-app/ui/editor'
32
32
  import { MenuItem, type HandleBlock } from '@estiva-app/ui'
33
33
 
34
- const turnInto: BlockTurnInto[] = [
35
- { label: 'Text', run: (editor) => editor.chain().setParagraph().run(), active: (node) => node.type.name === 'paragraph' },
36
- { label: 'Heading', run: (editor) => editor.chain().setHeading({ level: 1 }).run() },
37
- ]
38
34
  const rows = (block: HandleBlock) => <MenuItem label="Copy link" onClick={() => navigator.clipboard.writeText(`#${block.id}`)} />
39
35
 
40
- const editor = useEditor({ extensions: [StarterKit, BlockId] })
36
+ const editor = useEditor({ extensions: [StarterKit, BlockId, Checklist, Callout] })
41
37
 
42
- <EditorBlockHandle editor={editor} turnInto={turnInto} actions={rows}>
38
+ <EditorBlockHandle editor={editor} actions={rows}>
43
39
  <EditorContent editor={editor} />
44
40
  </EditorBlockHandle>
45
41
  ```
46
42
 
47
- - `turnInto`'s `run` gets the editor with the caret already in the block, and
48
- is offered only for a block of text: a paragraph, a heading, code.
43
+ - **Turn into is the same in every app** (`turnIntoRows`): a block of text gets the `/` menu's formats, code as a block, and
44
+ Checklist, Note and Warning; a list gets Bulleted list, Numbered list,
45
+ Checklist and Text; a quote gets Quote, Note, Warning and Text. Checklist
46
+ and the callouts appear only where the editor has `Checklist` and `Callout`.
47
+ - **A list or a quote turns whole**, and keeps its id and every item's, so a
48
+ comment anchored to it stays: Checklist keeps the ticks there are, Bulleted
49
+ and Numbered drop them, Text makes each item a paragraph under the item's
50
+ id. `turnListInto` and `turnQuoteInto` are those rows' actions.
51
+ - `turnInto` takes other rows: a function of the block, or a list offered for
52
+ a block of text only, its `run` getting the editor with the caret already in
53
+ the block. `[]` offers none.
49
54
  - **Every row leaves the editor focused**, as a drop does: Turn into in the
50
55
  block, Duplicate in the copy, Delete where the block was. An editor that
51
56
  saves when the person leaves it saves these the same way.
@@ -3,8 +3,8 @@ import { EditorContent, useEditor } from '@tiptap/react'
3
3
  import StarterKit from '@tiptap/starter-kit'
4
4
  import { IconLink, IconMessage } from '@tabler/icons-react'
5
5
  import type { HandleBlock } from './BlockHandle'
6
- import { BlockId } from './BlockNodes'
7
- import { EditorBlockHandle, blockDropCursor, type BlockTurnInto } from './EditorBlockHandle'
6
+ import { BlockId, Callout, Checklist } from './BlockNodes'
7
+ import { EditorBlockHandle, blockDropCursor } from './EditorBlockHandle'
8
8
  import { MenuItem } from './Menu'
9
9
  import { richTextClassName } from './RichText'
10
10
 
@@ -35,17 +35,11 @@ const DOCUMENT = {
35
35
  { type: 'listItem', attrs: { blockId: 'b6' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item two' }] }] },
36
36
  ],
37
37
  },
38
+ { type: 'blockquote', attrs: { blockId: 'b8' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A quote. Turn it into a Note or a Warning.' }] }] },
38
39
  { type: 'paragraph', attrs: { blockId: 'b7' }, content: [{ type: 'text', text: 'The last paragraph.' }] },
39
40
  ],
40
41
  }
41
42
 
42
- const TURN_INTO: BlockTurnInto[] = [
43
- { label: 'Text', run: (editor) => editor.chain().setParagraph().run(), active: (node) => node.type.name === 'paragraph' },
44
- { label: 'Heading', run: (editor) => editor.chain().setHeading({ level: 1 }).run(), active: (node) => node.type.name === 'heading' && node.attrs.level === 1 },
45
- { label: 'Subheading', run: (editor) => editor.chain().setHeading({ level: 2 }).run(), active: (node) => node.type.name === 'heading' && node.attrs.level === 2 },
46
- { label: 'Code', run: (editor) => editor.chain().setCodeBlock().run(), active: (node) => node.type.name === 'codeBlock' },
47
- ]
48
-
49
43
  /** The rows an app adds: here they only say which block they were pressed on. */
50
44
  function rows(block: HandleBlock) {
51
45
  return (
@@ -58,13 +52,13 @@ function rows(block: HandleBlock) {
58
52
 
59
53
  function Editing({ editable }: { editable: boolean }) {
60
54
  const editor = useEditor({
61
- extensions: [StarterKit.configure({ dropcursor: blockDropCursor }), BlockId],
55
+ extensions: [StarterKit.configure({ dropcursor: blockDropCursor }), BlockId, Checklist, Callout],
62
56
  content: DOCUMENT,
63
57
  editable,
64
58
  editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
65
59
  })
66
60
  return (
67
- <EditorBlockHandle editor={editor} turnInto={TURN_INTO} actions={rows} className="ml-8 max-w-[560px]">
61
+ <EditorBlockHandle editor={editor} actions={rows} className="ml-8 max-w-[560px]">
68
62
  <EditorContent editor={editor} />
69
63
  </EditorBlockHandle>
70
64
  )
@@ -0,0 +1,200 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The block menu's Turn into (LEAF-11): what each row makes of a list, a quote
4
+ * and a block of text, and that the block keeps its id — what a comment
5
+ * anchored to it is attached by.
6
+ */
7
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest'
8
+ import { act, cleanup, render, screen } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { Editor, type Extensions } from '@tiptap/core'
11
+ import StarterKit from '@tiptap/starter-kit'
12
+ import { BlockId, Callout, Checklist } from './BlockNodes'
13
+ import { EditorBlockHandle, turnIntoRows, turnListInto, turnQuoteInto } from './EditorBlockHandle'
14
+
15
+ type Json = { type: string; attrs?: Record<string, unknown>; content?: Json[]; text?: string }
16
+
17
+ const item = (id: string, text: string, attrs: Record<string, unknown> = {}, nested?: Json): Json => ({
18
+ type: 'listItem',
19
+ attrs: { blockId: id, ...attrs },
20
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }, ...(nested ? [nested] : [])],
21
+ })
22
+ const para = (id: string, text: string): Json => ({ type: 'paragraph', attrs: { blockId: id }, content: [{ type: 'text', text }] })
23
+ const quote = (id: string, text: string, tone: string | null = null): Json => ({ type: 'blockquote', attrs: { blockId: id, tone }, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] })
24
+
25
+ const editors: Editor[] = []
26
+ function editor(content: Json[], extensions: Extensions = [StarterKit, BlockId, Checklist, Callout]) {
27
+ const e = new Editor({ extensions, content: { type: 'doc', content } })
28
+ editors.push(e)
29
+ return e
30
+ }
31
+ /** The document's blocks, less the empty paragraph StarterKit keeps after a last list or quote. */
32
+ const blocks = (e: Editor) => (e.getJSON().content as Json[]).filter((b, i, all) => !(i === all.length - 1 && b.type === 'paragraph' && !b.content))
33
+ const posOf = (e: Editor, id: string) => {
34
+ let at = -1
35
+ e.state.doc.forEach((node, pos) => {
36
+ if (node.attrs.blockId === id) at = pos
37
+ })
38
+ return at
39
+ }
40
+ /** Each item of the list as `id text checked`, the nested ones after their parent. */
41
+ const items = (list: Json): string[] =>
42
+ (list.content ?? []).flatMap((i) => [
43
+ `${i.attrs?.blockId} ${i.content?.[0].content?.[0].text} ${i.attrs?.checked ?? '-'}`,
44
+ ...(i.content ?? []).slice(1).flatMap(items),
45
+ ])
46
+ const texts = (e: Editor) => blocks(e).map((b) => `${b.type} ${b.attrs?.blockId} ${b.content?.[0]?.content?.[0]?.text ?? b.content?.[0]?.text}`)
47
+
48
+ beforeAll(() => {
49
+ const none = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }
50
+ Range.prototype.getClientRects = () => [] as unknown as DOMRectList
51
+ Range.prototype.getBoundingClientRect = () => none as DOMRect
52
+ })
53
+
54
+ afterEach(() => {
55
+ cleanup()
56
+ for (const e of editors.splice(0)) e.destroy()
57
+ })
58
+
59
+ describe('turnListInto', () => {
60
+ const LIST: Json = { type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'milk', { checked: true }), item('i2', 'eggs', {}, { type: 'bulletList', content: [item('i3', 'brown')] })] }
61
+
62
+ it('a list into a checklist makes every item one, nested ones too, keeping ticks and every id', () => {
63
+ const e = editor([LIST])
64
+ expect(turnListInto(e, 0, 'checklist')).toBe(true)
65
+ const [list] = blocks(e)
66
+ expect(list.type).toBe('bulletList')
67
+ expect(list.attrs?.blockId).toBe('l1')
68
+ expect(items(list)).toEqual(['i1 milk true', 'i2 eggs false', 'i3 brown false'])
69
+ })
70
+
71
+ it('a checklist into a numbered list keeps the list’s id and drops every tick', () => {
72
+ const e = editor([LIST])
73
+ turnListInto(e, 0, 'checklist')
74
+ turnListInto(e, 0, 'orderedList')
75
+ const [list] = blocks(e)
76
+ expect(list.type).toBe('orderedList')
77
+ expect(list.attrs?.blockId).toBe('l1')
78
+ expect(list.content?.[1].content?.[1].type).toBe('orderedList')
79
+ expect(items(list)).toEqual(['i1 milk -', 'i2 eggs -', 'i3 brown -'])
80
+ })
81
+
82
+ it('does not join a list into a numbered list beside it, as Tiptap’s toggle would', () => {
83
+ const e = editor([LIST, { type: 'orderedList', attrs: { blockId: 'l2' }, content: [item('i9', 'after')] }])
84
+ turnListInto(e, 0, 'orderedList')
85
+ expect(blocks(e).map((b) => `${b.type} ${b.attrs?.blockId}`)).toEqual(['orderedList l1', 'orderedList l2'])
86
+ })
87
+
88
+ it('a list into text makes each item a paragraph with the item’s id, in order', () => {
89
+ const e = editor([para('p0', 'before'), LIST])
90
+ turnListInto(e, posOf(e, 'l1'), 'text')
91
+ expect(texts(e)).toEqual(['paragraph p0 before', 'paragraph i1 milk', 'paragraph i2 eggs', 'paragraph i3 brown'])
92
+ })
93
+
94
+ it('a numbered list into a checklist stays numbered', () => {
95
+ const e = editor([{ type: 'orderedList', attrs: { blockId: 'l1' }, content: [item('i1', 'a')] }])
96
+ turnListInto(e, 0, 'checklist')
97
+ expect(blocks(e)[0].type).toBe('orderedList')
98
+ expect(items(blocks(e)[0])).toEqual(['i1 a false'])
99
+ })
100
+
101
+ it('the kind a list or a quote already is makes no edit', () => {
102
+ // A paragraph last, or StarterKit adds one on the first change and that is an edit.
103
+ const e = editor([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a')] }, quote('q1', 'b', 'note'), para('p9', 'end')])
104
+ let updates = 0
105
+ e.on('update', () => updates++)
106
+ turnListInto(e, 0, 'bulletList')
107
+ turnQuoteInto(e, posOf(e, 'q1'), 'note')
108
+ expect(updates).toBe(0)
109
+ expect(e.can().undo()).toBe(false)
110
+ })
111
+
112
+ it('does nothing where there is no list', () => {
113
+ const e = editor([para('p0', 'before')])
114
+ expect(turnListInto(e, 0, 'checklist')).toBe(false)
115
+ expect(texts(e)).toEqual(['paragraph p0 before'])
116
+ })
117
+ })
118
+
119
+ describe('turnQuoteInto', () => {
120
+ it('a quote into a Warning and back keeps its id and text', () => {
121
+ const e = editor([quote('q1', 'careful')])
122
+ turnQuoteInto(e, 0, 'warning')
123
+ expect(blocks(e)[0].attrs).toMatchObject({ blockId: 'q1', tone: 'warning' })
124
+ turnQuoteInto(e, 0, 'quote')
125
+ expect(blocks(e)[0].attrs).toMatchObject({ blockId: 'q1', tone: null })
126
+ expect(texts(e)).toEqual(['blockquote q1 careful'])
127
+ })
128
+
129
+ it('a callout into text is a paragraph with the quote’s id', () => {
130
+ const e = editor([quote('q1', 'careful', 'note')])
131
+ turnQuoteInto(e, 0, 'text')
132
+ expect(texts(e)).toEqual(['paragraph q1 careful'])
133
+ })
134
+ })
135
+
136
+ describe('turnIntoRows', () => {
137
+ const labels = (e: Editor, id: string) => turnIntoRows(e.state.doc.nodeAt(posOf(e, id))!, e).map((r) => r.label)
138
+ const selected = (e: Editor, id: string) => {
139
+ const node = e.state.doc.nodeAt(posOf(e, id))!
140
+ return turnIntoRows(node, e).filter((r) => r.active?.(node)).map((r) => r.label)
141
+ }
142
+
143
+ it('offers a list its kinds and Text, a quote its callouts and Text, a paragraph everything', () => {
144
+ const e = editor([para('p1', 'one'), { type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a')] }, quote('q1', 'b', 'warning')])
145
+ expect(labels(e, 'l1')).toEqual(['Bulleted list', 'Numbered list', 'Checklist', 'Text'])
146
+ expect(labels(e, 'q1')).toEqual(['Quote', 'Note', 'Warning', 'Text'])
147
+ expect(labels(e, 'p1')).toEqual(['Text', 'Heading', 'Subheading', 'Quote', 'Code', 'Bulleted list', 'Numbered list', 'Checklist', 'Note', 'Warning'])
148
+ expect(selected(e, 'l1')).toEqual(['Bulleted list'])
149
+ expect(selected(e, 'q1')).toEqual(['Warning'])
150
+ })
151
+
152
+ it('selects Checklist, not Bulleted list, on a list of checklist items', () => {
153
+ const e = editor([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a', { checked: false })] }])
154
+ expect(selected(e, 'l1')).toEqual(['Checklist'])
155
+ })
156
+
157
+ it('selects no row on a list only partly of checklist items, since every row changes it', () => {
158
+ const e = editor([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a', { checked: true }), item('i2', 'b')] }])
159
+ expect(selected(e, 'l1')).toEqual([])
160
+ })
161
+
162
+ it('offers no checklist or callout where the editor cannot hold one', () => {
163
+ const e = editor([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a')] }, quote('q1', 'b')], [StarterKit, BlockId])
164
+ expect(labels(e, 'l1')).toEqual(['Bulleted list', 'Numbered list', 'Text'])
165
+ expect(labels(e, 'q1')).toEqual(['Quote', 'Text'])
166
+ })
167
+
168
+ it('offers a block that is not text, a list or a quote nothing — a rule', () => {
169
+ const e = editor([para('p1', 'one')])
170
+ expect(turnIntoRows(e.schema.nodes.horizontalRule.create(), e)).toEqual([])
171
+ })
172
+ })
173
+
174
+ describe('EditorBlockHandle Turn into', () => {
175
+ async function press(user: ReturnType<typeof userEvent.setup>, target: HTMLElement) {
176
+ await user.pointer({ keys: '[MouseLeft>]', target })
177
+ await user.pointer({ keys: '[/MouseLeft]', target })
178
+ await screen.findByRole('menu')
179
+ }
180
+
181
+ it('turns a list into a checklist from the block menu, with no rows passed', async () => {
182
+ // jsdom lays nothing out: every block's top is 0, so the pointer is on the last — the list.
183
+ const e = editor([para('p1', 'one'), { type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'milk'), item('i2', 'eggs')] }])
184
+ const { EditorContent } = await import('@tiptap/react')
185
+ render(
186
+ <EditorBlockHandle editor={e}>
187
+ <EditorContent editor={e} />
188
+ </EditorBlockHandle>,
189
+ )
190
+ const user = userEvent.setup()
191
+ await act(async () => user.hover(screen.getByText('eggs')))
192
+ await press(user, screen.getByRole('button', { name: 'Block menu' }))
193
+ await user.click(screen.getByRole('menuitem', { name: 'Turn into' }))
194
+ await user.click(await screen.findByRole('menuitem', { name: 'Checklist' }))
195
+ await act(() => new Promise<void>((done) => requestAnimationFrame(() => done())))
196
+ const list = blocks(e)[1]
197
+ expect(list.attrs?.blockId).toBe('l1')
198
+ expect(items(list)).toEqual(['i1 milk false', 'i2 eggs false'])
199
+ })
200
+ })
@@ -4,13 +4,19 @@ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
4
4
  import type { EditorView } from '@tiptap/pm/view'
5
5
  import { IconCopy, IconTrash } from '@tabler/icons-react'
6
6
  import { BlockHandle, blockAtY, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
7
+ import { Selection } from '@tiptap/pm/state'
7
8
  import { MenuItem, MenuSub } from './Menu'
9
+ import { DOCUMENT_FORMATS, FORMATS, applyFormat, type FormatId } from './ComposerTriggers'
8
10
 
9
11
  /** A row of the block menu's Turn into: what the block becomes. */
10
12
  export interface BlockTurnInto {
11
13
  label: string
12
- /** Turns the block the caret is in. The handle puts the caret there first. */
13
- run: (editor: Editor) => void
14
+ /**
15
+ * Turns the block. The handle puts the caret in a block of text first, so a
16
+ * row for one can act at the caret; `pos` is where the block starts, for a
17
+ * list or a quote, whose row acts on the whole block.
18
+ */
19
+ run: (editor: Editor, pos: number) => void
14
20
  /** Whether the block already is this — drawn as the selected row. */
15
21
  active?: (node: ProseMirrorNode) => boolean
16
22
  }
@@ -18,8 +24,12 @@ export interface BlockTurnInto {
18
24
  export interface EditorBlockHandleProps {
19
25
  /** The editor. Not editable, or not there yet, and the handle reads: the caller's rows only. */
20
26
  editor: Editor | null
21
- /** Turn into's rows. Offered for a block of text — a paragraph, a heading, code. */
22
- turnInto?: readonly BlockTurnInto[]
27
+ /**
28
+ * Turn into's rows for a block. By default `turnIntoRows`, the same in every
29
+ * app: a block of text, a list and a quote each get theirs. A list of rows
30
+ * instead is offered for a block of text only; `[]` offers none.
31
+ */
32
+ turnInto?: readonly BlockTurnInto[] | ((node: ProseMirrorNode, editor: Editor) => readonly BlockTurnInto[])
23
33
  /** The caller's own rows, after the editing ones. */
24
34
  actions?: (block: HandleBlock) => ReactNode
25
35
  /** The `EditorContent`. */
@@ -116,7 +126,7 @@ const NOTHING_YET: BlockHandleEditing = { find: () => null, rows: () => null, st
116
126
  * `BlockHandle` in an editor: drag the handle to move the block, and the menu
117
127
  * gains Turn into, Duplicate and Delete before the caller's rows.
118
128
  */
119
- export function EditorBlockHandle({ editor, turnInto = [], actions, children, className }: EditorBlockHandleProps) {
129
+ export function EditorBlockHandle({ editor, turnInto = turnIntoRows, actions, children, className }: EditorBlockHandleProps) {
120
130
  const editing = useMemo<BlockHandleEditing>(() => {
121
131
  // Before the editor exists there is no block to find — not the editor's whole element read as one.
122
132
  if (!editor) return NOTHING_YET
@@ -130,19 +140,20 @@ export function EditorBlockHandle({ editor, turnInto = [], actions, children, cl
130
140
  if (!editor.isEditable) return null
131
141
  const node = pos === undefined ? null : editor.state.doc.nodeAt(pos)
132
142
  if (pos === undefined || !node) return null
143
+ const turnRows = typeof turnInto === 'function' ? turnInto(node, editor) : node.isTextblock ? turnInto : []
133
144
  return (
134
145
  <>
135
- {node.isTextblock && turnInto.length > 0 && (
146
+ {turnRows.length > 0 && (
136
147
  <MenuSub label="Turn into">
137
- {turnInto.map((row) => (
148
+ {turnRows.map((row) => (
138
149
  <MenuItem
139
150
  key={row.label}
140
151
  label={row.label}
141
152
  selected={row.active?.(node) ?? false}
142
153
  onClick={() => {
143
- // Inside the block's text, so the row's command turns this block and no other.
144
- editor.chain().focus().setTextSelection(pos + 1).run()
145
- row.run(editor)
154
+ // Inside a block of text, so a row's command at the caret turns this block and no other.
155
+ if (node.isTextblock) editor.chain().focus().setTextSelection(pos + 1).run()
156
+ row.run(editor, pos)
146
157
  }}
147
158
  />
148
159
  ))}
@@ -179,3 +190,163 @@ export function EditorBlockHandle({ editor, turnInto = [], actions, children, cl
179
190
  </BlockHandle>
180
191
  )
181
192
  }
193
+
194
+ const LISTS = ['bulletList', 'orderedList']
195
+ const isList = (node: ProseMirrorNode) => LISTS.includes(node.type.name)
196
+
197
+ /** Every item of a list, the ones nested under an item too — the editor nests what §13.3 keeps flat (MAN-13). */
198
+ function listItems(list: ProseMirrorNode, at: number): { node: ProseMirrorNode; pos: number }[] {
199
+ const out: { node: ProseMirrorNode; pos: number }[] = []
200
+ list.descendants((node, pos) => {
201
+ if (node.type.name === 'listItem') out.push({ node, pos: at + 1 + pos })
202
+ })
203
+ return out
204
+ }
205
+
206
+ const isChecklistItem = ({ node }: { node: ProseMirrorNode }) => typeof node.attrs.checked === 'boolean'
207
+ /** Whether every item of the list is a checklist item. */
208
+ const isChecklist = (list: ProseMirrorNode) => listItems(list, 0).every(isChecklistItem)
209
+ /** A list of that type with no checklist item in it — a list partly ticked is neither, so no row reads as "nothing changes". */
210
+ const isPlainList = (list: ProseMirrorNode, type: string) => list.type.name === type && !listItems(list, 0).some(isChecklistItem)
211
+
212
+ const hasExtension = (editor: Editor, name: string) => editor.extensionManager.extensions.some((e) => e.name === name)
213
+
214
+ /** The caret back in the block, the editor still focused — as Duplicate and Delete leave it, so a save on leaving saves this too. */
215
+ function caretInto(editor: Editor, pos: number) {
216
+ const { state } = editor
217
+ editor.view.dispatch(state.tr.setSelection(Selection.near(state.doc.resolve(Math.min(pos + 1, state.doc.content.size)))))
218
+ editor.commands.focus()
219
+ }
220
+
221
+ export type ListKind = 'bulletList' | 'orderedList' | 'checklist' | 'text'
222
+
223
+ /**
224
+ * Turns the whole list at `pos`, every item, the nested ones too.
225
+ *
226
+ * - **Bulleted, Numbered:** the list's own type changes and its attributes
227
+ * stay, so it keeps its id; every item's tick goes. Not Tiptap's
228
+ * `toggleList`, which makes the list anew — a fresh id, detaching every
229
+ * comment anchored to it — and then joins it into a list of that type next
230
+ * to it.
231
+ * - **Checklist:** every item a checklist item, the list's type as it was; an
232
+ * item already one keeps its tick.
233
+ *
234
+ * The kind it already is changes nothing — no edit, no undo step.
235
+ * - **Text:** each item becomes a paragraph that takes the item's id, as
236
+ * lifting an item out does (MAN-12). The list's own id goes with the list.
237
+ *
238
+ * `false` when there is no list at `pos`.
239
+ */
240
+ export function turnListInto(editor: Editor, pos: number, kind: ListKind): boolean {
241
+ const { state } = editor
242
+ const list = state.doc.nodeAt(pos)
243
+ if (!list || !isList(list)) return false
244
+ const tr = state.tr
245
+ if (kind === 'text') {
246
+ const blocks: ProseMirrorNode[] = []
247
+ for (const { node: item } of listItems(list, pos)) {
248
+ item.forEach((child, _offset, index) => {
249
+ // Its nested list's items are in `listItems` already.
250
+ if (isList(child)) return
251
+ // The item's first block takes its id; any other gets one of its own from `BlockId`.
252
+ blocks.push(child.type.create({ ...child.attrs, blockId: index === 0 ? item.attrs.blockId : null }, child.content, child.marks))
253
+ })
254
+ }
255
+ tr.replaceWith(pos, pos + list.nodeSize, blocks)
256
+ } else {
257
+ // A checklist stays the list type it is: §13.3 ticks items of either.
258
+ const type = kind === 'checklist' ? list.type : state.schema.nodes[kind]
259
+ // The list and the lists nested in it, one type throughout: §13.3's indented items are their list's type.
260
+ if (list.type !== type) tr.setNodeMarkup(pos, type, list.attrs)
261
+ list.descendants((node, at) => {
262
+ if (isList(node) && node.type !== type) tr.setNodeMarkup(pos + 1 + at, type, node.attrs)
263
+ })
264
+ for (const { node, pos: at } of listItems(list, pos)) {
265
+ const checked = kind === 'checklist' ? (typeof node.attrs.checked === 'boolean' ? node.attrs.checked : false) : null
266
+ if (node.attrs.checked !== checked) tr.setNodeAttribute(at, 'checked', checked)
267
+ }
268
+ }
269
+ // The row it already is changes nothing: no edit to save, no step to undo.
270
+ if (tr.docChanged) editor.view.dispatch(tr.scrollIntoView())
271
+ caretInto(editor, pos)
272
+ return true
273
+ }
274
+
275
+ export type QuoteKind = 'quote' | 'note' | 'warning' | 'text'
276
+
277
+ /**
278
+ * Turns the quote at `pos`: a plain quote, a Note or Warning callout (the
279
+ * tone changes, the id stays), or its text, whose first paragraph takes the
280
+ * quote's id, as lifting it out does (MAN-12). `false` when there is no quote
281
+ * at `pos`.
282
+ */
283
+ export function turnQuoteInto(editor: Editor, pos: number, kind: QuoteKind): boolean {
284
+ const { state } = editor
285
+ const quote = state.doc.nodeAt(pos)
286
+ if (!quote || quote.type.name !== 'blockquote') return false
287
+ const tr = state.tr
288
+ if (kind === 'text') {
289
+ const blocks: ProseMirrorNode[] = []
290
+ quote.forEach((child) => blocks.push(blocks.length === 0 ? child.type.create({ ...child.attrs, blockId: quote.attrs.blockId }, child.content, child.marks) : child))
291
+ tr.replaceWith(pos, pos + quote.nodeSize, blocks)
292
+ } else {
293
+ const tone = kind === 'quote' ? null : kind
294
+ if ((quote.attrs.tone ?? null) !== tone) tr.setNodeAttribute(pos, 'tone', tone)
295
+ }
296
+ if (tr.docChanged) editor.view.dispatch(tr.scrollIntoView())
297
+ caretInto(editor, pos)
298
+ return true
299
+ }
300
+
301
+ function isFormat(node: ProseMirrorNode, id: FormatId): boolean {
302
+ if (id === 'paragraph') return node.type.name === 'paragraph'
303
+ if (id === 'heading') return node.type.name === 'heading' && node.attrs.level === 1
304
+ if (id === 'subheading') return node.type.name === 'heading' && node.attrs.level === 2
305
+ if (id === 'code') return node.type.name === 'codeBlock'
306
+ return false
307
+ }
308
+
309
+ /**
310
+ * The block menu's Turn into for one block, the same in every app (Miky,
311
+ * LEAF-11's kickoff, 15 October):
312
+ *
313
+ * - **A block of text** — a paragraph, a heading, code: the `/` menu's Format
314
+ * rows, code as a block, and Checklist, Note and Warning where the editor
315
+ * has `Checklist` and `Callout`.
316
+ * - **A list:** Bulleted list, Numbered list, Checklist (with `Checklist`), Text.
317
+ * - **A quote:** Quote, Note, Warning (with `Callout`), Text.
318
+ *
319
+ * Anything else — a table, an attachment — none. Each keeps the block's text
320
+ * and its id, and so the comments anchored to it.
321
+ */
322
+ export function turnIntoRows(node: ProseMirrorNode, editor: Editor): readonly BlockTurnInto[] {
323
+ const checklist = hasExtension(editor, 'checklist')
324
+ const callout = hasExtension(editor, 'callout')
325
+ if (node.isTextblock) {
326
+ const document = DOCUMENT_FORMATS.filter((f) => (f.id === 'checklist' ? checklist : callout))
327
+ return [...FORMATS, ...document].map((format) => ({
328
+ label: format.label,
329
+ run: (e) => void applyFormat(e, format.id, 'block'),
330
+ active: (n) => isFormat(n, format.id),
331
+ }))
332
+ }
333
+ if (isList(node)) {
334
+ const rows: BlockTurnInto[] = [
335
+ { label: 'Bulleted list', run: (e, pos) => void turnListInto(e, pos, 'bulletList'), active: (n) => isPlainList(n, 'bulletList') },
336
+ { label: 'Numbered list', run: (e, pos) => void turnListInto(e, pos, 'orderedList'), active: (n) => isPlainList(n, 'orderedList') },
337
+ ]
338
+ if (checklist) rows.push({ label: 'Checklist', run: (e, pos) => void turnListInto(e, pos, 'checklist'), active: isChecklist })
339
+ rows.push({ label: 'Text', run: (e, pos) => void turnListInto(e, pos, 'text') })
340
+ return rows
341
+ }
342
+ if (node.type.name === 'blockquote') {
343
+ const rows: BlockTurnInto[] = [{ label: 'Quote', run: (e, pos) => void turnQuoteInto(e, pos, 'quote'), active: (n) => !n.attrs.tone }]
344
+ if (callout) {
345
+ rows.push({ label: 'Note', run: (e, pos) => void turnQuoteInto(e, pos, 'note'), active: (n) => n.attrs.tone === 'note' })
346
+ rows.push({ label: 'Warning', run: (e, pos) => void turnQuoteInto(e, pos, 'warning'), active: (n) => n.attrs.tone === 'warning' })
347
+ }
348
+ rows.push({ label: 'Text', run: (e, pos) => void turnQuoteInto(e, pos, 'text') })
349
+ return rows
350
+ }
351
+ return []
352
+ }