@estiva-app/ui 0.65.3 → 0.66.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 +33 -0
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +11 -2
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/RichText.d.ts +13 -1
- package/dist/RichText.d.ts.map +1 -1
- package/dist/editor.d.ts +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +198 -7
- package/dist/editor.js.map +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -9
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/registry.json +84 -2
- package/src/BlockNodes.mdx +32 -1
- package/src/BlockNodes.stories.tsx +29 -1
- package/src/BlockNodes.test.tsx +141 -2
- package/src/BlockNodes.tsx +227 -4
- package/src/ComposerTriggers.test.tsx +23 -0
- package/src/ComposerTriggers.tsx +61 -5
- package/src/RichText.mdx +12 -0
- package/src/RichText.stories.tsx +50 -0
- package/src/RichText.test.tsx +56 -0
- package/src/RichText.tsx +81 -9
- package/src/editor.ts +3 -0
- package/src/index.ts +1 -1
- package/src/registry/registry.test.ts +4 -4
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.
|
|
8
|
-
"exports":
|
|
7
|
+
"packageVersion": "0.66.0",
|
|
8
|
+
"exports": 144,
|
|
9
9
|
"typeExports": 145,
|
|
10
10
|
"files": null
|
|
11
11
|
},
|
|
@@ -734,6 +734,44 @@
|
|
|
734
734
|
"signal:bg-transparent signal:border signal:border-border-default signal:text-text-primary signal:shadow-none signal:hover:bg-success-muted signal:hover:border-success-outline signal:hover:text-success-default signal:transition-colors"
|
|
735
735
|
]
|
|
736
736
|
},
|
|
737
|
+
{
|
|
738
|
+
"name": "Callout",
|
|
739
|
+
"repo": "estiva-ui",
|
|
740
|
+
"kind": "component",
|
|
741
|
+
"importPath": "@estiva-app/ui/editor",
|
|
742
|
+
"sourceFile": "src/BlockNodes.tsx",
|
|
743
|
+
"purpose": "A callout — SPEC §13.3's `tone` on a quote (RIC-19): a quote drawn to stand out, in one of GitHub's alert kinds (`note`, `tip`, `important`, `warning`, `caution`).",
|
|
744
|
+
"purposeFrom": "comment",
|
|
745
|
+
"props": [],
|
|
746
|
+
"variants": [],
|
|
747
|
+
"ownsBehaviours": [],
|
|
748
|
+
"status": "stable",
|
|
749
|
+
"migrationStage": null,
|
|
750
|
+
"docsId": "primitives-blocknodes--docs",
|
|
751
|
+
"storyId": "primitives-blocknodes--default",
|
|
752
|
+
"docPage": null,
|
|
753
|
+
"app": null,
|
|
754
|
+
"looks": []
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
"name": "calloutLook",
|
|
758
|
+
"repo": "estiva-ui",
|
|
759
|
+
"kind": "helper",
|
|
760
|
+
"importPath": "@estiva-app/ui",
|
|
761
|
+
"sourceFile": "src/RichText.tsx",
|
|
762
|
+
"purpose": "The tone a callout is drawn in: its own, or `note` for one this part does not know (§13.3).",
|
|
763
|
+
"purposeFrom": "comment",
|
|
764
|
+
"props": [],
|
|
765
|
+
"variants": [],
|
|
766
|
+
"ownsBehaviours": [],
|
|
767
|
+
"status": "stable",
|
|
768
|
+
"migrationStage": null,
|
|
769
|
+
"docsId": "components-richtext--docs",
|
|
770
|
+
"storyId": "components-richtext--every-block",
|
|
771
|
+
"docPage": null,
|
|
772
|
+
"app": null,
|
|
773
|
+
"looks": []
|
|
774
|
+
},
|
|
737
775
|
{
|
|
738
776
|
"name": "CaptionedReference",
|
|
739
777
|
"repo": "estiva-ui",
|
|
@@ -967,6 +1005,25 @@
|
|
|
967
1005
|
"rounded-lg transition-colors bg-bg-selected cursor-pointer hover:bg-bg-hover"
|
|
968
1006
|
]
|
|
969
1007
|
},
|
|
1008
|
+
{
|
|
1009
|
+
"name": "Checklist",
|
|
1010
|
+
"repo": "estiva-ui",
|
|
1011
|
+
"kind": "component",
|
|
1012
|
+
"importPath": "@estiva-app/ui/editor",
|
|
1013
|
+
"sourceFile": "src/BlockNodes.tsx",
|
|
1014
|
+
"purpose": "A checklist item — SPEC §13.3's `checked` on a list item (RIC-19): `true` ticked, `false` not, absent a plain item.",
|
|
1015
|
+
"purposeFrom": "comment",
|
|
1016
|
+
"props": [],
|
|
1017
|
+
"variants": [],
|
|
1018
|
+
"ownsBehaviours": [],
|
|
1019
|
+
"status": "stable",
|
|
1020
|
+
"migrationStage": null,
|
|
1021
|
+
"docsId": "primitives-blocknodes--docs",
|
|
1022
|
+
"storyId": "primitives-blocknodes--default",
|
|
1023
|
+
"docPage": null,
|
|
1024
|
+
"app": null,
|
|
1025
|
+
"looks": []
|
|
1026
|
+
},
|
|
970
1027
|
{
|
|
971
1028
|
"name": "Chip",
|
|
972
1029
|
"repo": "estiva-ui",
|
|
@@ -3144,6 +3201,25 @@
|
|
|
3144
3201
|
"app": null,
|
|
3145
3202
|
"looks": []
|
|
3146
3203
|
},
|
|
3204
|
+
{
|
|
3205
|
+
"name": "isChecklistTick",
|
|
3206
|
+
"repo": "estiva-ui",
|
|
3207
|
+
"kind": "helper",
|
|
3208
|
+
"importPath": "@estiva-app/ui/editor",
|
|
3209
|
+
"sourceFile": "src/BlockNodes.tsx",
|
|
3210
|
+
"purpose": "Whether a change is a click on a checklist item's box.",
|
|
3211
|
+
"purposeFrom": "comment",
|
|
3212
|
+
"props": [],
|
|
3213
|
+
"variants": [],
|
|
3214
|
+
"ownsBehaviours": [],
|
|
3215
|
+
"status": "stable",
|
|
3216
|
+
"migrationStage": null,
|
|
3217
|
+
"docsId": "primitives-blocknodes--docs",
|
|
3218
|
+
"storyId": "primitives-blocknodes--default",
|
|
3219
|
+
"docPage": null,
|
|
3220
|
+
"app": null,
|
|
3221
|
+
"looks": []
|
|
3222
|
+
},
|
|
3147
3223
|
{
|
|
3148
3224
|
"name": "isSuggestionActive",
|
|
3149
3225
|
"repo": "estiva-ui",
|
|
@@ -5406,6 +5482,12 @@
|
|
|
5406
5482
|
"takes": "a handler",
|
|
5407
5483
|
"required": false,
|
|
5408
5484
|
"note": "Draws a block this part has no design for (an attachment, a widget)."
|
|
5485
|
+
},
|
|
5486
|
+
{
|
|
5487
|
+
"name": "onTick",
|
|
5488
|
+
"takes": "a handler",
|
|
5489
|
+
"required": false,
|
|
5490
|
+
"note": "Ticks or unticks a checklist item, by its block id: its box is then a control, one click where the text is read."
|
|
5409
5491
|
}
|
|
5410
5492
|
],
|
|
5411
5493
|
"variants": [
|
package/src/BlockNodes.mdx
CHANGED
|
@@ -6,7 +6,8 @@ import * as BlockNodesStories from './BlockNodes.stories'
|
|
|
6
6
|
# BlockNodes
|
|
7
7
|
|
|
8
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,
|
|
9
|
+
on every block, a block it cannot edit kept whole, a reference, a file, a
|
|
10
|
+
checklist item and a callout.
|
|
10
11
|
|
|
11
12
|
<Canvas of={BlockNodesStories.Default} />
|
|
12
13
|
|
|
@@ -34,8 +35,11 @@ import StarterKit from '@tiptap/starter-kit'
|
|
|
34
35
|
import {
|
|
35
36
|
AttachmentNode,
|
|
36
37
|
BlockId,
|
|
38
|
+
Callout,
|
|
39
|
+
Checklist,
|
|
37
40
|
ReferenceNode,
|
|
38
41
|
UnknownBlock,
|
|
42
|
+
isChecklistTick,
|
|
39
43
|
type AttachmentViewProps,
|
|
40
44
|
type ReferenceViewProps,
|
|
41
45
|
} from '@estiva-app/ui/editor'
|
|
@@ -50,11 +54,17 @@ const editor = useEditor({
|
|
|
50
54
|
extensions: [
|
|
51
55
|
StarterKit,
|
|
52
56
|
BlockId,
|
|
57
|
+
Checklist,
|
|
58
|
+
Callout,
|
|
53
59
|
UnknownBlock,
|
|
54
60
|
ReferenceNode.configure({ view: Reference }),
|
|
55
61
|
AttachmentNode.configure({ view: File }),
|
|
56
62
|
],
|
|
57
63
|
content: opened,
|
|
64
|
+
// A tick is a click, so no blur follows it: save it now.
|
|
65
|
+
onUpdate: ({ transaction }) => {
|
|
66
|
+
if (isChecklistTick(transaction)) console.log('save now')
|
|
67
|
+
},
|
|
58
68
|
})
|
|
59
69
|
|
|
60
70
|
<EditorContent editor={editor} />
|
|
@@ -74,9 +84,30 @@ const editor = useEditor({
|
|
|
74
84
|
- `UnknownBlock` draws a block the editor has no design for as its text, in a
|
|
75
85
|
box. It cannot be changed here, only moved or deleted, and it is saved back
|
|
76
86
|
exactly as it arrived.
|
|
87
|
+
- `Checklist` is a list item with `checked` (§13.3): a box before it that
|
|
88
|
+
ticks with one click. `[ ] ` or `[] ` at the start of a line makes one,
|
|
89
|
+
`[x] ` a ticked one; `/`'s Checklist does too, in a block editor
|
|
90
|
+
(`formatSection('block')`). A change that is a tick says so
|
|
91
|
+
(`isChecklistTick`): save it at once, as no blur follows a click on the box.
|
|
92
|
+
The box is named by its item's words, and ticks only while the editor is
|
|
93
|
+
editable — `setEditable` is followed, not read once. Without `Checklist`, a
|
|
94
|
+
save drops the tick and keeps the item, and `/` does not offer Checklist.
|
|
95
|
+
- `Callout` is a quote with a `tone` (§13.3), drawn on its tone's wash as
|
|
96
|
+
`RichText` draws it. `/` offers Note and Warning in a block editor that has
|
|
97
|
+
it; the other tones are read and kept as written.
|
|
77
98
|
|
|
78
99
|
<Canvas of={BlockNodesStories.WithoutViews} />
|
|
79
100
|
|
|
101
|
+
<Canvas of={BlockNodesStories.ChecklistAndCallouts} />
|
|
102
|
+
|
|
80
103
|
## What it owns
|
|
81
104
|
|
|
82
105
|
Nothing. It only draws. Clicks and keys are yours.
|
|
106
|
+
|
|
107
|
+
## Keys
|
|
108
|
+
|
|
109
|
+
| Key | In a checklist item |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| Enter | Starts the next item, unticked. At the start of the item, its text moves down with its tick. On an empty item, leaves the list. |
|
|
112
|
+
| Backspace | At the start of the item, takes its box away; again, joins it to the item above. |
|
|
113
|
+
| Space after `[ ]`, `[]` or `[x]` | At the start of a line, makes a checklist item (ticked for `[x]`). |
|
|
@@ -4,7 +4,7 @@ import StarterKit from '@tiptap/starter-kit'
|
|
|
4
4
|
import { AttachmentCard } from './AttachmentCard'
|
|
5
5
|
import { InlineChip } from './InlineChip'
|
|
6
6
|
import { richTextClassName } from './RichText'
|
|
7
|
-
import { AttachmentNode, BlockId, ReferenceNode, UnknownBlock, type AttachmentViewProps, type ReferenceViewProps } from './BlockNodes'
|
|
7
|
+
import { AttachmentNode, BlockId, Callout, Checklist, ReferenceNode, UnknownBlock, type AttachmentViewProps, type ReferenceViewProps } from './BlockNodes'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The nodes an editor of a block document adds to StarterKit: an id on every
|
|
@@ -77,3 +77,31 @@ export const Default: Story = {
|
|
|
77
77
|
export const WithoutViews: Story = {
|
|
78
78
|
render: () => <WithAnEditor views={false} />,
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
const item = (blockId: string, text: string, checked?: boolean) => ({
|
|
82
|
+
type: 'listItem',
|
|
83
|
+
attrs: { blockId, ...(checked === undefined ? {} : { checked }) },
|
|
84
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
function WithChecklist() {
|
|
88
|
+
const editor = useEditor({
|
|
89
|
+
extensions: [StarterKit, BlockId, Checklist, Callout],
|
|
90
|
+
content: {
|
|
91
|
+
type: 'doc',
|
|
92
|
+
content: [
|
|
93
|
+
{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'Ticked: click the box', true), item('i2', 'Enter starts the next one unticked', false), item('i3', 'A plain item')] },
|
|
94
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'Type [ ] and a space at the start of a line for a checklist.' }] },
|
|
95
|
+
{ type: 'blockquote', attrs: { blockId: 'q1', tone: 'note' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A note.' }] }] },
|
|
96
|
+
{ type: 'blockquote', attrs: { blockId: 'q2', tone: 'warning' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A warning.' }] }] },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
100
|
+
})
|
|
101
|
+
return <EditorContent editor={editor} className="max-w-[560px]" />
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A checklist and two callouts, in the editor: the box ticks with one click, as where it is read (RIC-19). */
|
|
105
|
+
export const ChecklistAndCallouts: Story = {
|
|
106
|
+
render: () => <WithChecklist />,
|
|
107
|
+
}
|
package/src/BlockNodes.test.tsx
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* reads — so each test is a load, an edit or none, and a read back.
|
|
9
9
|
*/
|
|
10
10
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
11
|
-
import { render, screen, cleanup } from '@testing-library/react'
|
|
11
|
+
import { act, render, screen, cleanup, waitFor } from '@testing-library/react'
|
|
12
12
|
import { Editor } from '@tiptap/core'
|
|
13
13
|
import { EditorContent, useEditor } from '@tiptap/react'
|
|
14
14
|
import StarterKit from '@tiptap/starter-kit'
|
|
15
|
-
import { AttachmentNode, BlockId, ReferenceNode, UnknownBlock, type ReferenceViewProps } from './BlockNodes'
|
|
15
|
+
import { AttachmentNode, BlockId, Callout, Checklist, ReferenceNode, UnknownBlock, isChecklistTick, type ReferenceViewProps } from './BlockNodes'
|
|
16
|
+
import { applyFormat } from './ComposerTriggers'
|
|
16
17
|
|
|
17
18
|
const EXTENSIONS = [StarterKit, BlockId, UnknownBlock, ReferenceNode, AttachmentNode]
|
|
18
19
|
|
|
@@ -573,3 +574,141 @@ describe('Tab on a list item — MAN-13', () => {
|
|
|
573
574
|
expect(item.content![1].attrs?.blockId ?? null).toBeNull()
|
|
574
575
|
})
|
|
575
576
|
})
|
|
577
|
+
|
|
578
|
+
describe('a checklist item and a callout — RIC-19', () => {
|
|
579
|
+
const WITH = [StarterKit, BlockId, Checklist, Callout]
|
|
580
|
+
const press = (key: string) => editor!.view.someProp('handleKeyDown', (f) => f(editor!.view, new KeyboardEvent('keydown', { key })))
|
|
581
|
+
const item = (id: string, text: string, checked?: boolean) => ({
|
|
582
|
+
type: 'listItem',
|
|
583
|
+
attrs: { blockId: id, ...(checked === undefined ? {} : { checked }) },
|
|
584
|
+
content: [{ type: 'paragraph', content: text ? [{ type: 'text', text }] : [] }],
|
|
585
|
+
})
|
|
586
|
+
const CHECKLIST = {
|
|
587
|
+
type: 'doc',
|
|
588
|
+
content: [
|
|
589
|
+
{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'ship', true), item('i2', 'plain')] },
|
|
590
|
+
{ type: 'blockquote', attrs: { blockId: 'q1', tone: 'warning' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'careful' }] }] },
|
|
591
|
+
],
|
|
592
|
+
}
|
|
593
|
+
const items = () => ((editor!.getJSON() as Json).content![0].content ?? []).map((n) => [JSON.stringify(n.content).match(/"text":"([^"]*)"/)?.[1] ?? '', n.attrs?.checked ?? null])
|
|
594
|
+
/** The end of "ship": 1 opens the list, 1 the item, 1 its paragraph. */
|
|
595
|
+
const END_OF_SHIP = 3 + 4
|
|
596
|
+
|
|
597
|
+
it('keeps a tick and a tone through a load and a read, and a plain item plain', () => {
|
|
598
|
+
editor = new Editor({ extensions: WITH, content: CHECKLIST })
|
|
599
|
+
expect(items()).toEqual([['ship', true], ['plain', null]])
|
|
600
|
+
expect((editor.getJSON() as Json).content![1].attrs?.tone).toBe('warning')
|
|
601
|
+
expect(editor.getHTML()).toContain('data-callout="warning"')
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('starts the next item unticked on Enter, never with the tick it was split from', () => {
|
|
605
|
+
editor = new Editor({ extensions: WITH, content: CHECKLIST })
|
|
606
|
+
editor.commands.setTextSelection(END_OF_SHIP)
|
|
607
|
+
expect(press('Enter')).toBe(true)
|
|
608
|
+
expect(items()).toEqual([['ship', true], ['', false], ['plain', null]])
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('starts the next item unticked on Enter over a selection, the selection gone (review of #170)', () => {
|
|
612
|
+
editor = new Editor({ extensions: WITH, content: CHECKLIST })
|
|
613
|
+
editor.commands.setTextSelection({ from: 4, to: 5 })
|
|
614
|
+
expect(press('Enter')).toBe(true)
|
|
615
|
+
expect(items()).toEqual([['s', true], ['ip', false], ['plain', null]])
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('moves the text down with its tick on Enter at the start of an item', () => {
|
|
619
|
+
editor = new Editor({ extensions: WITH, content: CHECKLIST })
|
|
620
|
+
editor.commands.setTextSelection(3)
|
|
621
|
+
expect(press('Enter')).toBe(true)
|
|
622
|
+
expect(items()).toEqual([['', false], ['ship', true], ['plain', null]])
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('takes the box away first on Backspace at the start of an item', () => {
|
|
626
|
+
editor = new Editor({ extensions: WITH, content: CHECKLIST })
|
|
627
|
+
editor.commands.setTextSelection(3)
|
|
628
|
+
expect(press('Backspace')).toBe(true)
|
|
629
|
+
expect(items()).toEqual([['ship', null], ['plain', null]])
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
/** A space typed after `text`, as a key reaches the input rules. */
|
|
633
|
+
const typeSpaceAfter = (doc: object, at: number) => {
|
|
634
|
+
editor = new Editor({ extensions: WITH, content: doc })
|
|
635
|
+
editor.commands.setTextSelection(at)
|
|
636
|
+
const tr = () => editor!.state.tr.insertText(' ', at, at)
|
|
637
|
+
return editor.view.someProp('handleTextInput', (f) => f(editor!.view, at, at, ' ', tr))
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
it('makes a checklist item from [ ] at the start of a line', () => {
|
|
641
|
+
expect(typeSpaceAfter({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '[ ]' }] }] }, 4)).toBe(true)
|
|
642
|
+
const list = (editor!.getJSON() as Json).content![0]
|
|
643
|
+
expect(list.type).toBe('bulletList')
|
|
644
|
+
expect(list.content![0].attrs?.checked).toBe(false)
|
|
645
|
+
expect(editor!.getText().trim()).toBe('')
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('turns a list item into a ticked one from [x]', () => {
|
|
649
|
+
const doc = { type: 'doc', content: [{ type: 'bulletList', content: [item('i1', '[x]')] }] }
|
|
650
|
+
expect(typeSpaceAfter(doc, 6)).toBe(true)
|
|
651
|
+
expect(items()).toEqual([['', true]])
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('makes a checklist and a callout from `/`', () => {
|
|
655
|
+
editor = new Editor({ extensions: WITH, content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'todo' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'heads up' }] }] } })
|
|
656
|
+
editor.commands.setTextSelection(2)
|
|
657
|
+
expect(applyFormat(editor, 'checklist')).toBe(true)
|
|
658
|
+
editor.commands.setTextSelection(editor.state.doc.content.size - 2)
|
|
659
|
+
expect(applyFormat(editor, 'note')).toBe(true)
|
|
660
|
+
const [list, quote] = (editor.getJSON() as Json).content!
|
|
661
|
+
expect(list.content![0].attrs?.checked).toBe(false)
|
|
662
|
+
expect(quote.attrs?.tone).toBe('note')
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('keeps a tick when `/` Checklist is picked on a ticked item (review of #170)', () => {
|
|
666
|
+
editor = new Editor({ extensions: WITH, content: CHECKLIST })
|
|
667
|
+
editor.commands.setTextSelection(4)
|
|
668
|
+
expect(applyFormat(editor, 'checklist')).toBe(true)
|
|
669
|
+
expect(items()[0]).toEqual(['ship', true])
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('leaves `[] ` as typed in an editor with no bullet list (review of #170)', () => {
|
|
673
|
+
editor = new Editor({ extensions: [StarterKit.configure({ bulletList: false }), BlockId, Checklist], content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '[]' }] }] } })
|
|
674
|
+
editor.commands.setTextSelection(3)
|
|
675
|
+
const tr = () => editor!.state.tr.insertText(' ', 3, 3)
|
|
676
|
+
expect(editor.view.someProp('handleTextInput', (f) => f(editor!.view, 3, 3, ' ', tr))).toBeFalsy()
|
|
677
|
+
expect(editor.getText()).toBe('[]')
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('ticks with one click on the box, and says the change was a tick', async () => {
|
|
681
|
+
let ticked = false
|
|
682
|
+
function Checklisted() {
|
|
683
|
+
const e = useEditor({ extensions: WITH, content: CHECKLIST, onUpdate: ({ transaction }) => { ticked ||= isChecklistTick(transaction) } })
|
|
684
|
+
editor = e ?? undefined
|
|
685
|
+
return <EditorContent editor={e} />
|
|
686
|
+
}
|
|
687
|
+
render(<Checklisted />)
|
|
688
|
+
// Named by its item's words (Katerina, review of #170).
|
|
689
|
+
const boxes = await screen.findAllByRole('checkbox', { name: 'ship' })
|
|
690
|
+
expect(boxes).toHaveLength(1)
|
|
691
|
+
expect(boxes[0].getAttribute('aria-checked')).toBe('true')
|
|
692
|
+
boxes[0].click()
|
|
693
|
+
expect(items()[0]).toEqual(['ship', false])
|
|
694
|
+
expect(ticked).toBe(true)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('follows the editor going read-only and back, not only how it was when drawn', async () => {
|
|
698
|
+
function Checklisted() {
|
|
699
|
+
const e = useEditor({ extensions: WITH, content: CHECKLIST })
|
|
700
|
+
editor = e ?? undefined
|
|
701
|
+
return <EditorContent editor={e} />
|
|
702
|
+
}
|
|
703
|
+
const { container } = render(<Checklisted />)
|
|
704
|
+
await screen.findByRole('checkbox', { name: 'ship' })
|
|
705
|
+
const box = () => container.querySelector('[data-checklist-box]')!
|
|
706
|
+
act(() => editor!.setEditable(false))
|
|
707
|
+
// A picture now: the box around it says done, and nothing ticks.
|
|
708
|
+
expect([box().getAttribute('role'), box().getAttribute('aria-checked'), box().getAttribute('aria-readonly')]).toEqual(['checkbox', 'true', 'true'])
|
|
709
|
+
await waitFor(() => expect(box().querySelector('[role=checkbox]')).toBeNull())
|
|
710
|
+
act(() => editor!.setEditable(true))
|
|
711
|
+
expect(box().getAttribute('role')).toBeNull()
|
|
712
|
+
expect(await screen.findByRole('checkbox', { name: 'ship' })).toBeTruthy()
|
|
713
|
+
})
|
|
714
|
+
})
|
package/src/BlockNodes.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { ComponentType } from 'react'
|
|
2
|
-
import { Extension, Node } from '@tiptap/core'
|
|
3
|
-
import type
|
|
4
|
-
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
|
|
2
|
+
import { Extension, InputRule, Node, type Editor } from '@tiptap/core'
|
|
3
|
+
import { DOMSerializer, type Node as ProseMirrorNode, type Slice } from '@tiptap/pm/model'
|
|
4
|
+
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
|
|
5
5
|
import { Mapping, ReplaceAroundStep } from '@tiptap/pm/transform'
|
|
6
|
-
import {
|
|
6
|
+
import type { EditorView, NodeView } from '@tiptap/pm/view'
|
|
7
|
+
import { NodeViewWrapper, ReactNodeViewRenderer, ReactRenderer, type ReactNodeViewProps } from '@tiptap/react'
|
|
8
|
+
import { Checkbox, type CheckboxProps } from './Checkbox'
|
|
9
|
+
import { calloutLook } from './RichText'
|
|
7
10
|
|
|
8
11
|
/*
|
|
9
12
|
* The four nodes an editor of a block document needs that a stock schema does
|
|
@@ -529,3 +532,223 @@ export const AttachmentNode = Node.create<AttachmentNodeOptions>({
|
|
|
529
532
|
))
|
|
530
533
|
},
|
|
531
534
|
})
|
|
535
|
+
|
|
536
|
+
const TICK = 'checklistTick'
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Whether a change is a click on a checklist item's box. The click does not
|
|
540
|
+
* focus the editor, so no blur follows it: an editor that saves on blur saves
|
|
541
|
+
* a tick straight away instead (RIC-19, Miky 2026-10-13: one click ticks).
|
|
542
|
+
*/
|
|
543
|
+
export function isChecklistTick(transaction: Transaction): boolean {
|
|
544
|
+
return transaction.getMeta(TICK) === true
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** A list or quote command by name: StarterKit's declare them, and this package builds without StarterKit. */
|
|
548
|
+
type Chain = ReturnType<Editor['chain']>
|
|
549
|
+
function named(chain: Chain, command: string, ...args: unknown[]): Chain {
|
|
550
|
+
const own = (chain as unknown as Record<string, ((...a: unknown[]) => Chain) | undefined>)[command]
|
|
551
|
+
return own ? own(...args) : chain
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The list item the caret's paragraph is the first line of, if it is. */
|
|
555
|
+
function itemAtCaret(state: EditorState): { node: ProseMirrorNode; pos: number } | null {
|
|
556
|
+
const { $from } = state.selection
|
|
557
|
+
if ($from.depth < 2) return null
|
|
558
|
+
const item = $from.node(-1)
|
|
559
|
+
if (item.type.name !== 'listItem' || $from.index(-1) !== 0) return null
|
|
560
|
+
return { node: item, pos: $from.before(-1) }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const sameAttrs = (a: ProseMirrorNode, b: ProseMirrorNode) =>
|
|
564
|
+
Object.keys({ ...a.attrs, ...b.attrs }).every((key) => a.attrs[key] === b.attrs[key])
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* A list item, drawn with a box before it when it is a checklist item. The box
|
|
568
|
+
* is the package's `Checkbox`, as `RichText` draws it, and the item's own
|
|
569
|
+
* text and inner lists follow it in a `div`. Any change to the item's
|
|
570
|
+
* attributes draws it again, which is what a tick is.
|
|
571
|
+
*
|
|
572
|
+
* The box is named by the item's words, and follows them as they are typed.
|
|
573
|
+
* Whether it ticks follows the editor's `editable`, read again on every
|
|
574
|
+
* update — `setEditable` sends one — not only when the item was drawn
|
|
575
|
+
* (Katerina, review of #170). Not editable, it is a picture, and the box around
|
|
576
|
+
* it says done or not to a screen reader, as `RichText`'s does.
|
|
577
|
+
*/
|
|
578
|
+
function listItemView(editor: Editor, node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined): NodeView {
|
|
579
|
+
const toDOM = node.type.spec.toDOM
|
|
580
|
+
const { dom, contentDOM } = toDOM ? DOMSerializer.renderSpec(document, toDOM(node)) : { dom: document.createElement('li'), contentDOM: undefined }
|
|
581
|
+
const checked = node.attrs.checked
|
|
582
|
+
if (typeof checked !== 'boolean') return { dom, contentDOM: contentDOM ?? dom, update: (next) => next.type === node.type && sameAttrs(next, node) }
|
|
583
|
+
|
|
584
|
+
const li = dom as HTMLElement
|
|
585
|
+
const box = document.createElement('span')
|
|
586
|
+
box.contentEditable = 'false'
|
|
587
|
+
box.setAttribute('data-checklist-box', '')
|
|
588
|
+
const body = document.createElement('div')
|
|
589
|
+
li.replaceChildren(box, body)
|
|
590
|
+
const tick = (next: boolean) => {
|
|
591
|
+
const pos = getPos()
|
|
592
|
+
if (pos === undefined || !view.editable) return
|
|
593
|
+
view.dispatch(view.state.tr.setNodeAttribute(pos, 'checked', next).setMeta(TICK, true))
|
|
594
|
+
}
|
|
595
|
+
let words = node.firstChild?.textContent.trim() || 'Item'
|
|
596
|
+
const props = (): CheckboxProps => ({ checked, onChange: view.editable ? tick : undefined, 'aria-label': words })
|
|
597
|
+
const renderer = new ReactRenderer<unknown, CheckboxProps>(Checkbox, { editor, props: props(), as: 'span' })
|
|
598
|
+
box.appendChild(renderer.element)
|
|
599
|
+
// What the box last drew: an update that changes neither leaves it alone, so typing does not draw every box again.
|
|
600
|
+
let drawn = ''
|
|
601
|
+
const sync = () => {
|
|
602
|
+
const now = `${view.editable}:${words}`
|
|
603
|
+
if (now === drawn) return
|
|
604
|
+
drawn = now
|
|
605
|
+
renderer.updateProps(props())
|
|
606
|
+
for (const [name, value] of Object.entries({ role: 'checkbox', 'aria-checked': String(checked), 'aria-readonly': 'true', 'aria-label': words })) {
|
|
607
|
+
if (view.editable) box.removeAttribute(name)
|
|
608
|
+
else box.setAttribute(name, value)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
sync()
|
|
612
|
+
editor.on('update', sync)
|
|
613
|
+
return {
|
|
614
|
+
dom: li,
|
|
615
|
+
contentDOM: body,
|
|
616
|
+
update: (next) => {
|
|
617
|
+
if (next.type !== node.type || !sameAttrs(next, node)) return false
|
|
618
|
+
words = next.firstChild?.textContent.trim() || 'Item'
|
|
619
|
+
sync()
|
|
620
|
+
return true
|
|
621
|
+
},
|
|
622
|
+
stopEvent: (event) => box.contains(event.target as globalThis.Node),
|
|
623
|
+
ignoreMutation: (mutation) => box.contains(mutation.target),
|
|
624
|
+
destroy: () => {
|
|
625
|
+
editor.off('update', sync)
|
|
626
|
+
renderer.destroy()
|
|
627
|
+
},
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* A checklist item — SPEC §13.3's `checked` on a list item (RIC-19): `true`
|
|
633
|
+
* ticked, `false` not, absent a plain item. Add it beside `BlockId`.
|
|
634
|
+
*
|
|
635
|
+
* - **The box ticks with one click**, where the description is read, and the
|
|
636
|
+
* change says so (`isChecklistTick`), so the app saves it at once.
|
|
637
|
+
* - **`[ ] ` or `[] ` at the start of a line** makes it, `[x] ` ticked; in a
|
|
638
|
+
* list it turns that item into one.
|
|
639
|
+
* - **Enter** starts the next item unticked: a new item never takes the tick
|
|
640
|
+
* of the one it was split from. At the start of an item, the text moves down
|
|
641
|
+
* with its tick and the empty item above is unticked. On an empty item Enter
|
|
642
|
+
* leaves the list, as in any list.
|
|
643
|
+
* - **Backspace** at the start of a checklist item takes its box away first;
|
|
644
|
+
* the next one joins it to the item above, as in any list.
|
|
645
|
+
*
|
|
646
|
+
* An editor without it drops `checked` on save and keeps the item and its text.
|
|
647
|
+
*/
|
|
648
|
+
export const Checklist = Extension.create({
|
|
649
|
+
name: 'checklist',
|
|
650
|
+
// Before the list item's own Enter and Backspace.
|
|
651
|
+
priority: 101,
|
|
652
|
+
addGlobalAttributes() {
|
|
653
|
+
return [
|
|
654
|
+
{
|
|
655
|
+
types: ['listItem'],
|
|
656
|
+
attributes: {
|
|
657
|
+
checked: {
|
|
658
|
+
default: null,
|
|
659
|
+
keepOnSplit: false,
|
|
660
|
+
parseHTML: (element: HTMLElement) => {
|
|
661
|
+
const value = element.getAttribute('data-checked')
|
|
662
|
+
return value === 'true' ? true : value === 'false' ? false : null
|
|
663
|
+
},
|
|
664
|
+
renderHTML: (attributes: Record<string, unknown>) =>
|
|
665
|
+
typeof attributes.checked === 'boolean' ? { 'data-checked': String(attributes.checked) } : {},
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
]
|
|
670
|
+
},
|
|
671
|
+
addInputRules() {
|
|
672
|
+
return [
|
|
673
|
+
new InputRule({
|
|
674
|
+
find: /^\[([ xX]?)\]\s$/,
|
|
675
|
+
handler: ({ state, range, match, chain }) => {
|
|
676
|
+
const checked = match[1].toLowerCase() === 'x'
|
|
677
|
+
const inList = itemAtCaret(state) !== null
|
|
678
|
+
// Without a bullet list to make, the typed text stays as it was typed.
|
|
679
|
+
if (!inList && !(chain() as unknown as Record<string, unknown>).toggleBulletList) return null
|
|
680
|
+
const deleted = chain().deleteRange(range)
|
|
681
|
+
const listed = inList ? deleted : named(deleted, 'toggleBulletList')
|
|
682
|
+
listed.updateAttributes('listItem', { checked }).run()
|
|
683
|
+
},
|
|
684
|
+
}),
|
|
685
|
+
]
|
|
686
|
+
},
|
|
687
|
+
addKeyboardShortcuts() {
|
|
688
|
+
return {
|
|
689
|
+
Enter: () => {
|
|
690
|
+
const { state } = this.editor
|
|
691
|
+
const item = itemAtCaret(state)
|
|
692
|
+
const { $from, empty } = state.selection
|
|
693
|
+
if (!item || typeof item.node.attrs.checked !== 'boolean') return false
|
|
694
|
+
// Over a selection: the selection goes and the next item is unticked, as for a caret in the text.
|
|
695
|
+
if (!empty) return this.editor.chain().deleteSelection().splitListItem('listItem', { checked: false }).run()
|
|
696
|
+
// An empty item: the list item's own Enter, which leaves the list.
|
|
697
|
+
if ($from.parent.content.size === 0) return false
|
|
698
|
+
if ($from.parentOffset > 0) return this.editor.commands.splitListItem('listItem', { checked: false })
|
|
699
|
+
return this.editor
|
|
700
|
+
.chain()
|
|
701
|
+
.splitListItem('listItem', { checked: item.node.attrs.checked })
|
|
702
|
+
.command(({ tr }) => {
|
|
703
|
+
tr.setNodeAttribute(tr.mapping.map(item.pos), 'checked', false)
|
|
704
|
+
return true
|
|
705
|
+
})
|
|
706
|
+
.run()
|
|
707
|
+
},
|
|
708
|
+
Backspace: () => {
|
|
709
|
+
const { state } = this.editor
|
|
710
|
+
const item = itemAtCaret(state)
|
|
711
|
+
const { $from, empty } = state.selection
|
|
712
|
+
if (!item || !empty || $from.parentOffset > 0 || typeof item.node.attrs.checked !== 'boolean') return false
|
|
713
|
+
this.editor.view.dispatch(state.tr.setNodeAttribute(item.pos, 'checked', null))
|
|
714
|
+
return true
|
|
715
|
+
},
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
addProseMirrorPlugins() {
|
|
719
|
+
const editor = this.editor
|
|
720
|
+
return [
|
|
721
|
+
new Plugin({
|
|
722
|
+
key: new PluginKey('checklist'),
|
|
723
|
+
props: { nodeViews: { listItem: (node, view, getPos) => listItemView(editor, node, view, getPos) } },
|
|
724
|
+
}),
|
|
725
|
+
]
|
|
726
|
+
},
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* A callout — SPEC §13.3's `tone` on a quote (RIC-19): a quote drawn to stand
|
|
731
|
+
* out, in one of GitHub's alert kinds (`note`, `tip`, `important`, `warning`,
|
|
732
|
+
* `caution`). Its look is `RichText`'s, by `data-tone`; a tone it does not
|
|
733
|
+
* know looks like `note`, and the editor keeps it as written. Add it beside
|
|
734
|
+
* `BlockId`.
|
|
735
|
+
*/
|
|
736
|
+
export const Callout = Extension.create({
|
|
737
|
+
name: 'callout',
|
|
738
|
+
addGlobalAttributes() {
|
|
739
|
+
return [
|
|
740
|
+
{
|
|
741
|
+
types: ['blockquote'],
|
|
742
|
+
attributes: {
|
|
743
|
+
tone: {
|
|
744
|
+
default: null,
|
|
745
|
+
parseHTML: (element: HTMLElement) => element.getAttribute('data-tone'),
|
|
746
|
+
// As `RichText` draws it: the tone as written, and the look it is drawn in.
|
|
747
|
+
renderHTML: (attributes: Record<string, unknown>) =>
|
|
748
|
+
typeof attributes.tone === 'string' ? { 'data-tone': attributes.tone, 'data-callout': calloutLook(attributes.tone) } : {},
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
]
|
|
753
|
+
},
|
|
754
|
+
})
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type SlashSection,
|
|
28
28
|
} from './ComposerTriggers'
|
|
29
29
|
import { isSuggestionActive, isSuggestionOpen } from './suggestionPopup'
|
|
30
|
+
import { Callout, Checklist } from './BlockNodes'
|
|
30
31
|
|
|
31
32
|
const KEY = 'b'.repeat(64)
|
|
32
33
|
const PEOPLE: MentionPerson[] = [
|
|
@@ -549,3 +550,25 @@ describe('/', () => {
|
|
|
549
550
|
expect(hint(formatSection())).toBe('`')
|
|
550
551
|
})
|
|
551
552
|
})
|
|
553
|
+
|
|
554
|
+
describe('/ in a block editor — RIC-19', () => {
|
|
555
|
+
function BlockComposer({ blocks }: { blocks: boolean }) {
|
|
556
|
+
const made = useEditor({
|
|
557
|
+
extensions: [StarterKit, ...(blocks ? [Checklist, Callout] : []), SlashCommands.configure({ sections: [formatSection('block')] })],
|
|
558
|
+
})
|
|
559
|
+
editor = made
|
|
560
|
+
return <EditorContent editor={made} />
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
it('offers Checklist, Note and Warning where the editor has Checklist and Callout', async () => {
|
|
564
|
+
render(<BlockComposer blocks />)
|
|
565
|
+
await type('/')
|
|
566
|
+
expect(options()?.slice(-3)).toEqual(['Checklist[]', 'Note', 'Warning'])
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('offers none of them where it has not: they would make a plain bullet and a plain quote (Katerina, review of #170)', async () => {
|
|
570
|
+
render(<BlockComposer blocks={false} />)
|
|
571
|
+
await type('/')
|
|
572
|
+
expect(options()).toEqual(['Text', 'Heading#', 'Subheading##', 'Quote>', 'Code```', 'Bulleted list-', 'Numbered list1.'])
|
|
573
|
+
})
|
|
574
|
+
})
|