@estiva-app/ui 0.54.0 → 0.55.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/BlockHandle.d.ts +47 -0
- package/dist/BlockHandle.d.ts.map +1 -0
- package/dist/EditorBlockHandle.d.ts +57 -0
- package/dist/EditorBlockHandle.d.ts.map +1 -0
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +127 -11
- package/dist/editor.js.map +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +716 -573
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +211 -3
- package/src/BlockHandle.mdx +58 -0
- package/src/BlockHandle.stories.tsx +44 -0
- package/src/BlockHandle.test.tsx +216 -0
- package/src/BlockHandle.tsx +220 -0
- package/src/EditorBlockHandle.mdx +65 -0
- package/src/EditorBlockHandle.stories.tsx +81 -0
- package/src/EditorBlockHandle.tsx +181 -0
- package/src/editor.ts +1 -0
- package/src/index.ts +1 -0
- package/src/registry/registry.test.ts +5 -5
- package/stories/Choosing.mdx +2 -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.55.0",
|
|
8
|
+
"exports": 130,
|
|
9
|
+
"typeExports": 134,
|
|
10
10
|
"files": null
|
|
11
11
|
},
|
|
12
12
|
"storybook": {
|
|
@@ -458,6 +458,88 @@
|
|
|
458
458
|
"app": null,
|
|
459
459
|
"looks": []
|
|
460
460
|
},
|
|
461
|
+
{
|
|
462
|
+
"name": "blockAtY",
|
|
463
|
+
"repo": "estiva-ui",
|
|
464
|
+
"kind": "helper",
|
|
465
|
+
"importPath": "@estiva-app/ui",
|
|
466
|
+
"sourceFile": "src/BlockHandle.tsx",
|
|
467
|
+
"purpose": "The last of `blocks` whose top is at or above `y` — the gap between two blocks belongs to the one before.",
|
|
468
|
+
"purposeFrom": "comment",
|
|
469
|
+
"props": [],
|
|
470
|
+
"variants": [],
|
|
471
|
+
"ownsBehaviours": [],
|
|
472
|
+
"status": "stable",
|
|
473
|
+
"migrationStage": null,
|
|
474
|
+
"docsId": "primitives-blockhandle--docs",
|
|
475
|
+
"storyId": "primitives-blockhandle--default",
|
|
476
|
+
"docPage": null,
|
|
477
|
+
"app": null,
|
|
478
|
+
"looks": []
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
"name": "blockDropCursor",
|
|
482
|
+
"repo": "estiva-ui",
|
|
483
|
+
"kind": "helper",
|
|
484
|
+
"importPath": "@estiva-app/ui/editor",
|
|
485
|
+
"sourceFile": "src/EditorBlockHandle.tsx",
|
|
486
|
+
"purpose": "The drop line a dragged block shows, in the package's accent instead of the editor's default (the text's colour).",
|
|
487
|
+
"purposeFrom": "comment",
|
|
488
|
+
"props": [],
|
|
489
|
+
"variants": [],
|
|
490
|
+
"ownsBehaviours": [],
|
|
491
|
+
"status": "stable",
|
|
492
|
+
"migrationStage": null,
|
|
493
|
+
"docsId": "primitives-editorblockhandle--docs",
|
|
494
|
+
"storyId": "primitives-editorblockhandle--default",
|
|
495
|
+
"docPage": null,
|
|
496
|
+
"app": null,
|
|
497
|
+
"looks": []
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
"name": "BlockHandle",
|
|
501
|
+
"repo": "estiva-ui",
|
|
502
|
+
"kind": "component",
|
|
503
|
+
"importPath": "@estiva-app/ui",
|
|
504
|
+
"sourceFile": "src/BlockHandle.tsx",
|
|
505
|
+
"purpose": "The handle left of the block under the pointer, as a block-document page has it, and the block's menu behind it.",
|
|
506
|
+
"purposeFrom": "page",
|
|
507
|
+
"props": [
|
|
508
|
+
{
|
|
509
|
+
"name": "actions",
|
|
510
|
+
"takes": "a handler",
|
|
511
|
+
"required": false,
|
|
512
|
+
"note": "The caller's own rows — a link to the block, a comment on it."
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
"name": "editing",
|
|
516
|
+
"takes": "BlockHandleEditing",
|
|
517
|
+
"required": false,
|
|
518
|
+
"note": "Given by `EditorBlockHandle` (from `@estiva-app/ui/editor`)."
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
"name": "children",
|
|
522
|
+
"takes": "anything",
|
|
523
|
+
"required": true,
|
|
524
|
+
"note": "What the blocks are drawn in. Reading: a surface whose top-level elements carry `data-block-id`, as `RichText` draws."
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
"name": "className",
|
|
528
|
+
"takes": "text",
|
|
529
|
+
"required": false,
|
|
530
|
+
"note": null
|
|
531
|
+
}
|
|
532
|
+
],
|
|
533
|
+
"variants": [],
|
|
534
|
+
"ownsBehaviours": [],
|
|
535
|
+
"status": "stable",
|
|
536
|
+
"migrationStage": null,
|
|
537
|
+
"docsId": "primitives-blockhandle--docs",
|
|
538
|
+
"storyId": "primitives-blockhandle--default",
|
|
539
|
+
"docPage": "src/BlockHandle.mdx",
|
|
540
|
+
"app": null,
|
|
541
|
+
"looks": []
|
|
542
|
+
},
|
|
461
543
|
{
|
|
462
544
|
"name": "BlockId",
|
|
463
545
|
"repo": "estiva-ui",
|
|
@@ -1691,6 +1773,25 @@
|
|
|
1691
1773
|
"app": null,
|
|
1692
1774
|
"looks": []
|
|
1693
1775
|
},
|
|
1776
|
+
{
|
|
1777
|
+
"name": "deleteBlock",
|
|
1778
|
+
"repo": "estiva-ui",
|
|
1779
|
+
"kind": "helper",
|
|
1780
|
+
"importPath": "@estiva-app/ui/editor",
|
|
1781
|
+
"sourceFile": "src/EditorBlockHandle.tsx",
|
|
1782
|
+
"purpose": "Removes the block, the caret where it was.",
|
|
1783
|
+
"purposeFrom": "comment",
|
|
1784
|
+
"props": [],
|
|
1785
|
+
"variants": [],
|
|
1786
|
+
"ownsBehaviours": [],
|
|
1787
|
+
"status": "stable",
|
|
1788
|
+
"migrationStage": null,
|
|
1789
|
+
"docsId": "primitives-editorblockhandle--docs",
|
|
1790
|
+
"storyId": "primitives-editorblockhandle--default",
|
|
1791
|
+
"docPage": null,
|
|
1792
|
+
"app": null,
|
|
1793
|
+
"looks": []
|
|
1794
|
+
},
|
|
1694
1795
|
{
|
|
1695
1796
|
"name": "DialogShell",
|
|
1696
1797
|
"repo": "estiva-ui",
|
|
@@ -1864,6 +1965,25 @@
|
|
|
1864
1965
|
"app": null,
|
|
1865
1966
|
"looks": []
|
|
1866
1967
|
},
|
|
1968
|
+
{
|
|
1969
|
+
"name": "duplicateBlock",
|
|
1970
|
+
"repo": "estiva-ui",
|
|
1971
|
+
"kind": "helper",
|
|
1972
|
+
"importPath": "@estiva-app/ui/editor",
|
|
1973
|
+
"sourceFile": "src/EditorBlockHandle.tsx",
|
|
1974
|
+
"purpose": "A copy of the block straight after it, the caret in the copy.",
|
|
1975
|
+
"purposeFrom": "comment",
|
|
1976
|
+
"props": [],
|
|
1977
|
+
"variants": [],
|
|
1978
|
+
"ownsBehaviours": [],
|
|
1979
|
+
"status": "stable",
|
|
1980
|
+
"migrationStage": null,
|
|
1981
|
+
"docsId": "primitives-editorblockhandle--docs",
|
|
1982
|
+
"storyId": "primitives-editorblockhandle--default",
|
|
1983
|
+
"docPage": null,
|
|
1984
|
+
"app": null,
|
|
1985
|
+
"looks": []
|
|
1986
|
+
},
|
|
1867
1987
|
{
|
|
1868
1988
|
"name": "EditableText",
|
|
1869
1989
|
"repo": "estiva-ui",
|
|
@@ -1951,6 +2071,56 @@
|
|
|
1951
2071
|
"rounded-md border border-transparent text-left transition-colors hover:border-border-default"
|
|
1952
2072
|
]
|
|
1953
2073
|
},
|
|
2074
|
+
{
|
|
2075
|
+
"name": "EditorBlockHandle",
|
|
2076
|
+
"repo": "estiva-ui",
|
|
2077
|
+
"kind": "component",
|
|
2078
|
+
"importPath": "@estiva-app/ui/editor",
|
|
2079
|
+
"sourceFile": "src/EditorBlockHandle.tsx",
|
|
2080
|
+
"purpose": "`BlockHandle` in a Tiptap editor: drag the handle to move the block, press it for Turn into, Duplicate and Delete, then the app's rows.",
|
|
2081
|
+
"purposeFrom": "page",
|
|
2082
|
+
"props": [
|
|
2083
|
+
{
|
|
2084
|
+
"name": "editor",
|
|
2085
|
+
"takes": "Editor | null",
|
|
2086
|
+
"required": true,
|
|
2087
|
+
"note": "The editor. Not editable, or not there yet, and the handle reads: the caller's rows only."
|
|
2088
|
+
},
|
|
2089
|
+
{
|
|
2090
|
+
"name": "turnInto",
|
|
2091
|
+
"takes": "readonly BlockTurnInto[]",
|
|
2092
|
+
"required": false,
|
|
2093
|
+
"note": "Turn into's rows. Offered for a block of text — a paragraph, a heading, code."
|
|
2094
|
+
},
|
|
2095
|
+
{
|
|
2096
|
+
"name": "actions",
|
|
2097
|
+
"takes": "a handler",
|
|
2098
|
+
"required": false,
|
|
2099
|
+
"note": "The caller's own rows, after the editing ones."
|
|
2100
|
+
},
|
|
2101
|
+
{
|
|
2102
|
+
"name": "children",
|
|
2103
|
+
"takes": "anything",
|
|
2104
|
+
"required": true,
|
|
2105
|
+
"note": "The `EditorContent`."
|
|
2106
|
+
},
|
|
2107
|
+
{
|
|
2108
|
+
"name": "className",
|
|
2109
|
+
"takes": "text",
|
|
2110
|
+
"required": false,
|
|
2111
|
+
"note": null
|
|
2112
|
+
}
|
|
2113
|
+
],
|
|
2114
|
+
"variants": [],
|
|
2115
|
+
"ownsBehaviours": [],
|
|
2116
|
+
"status": "stable",
|
|
2117
|
+
"migrationStage": null,
|
|
2118
|
+
"docsId": "primitives-editorblockhandle--docs",
|
|
2119
|
+
"storyId": "primitives-editorblockhandle--default",
|
|
2120
|
+
"docPage": "src/EditorBlockHandle.mdx",
|
|
2121
|
+
"app": null,
|
|
2122
|
+
"looks": []
|
|
2123
|
+
},
|
|
1954
2124
|
{
|
|
1955
2125
|
"name": "EmptyState",
|
|
1956
2126
|
"repo": "estiva-ui",
|
|
@@ -3594,6 +3764,25 @@
|
|
|
3594
3764
|
"app": null,
|
|
3595
3765
|
"looks": []
|
|
3596
3766
|
},
|
|
3767
|
+
{
|
|
3768
|
+
"name": "moveBlock",
|
|
3769
|
+
"repo": "estiva-ui",
|
|
3770
|
+
"kind": "helper",
|
|
3771
|
+
"importPath": "@estiva-app/ui/editor",
|
|
3772
|
+
"sourceFile": "src/EditorBlockHandle.tsx",
|
|
3773
|
+
"purpose": "Moves the block at `from` to `to` (a position between top-level blocks), the caret in it: what a drop in the gutter does, where the editor has no drop of its own.",
|
|
3774
|
+
"purposeFrom": "comment",
|
|
3775
|
+
"props": [],
|
|
3776
|
+
"variants": [],
|
|
3777
|
+
"ownsBehaviours": [],
|
|
3778
|
+
"status": "stable",
|
|
3779
|
+
"migrationStage": null,
|
|
3780
|
+
"docsId": "primitives-editorblockhandle--docs",
|
|
3781
|
+
"storyId": "primitives-editorblockhandle--default",
|
|
3782
|
+
"docPage": null,
|
|
3783
|
+
"app": null,
|
|
3784
|
+
"looks": []
|
|
3785
|
+
},
|
|
3597
3786
|
{
|
|
3598
3787
|
"name": "NavItem",
|
|
3599
3788
|
"repo": "estiva-ui",
|
|
@@ -5532,6 +5721,25 @@
|
|
|
5532
5721
|
"app": null,
|
|
5533
5722
|
"looks": []
|
|
5534
5723
|
},
|
|
5724
|
+
{
|
|
5725
|
+
"name": "startBlockDrag",
|
|
5726
|
+
"repo": "estiva-ui",
|
|
5727
|
+
"kind": "helper",
|
|
5728
|
+
"importPath": "@estiva-app/ui/editor",
|
|
5729
|
+
"sourceFile": "src/EditorBlockHandle.tsx",
|
|
5730
|
+
"purpose": "Starts the browser's own drag of the block, as ProseMirror starts one of a selected node: the block selected, its slice on `view.dragging` with `move`, and its HTML on the transfer.",
|
|
5731
|
+
"purposeFrom": "comment",
|
|
5732
|
+
"props": [],
|
|
5733
|
+
"variants": [],
|
|
5734
|
+
"ownsBehaviours": [],
|
|
5735
|
+
"status": "stable",
|
|
5736
|
+
"migrationStage": null,
|
|
5737
|
+
"docsId": "primitives-editorblockhandle--docs",
|
|
5738
|
+
"storyId": "primitives-editorblockhandle--default",
|
|
5739
|
+
"docPage": null,
|
|
5740
|
+
"app": null,
|
|
5741
|
+
"looks": []
|
|
5742
|
+
},
|
|
5535
5743
|
{
|
|
5536
5744
|
"name": "SuggestionMenu",
|
|
5537
5745
|
"repo": "estiva-ui",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as BlockHandleStories from './BlockHandle.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={BlockHandleStories} />
|
|
5
|
+
|
|
6
|
+
# BlockHandle
|
|
7
|
+
|
|
8
|
+
The handle left of the block under the pointer, as a block-document page has
|
|
9
|
+
it, and the block's menu behind it. Over a document being read, the menu is
|
|
10
|
+
the app's rows only — a link to the block, a comment on it.
|
|
11
|
+
|
|
12
|
+
<Canvas of={BlockHandleStories.Default} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- **A document made of blocks, read by someone who cannot edit it**, when the
|
|
17
|
+
app has something to offer on one block: a link to it, a comment on it.
|
|
18
|
+
|
|
19
|
+
## When not
|
|
20
|
+
|
|
21
|
+
- The same document being edited → **EditorBlockHandle**, from
|
|
22
|
+
`@estiva-app/ui/editor`: the same handle, which also drags and has Turn
|
|
23
|
+
into, Duplicate and Delete.
|
|
24
|
+
- Reading a document with nothing to offer on one block → **RichText** alone.
|
|
25
|
+
|
|
26
|
+
## How
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { BlockHandle, MenuItem, RichText, type HandleBlock, type RichTextBlock } from '@estiva-app/ui'
|
|
30
|
+
|
|
31
|
+
const blocks: RichTextBlock[] = [{ type: 'paragraph', id: 'b1', inline: [{ text: 'Label', marks: [] }] }]
|
|
32
|
+
const rows = (block: HandleBlock) => <MenuItem label="Copy link" onClick={() => navigator.clipboard.writeText(`#${block.id}`)} />
|
|
33
|
+
|
|
34
|
+
<BlockHandle actions={rows}>
|
|
35
|
+
<RichText blocks={blocks} />
|
|
36
|
+
</BlockHandle>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- **Leave room on the left.** The handle sits 28px left of the content, in
|
|
40
|
+
the page's margin, so wrapping the content moves nothing. A surface flush
|
|
41
|
+
with its container's left edge clips it.
|
|
42
|
+
- `children` is a surface whose top-level elements carry `data-block-id`, as
|
|
43
|
+
`RichText` draws them. `HandleBlock.id` is that §13.3 id: what a link or a
|
|
44
|
+
comment addresses.
|
|
45
|
+
- With no `actions`, or `actions` that return nothing for a block, no handle
|
|
46
|
+
is drawn.
|
|
47
|
+
- **In the main entry on purpose**: a reader must not download an editor.
|
|
48
|
+
`editing` is how `EditorBlockHandle` makes this part edit, not a prop to
|
|
49
|
+
fill by hand.
|
|
50
|
+
|
|
51
|
+
## What it owns
|
|
52
|
+
|
|
53
|
+
It does these by itself. Write none of them around it.
|
|
54
|
+
|
|
55
|
+
| It does | So you never write | Refused when written by hand |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| Finds the block under the pointer, by height: the gap between two blocks belongs to the one above | a pointer handler on each block | |
|
|
58
|
+
| Centres the handle on the block's first line, a heading's or a paragraph's | a position for the handle | |
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconLink, IconMessage } from '@tabler/icons-react'
|
|
3
|
+
import { BlockHandle, type HandleBlock } from './BlockHandle'
|
|
4
|
+
import { MenuItem } from './Menu'
|
|
5
|
+
import { RichText, type RichTextBlock } from './RichText'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The handle left of the block under the pointer, over a document being read:
|
|
9
|
+
* press it for the app's rows. In an editor, `EditorBlockHandle` adds the drag
|
|
10
|
+
* and the editing rows.
|
|
11
|
+
*/
|
|
12
|
+
const meta = {
|
|
13
|
+
title: 'Primitives/BlockHandle',
|
|
14
|
+
// The handle sits 28px left of the content, in the page's margin.
|
|
15
|
+
parameters: { layout: 'padded' },
|
|
16
|
+
} satisfies Meta
|
|
17
|
+
|
|
18
|
+
export default meta
|
|
19
|
+
type Story = StoryObj
|
|
20
|
+
|
|
21
|
+
/** The rows an app adds: here they only say which block they were pressed on. */
|
|
22
|
+
function rows(block: HandleBlock) {
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<MenuItem label="Copy link" leading={<IconLink size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => console.info('copy link', block.id)} />
|
|
26
|
+
<MenuItem label="Comment" leading={<IconMessage size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => console.info('comment', block.id)} />
|
|
27
|
+
</>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const READING: RichTextBlock[] = [
|
|
32
|
+
{ type: 'heading', level: 1, id: 'b1', inline: [{ text: 'A heading', marks: [] }] },
|
|
33
|
+
{ type: 'paragraph', id: 'b2', inline: [{ text: 'Someone who cannot edit this still gets the handle, with the app’s rows only.', marks: [] }] },
|
|
34
|
+
{ type: 'paragraph', id: 'b3', inline: [{ text: 'The second paragraph.', marks: [] }] },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/** Reading: no drag and no editing rows — only what the app adds. */
|
|
38
|
+
export const Default: Story = {
|
|
39
|
+
render: () => (
|
|
40
|
+
<BlockHandle actions={rows} className="ml-8 max-w-[560px]">
|
|
41
|
+
<RichText blocks={READING} />
|
|
42
|
+
</BlockHandle>
|
|
43
|
+
),
|
|
44
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The block handle (RIC-18): what its rows do to the document, and which rows
|
|
4
|
+
* it offers. The drag itself is the browser's and is driven in real Chromium
|
|
5
|
+
* (the PR says how); here, the slice it starts with and what a drop of it keeps.
|
|
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 } from '@tiptap/core'
|
|
11
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
12
|
+
import { BlockHandle } from './BlockHandle'
|
|
13
|
+
import { BlockId } from './BlockNodes'
|
|
14
|
+
import { EditorBlockHandle, deleteBlock, duplicateBlock, moveBlock, startBlockDrag } from './EditorBlockHandle'
|
|
15
|
+
import { MenuItem } from './Menu'
|
|
16
|
+
|
|
17
|
+
const DOCUMENT = {
|
|
18
|
+
type: 'doc',
|
|
19
|
+
content: [
|
|
20
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'one' }] },
|
|
21
|
+
{ type: 'paragraph', attrs: { blockId: 'p2' }, content: [{ type: 'text', text: 'two' }] },
|
|
22
|
+
],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Json = { type: string; attrs?: Record<string, unknown>; content?: Json[] }
|
|
26
|
+
const editors: Editor[] = []
|
|
27
|
+
function editor(content: object = DOCUMENT) {
|
|
28
|
+
const e = new Editor({ extensions: [StarterKit, BlockId], content })
|
|
29
|
+
editors.push(e)
|
|
30
|
+
return e
|
|
31
|
+
}
|
|
32
|
+
const ids = (e: Editor) => (e.getJSON().content as Json[]).map((b) => b.attrs?.blockId)
|
|
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
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Press and release on the handle. Base UI opens a menu on the press; under
|
|
43
|
+
* jsdom `user.click`'s own move-press-release-click left it shut, while the
|
|
44
|
+
* same handle opens on a click in Chromium (the story, driven in the PR).
|
|
45
|
+
*/
|
|
46
|
+
async function press(user: ReturnType<typeof userEvent.setup>, target: HTMLElement) {
|
|
47
|
+
await user.pointer({ keys: '[MouseLeft>]', target })
|
|
48
|
+
await user.pointer({ keys: '[/MouseLeft]', target })
|
|
49
|
+
await screen.findByRole('menu')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// jsdom lays nothing out, so a Range has no rects. The editor measures the caret
|
|
53
|
+
// a frame after Duplicate, Delete or a move gives it the focus back; under load
|
|
54
|
+
// that frame came while a test still ran (CI, 5 October), and threw.
|
|
55
|
+
beforeAll(() => {
|
|
56
|
+
const none = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }
|
|
57
|
+
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
|
|
58
|
+
Range.prototype.getBoundingClientRect = () => none as DOMRect
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
cleanup()
|
|
63
|
+
for (const e of editors.splice(0)) e.destroy()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('duplicateBlock', () => {
|
|
67
|
+
it('puts a copy after the block, and the copy gets an id of its own', () => {
|
|
68
|
+
const e = editor()
|
|
69
|
+
duplicateBlock(e, posOf(e, 'p1'))
|
|
70
|
+
const [first, copy, last] = ids(e)
|
|
71
|
+
expect(first).toBe('p1')
|
|
72
|
+
expect(copy).toMatch(/^[0-9a-f]{12}$/)
|
|
73
|
+
expect(last).toBe('p2')
|
|
74
|
+
expect(e.getText()).toBe('one\n\none\n\ntwo')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('then deleting the copy gives back the document it started from', () => {
|
|
78
|
+
const e = editor()
|
|
79
|
+
const before = e.getJSON()
|
|
80
|
+
duplicateBlock(e, posOf(e, 'p2'))
|
|
81
|
+
deleteBlock(e, posOf(e, ids(e)[2] as string))
|
|
82
|
+
expect(e.getJSON()).toEqual(before)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('deleteBlock', () => {
|
|
87
|
+
it('removes the block and leaves the others their ids', () => {
|
|
88
|
+
const e = editor()
|
|
89
|
+
deleteBlock(e, posOf(e, 'p1'))
|
|
90
|
+
expect(ids(e)).toEqual(['p2'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('leaves an empty paragraph when it was the only block', () => {
|
|
94
|
+
const e = editor({ type: 'doc', content: [DOCUMENT.content[0]] })
|
|
95
|
+
deleteBlock(e, 0)
|
|
96
|
+
expect(e.getText()).toBe('')
|
|
97
|
+
expect(e.state.doc.childCount).toBe(1)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('moveBlock', () => {
|
|
102
|
+
it('moves the block whole, so it keeps its id, as a drop in the gutter does', () => {
|
|
103
|
+
const e = editor()
|
|
104
|
+
moveBlock(e, posOf(e, 'p1'), e.state.doc.content.size)
|
|
105
|
+
expect(ids(e)).toEqual(['p2', 'p1'])
|
|
106
|
+
expect(e.getText()).toBe('two\n\none')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does nothing when the block would land where it is', () => {
|
|
110
|
+
const e = editor()
|
|
111
|
+
const before = e.getJSON()
|
|
112
|
+
expect(moveBlock(e, posOf(e, 'p2'), posOf(e, 'p2'))).toBe(false)
|
|
113
|
+
expect(e.getJSON()).toEqual(before)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('startBlockDrag', () => {
|
|
118
|
+
it('selects the block and drags its slice, with its id, as a move', () => {
|
|
119
|
+
const e = editor()
|
|
120
|
+
const transfer = { data: {} as Record<string, string>, clearData() {}, setData(type: string, value: string) { this.data[type] = value }, effectAllowed: '' }
|
|
121
|
+
startBlockDrag(e, posOf(e, 'p2'), { dataTransfer: transfer as unknown as DataTransfer })
|
|
122
|
+
expect(e.view.dragging?.move).toBe(true)
|
|
123
|
+
expect(e.view.dragging?.slice.content.firstChild?.attrs.blockId).toBe('p2')
|
|
124
|
+
expect(e.state.selection.from).toBe(posOf(e, 'p2'))
|
|
125
|
+
expect(transfer.data['text/plain']).toBe('two')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('a drop of that slice, as ProseMirror makes it, keeps the id', () => {
|
|
129
|
+
const e = editor()
|
|
130
|
+
startBlockDrag(e, posOf(e, 'p1'), { dataTransfer: null })
|
|
131
|
+
// ProseMirror's drop: delete the selection, then insert the slice's node at the drop point.
|
|
132
|
+
const node = e.view.dragging!.slice.content.firstChild!
|
|
133
|
+
const tr = e.state.tr.deleteSelection()
|
|
134
|
+
tr.insert(tr.doc.content.size, node)
|
|
135
|
+
e.view.dispatch(tr)
|
|
136
|
+
expect(ids(e)).toEqual(['p2', 'p1'])
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('BlockHandle', () => {
|
|
141
|
+
it('reading, offers only the caller’s rows and does not drag', async () => {
|
|
142
|
+
render(
|
|
143
|
+
<BlockHandle actions={(b) => <MenuItem label={`Copy link ${b.id}`} onClick={() => {}} />}>
|
|
144
|
+
<div>
|
|
145
|
+
<p data-block-id="r1">read me</p>
|
|
146
|
+
</div>
|
|
147
|
+
</BlockHandle>,
|
|
148
|
+
)
|
|
149
|
+
const user = userEvent.setup()
|
|
150
|
+
await user.hover(screen.getByText('read me'))
|
|
151
|
+
const handle = screen.getByRole('button', { name: 'Block menu' })
|
|
152
|
+
expect(handle.getAttribute('draggable')).toBe('false')
|
|
153
|
+
await press(user, handle)
|
|
154
|
+
expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Copy link r1'])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('reading with no rows of the caller’s, draws no handle', async () => {
|
|
158
|
+
render(
|
|
159
|
+
<BlockHandle>
|
|
160
|
+
<div>
|
|
161
|
+
<p data-block-id="r1">read me</p>
|
|
162
|
+
</div>
|
|
163
|
+
</BlockHandle>,
|
|
164
|
+
)
|
|
165
|
+
await userEvent.setup().hover(screen.getByText('read me'))
|
|
166
|
+
expect(screen.queryByRole('button', { name: 'Block menu' })).toBeNull()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('editing, Delete from the menu removes the block', async () => {
|
|
170
|
+
const e = editor()
|
|
171
|
+
const { EditorContent } = await import('@tiptap/react')
|
|
172
|
+
render(
|
|
173
|
+
<EditorBlockHandle editor={e}>
|
|
174
|
+
<EditorContent editor={e} />
|
|
175
|
+
</EditorBlockHandle>,
|
|
176
|
+
)
|
|
177
|
+
const user = userEvent.setup()
|
|
178
|
+
// jsdom lays nothing out: every block's top is 0, so the pointer is on the last.
|
|
179
|
+
await act(async () => user.hover(screen.getByText('two')))
|
|
180
|
+
await press(user, screen.getByRole('button', { name: 'Block menu' }))
|
|
181
|
+
expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Duplicate', 'Delete'])
|
|
182
|
+
await user.click(screen.getByRole('menuitem', { name: 'Delete' }))
|
|
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
|
+
await act(() => new Promise<void>((done) => requestAnimationFrame(() => done())))
|
|
185
|
+
expect(ids(e)).toEqual(['p1'])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('an editor that cannot be edited gets the caller’s rows only, and no drag', async () => {
|
|
189
|
+
const e = editor()
|
|
190
|
+
e.setEditable(false)
|
|
191
|
+
const { EditorContent } = await import('@tiptap/react')
|
|
192
|
+
render(
|
|
193
|
+
<EditorBlockHandle editor={e} actions={(b) => <MenuItem label={`Copy link ${b.id}`} onClick={() => {}} />}>
|
|
194
|
+
<EditorContent editor={e} />
|
|
195
|
+
</EditorBlockHandle>,
|
|
196
|
+
)
|
|
197
|
+
const user = userEvent.setup()
|
|
198
|
+
await act(async () => user.hover(screen.getByText('two')))
|
|
199
|
+
const handle = screen.getByRole('button', { name: 'Block menu' })
|
|
200
|
+
expect(handle.getAttribute('draggable')).toBe('false')
|
|
201
|
+
await press(user, handle)
|
|
202
|
+
expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Copy link p2'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('draws no handle before its editor exists', async () => {
|
|
206
|
+
render(
|
|
207
|
+
<EditorBlockHandle editor={null} actions={() => <MenuItem label="Copy link" onClick={() => {}} />}>
|
|
208
|
+
<div>
|
|
209
|
+
<p data-block-id="r1">not yet</p>
|
|
210
|
+
</div>
|
|
211
|
+
</EditorBlockHandle>,
|
|
212
|
+
)
|
|
213
|
+
await userEvent.setup().hover(screen.getByText('not yet'))
|
|
214
|
+
expect(screen.queryByRole('button', { name: 'Block menu' })).toBeNull()
|
|
215
|
+
})
|
|
216
|
+
})
|