@estiva-app/ui 0.69.2 → 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/dist/BlockNodes.d.ts +2 -0
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +8 -0
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/EditorBlockHandle.d.ts +53 -4
- package/dist/EditorBlockHandle.d.ts.map +1 -1
- package/dist/LinkChips.d.ts +2 -0
- package/dist/LinkChips.d.ts.map +1 -1
- package/dist/PastedMarkdown.d.ts +24 -0
- package/dist/PastedMarkdown.d.ts.map +1 -0
- package/dist/editor.d.ts +2 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +554 -403
- package/dist/editor.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +81 -5
- package/src/BlockHandle.test.tsx +1 -1
- package/src/BlockNodes.tsx +2 -0
- package/src/ComposerTriggers.tsx +3 -1
- package/src/EditorBlockHandle.mdx +14 -9
- package/src/EditorBlockHandle.stories.tsx +5 -11
- package/src/EditorBlockHandle.test.tsx +200 -0
- package/src/EditorBlockHandle.tsx +181 -10
- package/src/LinkChips.ts +2 -0
- package/src/PastedMarkdown.mdx +59 -0
- package/src/PastedMarkdown.stories.tsx +62 -0
- package/src/PastedMarkdown.test.ts +134 -0
- package/src/PastedMarkdown.ts +68 -0
- package/src/editor.ts +16 -1
- package/src/pages.test.ts +1 -0
- package/src/registry/registry.test.ts +6 -6
- 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.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": "
|
|
2338
|
+
"takes": "a handler",
|
|
2339
2339
|
"required": false,
|
|
2340
|
-
"note": "Turn into's rows.
|
|
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",
|
|
@@ -4819,6 +4819,25 @@
|
|
|
4819
4819
|
"app": null,
|
|
4820
4820
|
"looks": []
|
|
4821
4821
|
},
|
|
4822
|
+
{
|
|
4823
|
+
"name": "PastedMarkdown",
|
|
4824
|
+
"repo": "estiva-ui",
|
|
4825
|
+
"kind": "component",
|
|
4826
|
+
"importPath": "@estiva-app/ui/editor",
|
|
4827
|
+
"sourceFile": "src/PastedMarkdown.ts",
|
|
4828
|
+
"purpose": "Markdown pasted into an editor as plain text lands as what it spells: a heading, a list, a checklist, a quote or callout, a table.",
|
|
4829
|
+
"purposeFrom": "page",
|
|
4830
|
+
"props": [],
|
|
4831
|
+
"variants": [],
|
|
4832
|
+
"ownsBehaviours": [],
|
|
4833
|
+
"status": "stable",
|
|
4834
|
+
"migrationStage": null,
|
|
4835
|
+
"docsId": "primitives-pastedmarkdown--docs",
|
|
4836
|
+
"storyId": "primitives-pastedmarkdown--in-an-editor",
|
|
4837
|
+
"docPage": "src/PastedMarkdown.mdx",
|
|
4838
|
+
"app": null,
|
|
4839
|
+
"looks": []
|
|
4840
|
+
},
|
|
4822
4841
|
{
|
|
4823
4842
|
"name": "peopleMenu",
|
|
4824
4843
|
"repo": "estiva-ui",
|
|
@@ -7767,6 +7786,63 @@
|
|
|
7767
7786
|
"truncate text-left text-input-value text-text-muted"
|
|
7768
7787
|
]
|
|
7769
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
|
+
},
|
|
7770
7846
|
{
|
|
7771
7847
|
"name": "typeTrigger",
|
|
7772
7848
|
"repo": "estiva-ui",
|
package/src/BlockHandle.test.tsx
CHANGED
|
@@ -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())))
|
package/src/BlockNodes.tsx
CHANGED
|
@@ -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',
|
package/src/ComposerTriggers.tsx
CHANGED
|
@@ -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,
|
|
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}
|
|
38
|
+
<EditorBlockHandle editor={editor} actions={rows}>
|
|
43
39
|
<EditorContent editor={editor} />
|
|
44
40
|
</EditorBlockHandle>
|
|
45
41
|
```
|
|
46
42
|
|
|
47
|
-
-
|
|
48
|
-
|
|
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
|
|
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}
|
|
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
|
+
})
|