@estiva-app/ui 0.70.0 → 0.72.0-widgets2.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/ReferenceMenu.d.ts.map +1 -1
- package/dist/editor.d.ts +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +373 -264
- package/dist/editor.js.map +4 -4
- package/dist/widgets-2/fixtures.d.ts +100 -0
- package/dist/widgets-2/fixtures.d.ts.map +1 -0
- package/dist/widgets-2/proposed.d.ts +130 -0
- package/dist/widgets-2/proposed.d.ts.map +1 -0
- package/dist/widgets-2.d.ts +11 -0
- package/dist/widgets-2.d.ts.map +1 -0
- package/dist/widgets-2.js +418 -0
- package/dist/widgets-2.js.map +7 -0
- package/package.json +5 -1
- package/registry.json +62 -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/ReferenceMenu.tsx +2 -0
- package/src/editor.ts +15 -1
- package/src/gates/gates.test.ts +2 -1
- package/src/registry/registry.test.ts +3 -3
- package/src/widgets-2/fixtures.ts +159 -0
- package/src/widgets-2/proposed.tsx +596 -0
- package/src/widgets-2.ts +10 -0
- package/stories/widgets-2/ReadMe.mdx +60 -0
- package/stories/widgets-2/Widgets.stories.tsx +504 -0
- package/stories/widgets-2/today.tsx +352 -0
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.72.0-widgets2.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",
|
|
@@ -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",
|
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
|
+
})
|
|
@@ -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
|
-
/**
|
|
13
|
-
|
|
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
|
-
/**
|
|
22
|
-
|
|
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 =
|
|
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
|
-
{
|
|
146
|
+
{turnRows.length > 0 && (
|
|
136
147
|
<MenuSub label="Turn into">
|
|
137
|
-
{
|
|
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
|
|
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
|
+
}
|
package/src/LinkChips.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* half-typed address never turns into a chip under the person's fingers. A
|
|
18
18
|
* pasted URL is whole, so it is asked about and made a chip at once, the caret
|
|
19
19
|
* still at its end — where `ReferenceMenu` opens its menu.
|
|
20
|
+
*
|
|
21
|
+
* why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-link-is-stored-as-the-url-a-person-wrote.md
|
|
20
22
|
*/
|
|
21
23
|
import { Extension, type Editor } from '@tiptap/core'
|
|
22
24
|
import { closeHistory } from '@tiptap/pm/history'
|