@estiva-app/ui 0.53.1 → 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.
Files changed (54) hide show
  1. package/dist/BlockHandle.d.ts +47 -0
  2. package/dist/BlockHandle.d.ts.map +1 -0
  3. package/dist/EditorBlockHandle.d.ts +57 -0
  4. package/dist/EditorBlockHandle.d.ts.map +1 -0
  5. package/dist/NavItem.d.ts +14 -1
  6. package/dist/NavItem.d.ts.map +1 -1
  7. package/dist/NavTree.d.ts +93 -13
  8. package/dist/NavTree.d.ts.map +1 -1
  9. package/dist/UnreadDot.d.ts +12 -0
  10. package/dist/UnreadDot.d.ts.map +1 -0
  11. package/dist/editor.d.ts +1 -0
  12. package/dist/editor.d.ts.map +1 -1
  13. package/dist/editor.js +127 -11
  14. package/dist/editor.js.map +4 -4
  15. package/dist/eslint/index.js +1 -1
  16. package/dist/eslint/index.js.map +2 -2
  17. package/dist/eslint/no-native-title.d.ts +1 -1
  18. package/dist/eslint/no-native-title.d.ts.map +1 -1
  19. package/dist/index.d.ts +3 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +801 -584
  22. package/dist/index.js.map +4 -4
  23. package/dist/looks.d.ts +1 -1
  24. package/dist/looks.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/registry.json +362 -11
  27. package/src/BlockHandle.mdx +58 -0
  28. package/src/BlockHandle.stories.tsx +44 -0
  29. package/src/BlockHandle.test.tsx +216 -0
  30. package/src/BlockHandle.tsx +220 -0
  31. package/src/EditorBlockHandle.mdx +65 -0
  32. package/src/EditorBlockHandle.stories.tsx +81 -0
  33. package/src/EditorBlockHandle.tsx +181 -0
  34. package/src/NavItem.mdx +14 -1
  35. package/src/NavItem.stories.tsx +15 -0
  36. package/src/NavItem.test.tsx +31 -0
  37. package/src/NavItem.tsx +49 -12
  38. package/src/NavTree.mdx +25 -0
  39. package/src/NavTree.stories.tsx +57 -1
  40. package/src/NavTree.test.tsx +78 -1
  41. package/src/NavTree.tsx +175 -40
  42. package/src/SectionLabel.stories.tsx +2 -3
  43. package/src/UnreadDot.mdx +57 -0
  44. package/src/UnreadDot.stories.tsx +22 -0
  45. package/src/UnreadDot.test.tsx +17 -0
  46. package/src/UnreadDot.tsx +17 -0
  47. package/src/editor.ts +1 -0
  48. package/src/eslint/no-native-title.ts +1 -1
  49. package/src/index.ts +3 -1
  50. package/src/looks.ts +2 -1
  51. package/src/registry/registry.test.ts +5 -5
  52. package/stories/Choosing.mdx +3 -0
  53. package/tailwind-preset.js +1 -0
  54. package/tokens.css +5 -0
@@ -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
+ })
@@ -0,0 +1,220 @@
1
+ import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
2
+ import type { BaseUIEvent } from '@base-ui/react/types'
3
+ import { IconGripVertical } from '@tabler/icons-react'
4
+ import { cn } from './cn'
5
+ import { IconButton } from './IconButton'
6
+ import { Menu, MenuSeparator } from './Menu'
7
+
8
+ /** The block the pointer is on: its §13.3 id, its element, and — in an editor — where it starts. */
9
+ export interface HandleBlock {
10
+ /** `undefined` only for a block that has none yet, which nothing can address. */
11
+ id: string | undefined
12
+ element: HTMLElement
13
+ /** Position before the block in the editor's document; absent when reading. */
14
+ pos?: number
15
+ }
16
+
17
+ /** What `EditorBlockHandle` hands this part to make it edit. A reading surface leaves it off. */
18
+ export interface BlockHandleEditing {
19
+ /** The block level with `y`, in the editor's own document. */
20
+ find: (y: number) => HandleBlock | null
21
+ /** The editing rows, before the caller's. */
22
+ rows: (block: HandleBlock) => ReactNode
23
+ /** Starts the browser's drag of the block; the handle is draggable only with this. */
24
+ startDrag: (block: HandleBlock, event: globalThis.DragEvent) => void
25
+ /** Drops the dragged block before `target`, or after it: a drop in the gutter, where the editor has none. */
26
+ drop: (target: HandleBlock, after: boolean) => void
27
+ endDrag: () => void
28
+ /** Calls back when the document changes, so a block found before is found again. Returns the unsubscribe. */
29
+ onChange: (changed: () => void) => () => void
30
+ }
31
+
32
+ export interface BlockHandleProps {
33
+ /** The caller's own rows — a link to the block, a comment on it. Reading, they are the whole menu; with none, no handle is drawn. */
34
+ actions?: (block: HandleBlock) => ReactNode
35
+ /** Given by `EditorBlockHandle` (from `@estiva-app/ui/editor`). Leave it off to read. */
36
+ editing?: BlockHandleEditing
37
+ /** What the blocks are drawn in. Reading: a surface whose top-level elements carry `data-block-id`, as `RichText` draws. */
38
+ children: ReactNode
39
+ className?: string
40
+ }
41
+
42
+ /** A block's id as a reading surface writes it: on the element, or on the first inside it (a table's wrapper). */
43
+ function readId(element: HTMLElement): string | undefined {
44
+ const own = element.getAttribute('data-block-id') ?? element.querySelector('[data-block-id]')?.getAttribute('data-block-id')
45
+ return own || undefined
46
+ }
47
+
48
+ /** The last of `blocks` whose top is at or above `y` — the gap between two blocks belongs to the one before. */
49
+ export function blockAtY(blocks: readonly HandleBlock[], y: number): HandleBlock | null {
50
+ let found: HandleBlock | null = null
51
+ for (const block of blocks) {
52
+ if (block.element.getBoundingClientRect().top > y) break
53
+ found = block
54
+ }
55
+ return found ?? blocks[0] ?? null
56
+ }
57
+
58
+ /** The top-level blocks of a reading surface: its root's children that carry an id. */
59
+ function readingBlocks(root: Element | null): HandleBlock[] {
60
+ if (!root) return []
61
+ return Array.from(root.children)
62
+ .filter((el): el is HTMLElement => el instanceof HTMLElement)
63
+ .map((element) => ({ id: readId(element), element }))
64
+ .filter((b) => b.id !== undefined)
65
+ }
66
+
67
+ /** The handle's 24px, against the block's first line. */
68
+ const HANDLE = 24
69
+
70
+ /**
71
+ * How far down the block the handle starts, so it is centred on the first
72
+ * line: a heading's line is taller than a paragraph's. A table's or a list's
73
+ * first line is its first descendant's.
74
+ */
75
+ function firstLineOffset(element: HTMLElement): number {
76
+ const text = element.querySelector<HTMLElement>('p, h1, h2, h3, pre, li') ?? element
77
+ const line = parseFloat(getComputedStyle(text).lineHeight)
78
+ if (!Number.isFinite(line)) return 0
79
+ const inset = text === element ? 0 : text.getBoundingClientRect().top - element.getBoundingClientRect().top
80
+ return inset + (line - HANDLE) / 2
81
+ }
82
+
83
+ /**
84
+ * The handle at the left of the block under the pointer, and the block's menu
85
+ * behind it. Reading, the menu is the caller's rows only, so someone who
86
+ * cannot edit can still point at one block — copy a link to it, comment on
87
+ * it. In an editor, `EditorBlockHandle` adds the drag, Turn into, Duplicate
88
+ * and Delete.
89
+ *
90
+ * It sits in the gutter left of the content, 28px out, so wrapping the content
91
+ * moves nothing. The gutter counts as the block's, as in Notion: the handle
92
+ * stays while the pointer crosses to it.
93
+ */
94
+ export function BlockHandle({ actions, editing, children, className }: BlockHandleProps) {
95
+ const frame = useRef<HTMLDivElement>(null)
96
+ const content = useRef<HTMLDivElement>(null)
97
+ const [block, setBlock] = useState<HandleBlock | null>(null)
98
+ const [top, setTop] = useState(0)
99
+ const [open, setOpen] = useState(false)
100
+
101
+ const follow = useCallback(
102
+ (y: number) => {
103
+ if (!content.current || !frame.current) return
104
+ const found = editing ? editing.find(y) : blockAtY(readingBlocks(content.current.firstElementChild), y)
105
+ setBlock(found)
106
+ if (found) setTop(found.element.getBoundingClientRect().top - frame.current.getBoundingClientRect().top + firstLineOffset(found.element))
107
+ },
108
+ [editing],
109
+ )
110
+
111
+ // A block moved or deleted under the handle would leave its rows acting on a stale position.
112
+ useEffect(() => {
113
+ if (!editing) return
114
+ return editing.onChange(() => {
115
+ if (!open) setBlock(null)
116
+ })
117
+ }, [editing, open])
118
+
119
+ const rows = editing && block ? editing.rows(block) : null
120
+ const own = actions && block ? actions(block) : null
121
+
122
+ /*
123
+ A block dragged down the gutter lands by height, as in Notion: the top half
124
+ of a block puts it before, the bottom half after, and the line shows where.
125
+ Over the text, the editor draws its own line and makes its own drop.
126
+ */
127
+ const dragging = useRef(false)
128
+ const [line, setLine] = useState<number | null>(null)
129
+ const inGutter = (e: DragEvent<HTMLDivElement>) => dragging.current && !!editing && !!content.current && !content.current.contains(e.target as Node)
130
+ const landing = (y: number) => {
131
+ const target = editing?.find(y)
132
+ if (!target || !frame.current) return null
133
+ const box = target.element.getBoundingClientRect()
134
+ const after = y > box.top + box.height / 2
135
+ // Midway across the gap to the neighbour, where the editor draws its own line.
136
+ const neighbour = (after ? target.element.nextElementSibling : target.element.previousElementSibling)?.getBoundingClientRect()
137
+ const edge = after ? (neighbour ? (box.bottom + neighbour.top) / 2 : box.bottom) : neighbour ? (neighbour.bottom + box.top) / 2 : box.top
138
+ return { target, after, top: edge - frame.current.getBoundingClientRect().top }
139
+ }
140
+ const endDrag = () => {
141
+ dragging.current = false
142
+ setLine(null)
143
+ editing?.endDrag()
144
+ }
145
+
146
+ return (
147
+ <div
148
+ ref={frame}
149
+ className={cn('relative', className)}
150
+ onMouseMove={(e) => {
151
+ if (!open) follow(e.clientY)
152
+ }}
153
+ onMouseLeave={() => {
154
+ if (!open) setBlock(null)
155
+ }}
156
+ onDragOver={(e) => {
157
+ if (!inGutter(e)) return setLine(null)
158
+ e.preventDefault()
159
+ setLine(landing(e.clientY)?.top ?? null)
160
+ }}
161
+ onDrop={(e) => {
162
+ if (inGutter(e)) {
163
+ e.preventDefault()
164
+ const at = landing(e.clientY)
165
+ if (at) editing?.drop(at.target, at.after)
166
+ }
167
+ // The drop can redraw the document and take the handle with it, before its dragend.
168
+ endDrag()
169
+ }}
170
+ onDragLeave={(e) => {
171
+ if (!frame.current?.contains(e.relatedTarget as Node)) setLine(null)
172
+ }}
173
+ >
174
+ <div ref={content}>{children}</div>
175
+ {/* The gutter is the frame's own, so the pointer on its way from the text to the handle never leaves it. */}
176
+ <div aria-hidden className="absolute inset-y-0 -left-7 w-7" />
177
+ {line !== null && <div aria-hidden className="pointer-events-none absolute inset-x-0 h-px bg-accent-primary" style={{ top: line }} />}
178
+ {block && (rows || own) && (
179
+ // The top is the block's, measured: it depends on the document, not on a size of ours.
180
+ <div className={cn('absolute -left-7', rows && 'cursor-grab active:cursor-grabbing')} style={{ top }}>
181
+ <Menu
182
+ open={open}
183
+ onOpenChange={setOpen}
184
+ trigger={
185
+ <IconButton
186
+ aria-label="Block menu"
187
+ tooltip={rows ? 'Drag to move, click for the menu' : 'Menu'}
188
+ draggable={!!rows}
189
+ // Base UI opens a menu on the press, so a drag flashed it open.
190
+ // The handle's menu opens on the click instead, as Notion's does:
191
+ // a press that becomes a drag never clicks.
192
+ onMouseDown={(e: BaseUIEvent<MouseEvent<HTMLButtonElement>>) => e.preventBaseUIHandler()}
193
+ onClick={(e: BaseUIEvent<MouseEvent<HTMLButtonElement>>) => {
194
+ e.preventBaseUIHandler()
195
+ setOpen((was) => !was)
196
+ }}
197
+ onDragStart={(e: DragEvent<HTMLButtonElement>) => {
198
+ if (!editing || !rows) return
199
+ setOpen(false)
200
+ dragging.current = true
201
+ editing.startDrag(block, e.nativeEvent)
202
+ }}
203
+ onDragEnd={() => {
204
+ endDrag()
205
+ setBlock(null)
206
+ }}
207
+ >
208
+ <IconGripVertical size={16} stroke={1.5} />
209
+ </IconButton>
210
+ }
211
+ >
212
+ {rows}
213
+ {rows && own && <MenuSeparator />}
214
+ {own}
215
+ </Menu>
216
+ </div>
217
+ )}
218
+ </div>
219
+ )
220
+ }
@@ -0,0 +1,65 @@
1
+ import { Meta, Canvas } from '@storybook/addon-docs/blocks'
2
+ import * as EditorBlockHandleStories from './EditorBlockHandle.stories'
3
+
4
+ <Meta of={EditorBlockHandleStories} />
5
+
6
+ # EditorBlockHandle
7
+
8
+ `BlockHandle` in a Tiptap editor: drag the handle to move the block, press it
9
+ for Turn into, Duplicate and Delete, then the app's rows.
10
+
11
+ <Canvas of={EditorBlockHandleStories.Default} />
12
+
13
+ ## When
14
+
15
+ - **An editor of a block document** (SPEC §13.3) that someone reorganises —
16
+ with `BlockNodes`' `BlockId` in its extensions, so every block has an id.
17
+ - **Not editable**: the same handle reads — no drag, the app's rows only.
18
+
19
+ ## When not
20
+
21
+ - The document being read, with no editor → **BlockHandle**, from the main
22
+ entry, which loads no Tiptap.
23
+ - Formatting a few words inside a block → **SelectionToolbar**. This acts on
24
+ whole blocks.
25
+
26
+ ## How
27
+
28
+ ```tsx
29
+ import { EditorContent, useEditor } from '@tiptap/react'
30
+ import StarterKit from '@tiptap/starter-kit'
31
+ import { BlockId, EditorBlockHandle, type BlockTurnInto } from '@estiva-app/ui/editor'
32
+ import { MenuItem, type HandleBlock } from '@estiva-app/ui'
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
+ const rows = (block: HandleBlock) => <MenuItem label="Copy link" onClick={() => navigator.clipboard.writeText(`#${block.id}`)} />
39
+
40
+ const editor = useEditor({ extensions: [StarterKit, BlockId] })
41
+
42
+ <EditorBlockHandle editor={editor} turnInto={turnInto} actions={rows}>
43
+ <EditorContent editor={editor} />
44
+ </EditorBlockHandle>
45
+ ```
46
+
47
+ - `turnInto`'s `run` gets the editor with the caret already in the block, and
48
+ is offered only for a block of text: a paragraph, a heading, code.
49
+ - **Every row leaves the editor focused**, as a drop does: Turn into in the
50
+ block, Duplicate in the copy, Delete where the block was. An editor that
51
+ saves when the person leaves it saves these the same way.
52
+ - `duplicateBlock`, `deleteBlock` and `startBlockDrag` are the rows' own
53
+ actions, for a caller that offers them elsewhere (a shortcut, a test).
54
+
55
+ <Canvas of={EditorBlockHandleStories.NotEditable} />
56
+
57
+ ## What it owns
58
+
59
+ It does these by itself. Write none of them around it.
60
+
61
+ | It does | So you never write | Refused when written by hand |
62
+ |---|---|---|
63
+ | Moves the block through ProseMirror's own drop, so it arrives with its id and its anchored comments still find it | a drop handler, or a delete and an insert | |
64
+ | Duplicates a block under a fresh id, the original keeping its own (`BlockId` decides) | an id for the copy | |
65
+ | Puts the caret in the block before a Turn into row runs | a selection before your `run` | |
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { EditorContent, useEditor } from '@tiptap/react'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import { IconLink, IconMessage } from '@tabler/icons-react'
5
+ import type { HandleBlock } from './BlockHandle'
6
+ import { BlockId } from './BlockNodes'
7
+ import { EditorBlockHandle, blockDropCursor, type BlockTurnInto } from './EditorBlockHandle'
8
+ import { MenuItem } from './Menu'
9
+ import { richTextClassName } from './RichText'
10
+
11
+ /**
12
+ * `BlockHandle` in an editor. Drag the handle to move a block; press it for
13
+ * Turn into, Duplicate, Delete, then the app's rows.
14
+ */
15
+ const meta = {
16
+ title: 'Primitives/EditorBlockHandle',
17
+ // The handle sits 28px left of the content, in the page's margin.
18
+ parameters: { layout: 'padded' },
19
+ } satisfies Meta
20
+
21
+ export default meta
22
+ type Story = StoryObj
23
+
24
+ const DOCUMENT = {
25
+ type: 'doc',
26
+ content: [
27
+ { type: 'heading', attrs: { level: 1, blockId: 'b1' }, content: [{ type: 'text', text: 'A heading' }] },
28
+ { type: 'paragraph', attrs: { blockId: 'b2' }, content: [{ type: 'text', text: 'The first paragraph. Drag its handle below the list.' }] },
29
+ { type: 'paragraph', attrs: { blockId: 'b3' }, content: [{ type: 'text', text: 'The second paragraph.' }] },
30
+ {
31
+ type: 'bulletList',
32
+ attrs: { blockId: 'b4' },
33
+ content: [
34
+ { type: 'listItem', attrs: { blockId: 'b5' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item one' }] }] },
35
+ { type: 'listItem', attrs: { blockId: 'b6' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item two' }] }] },
36
+ ],
37
+ },
38
+ { type: 'paragraph', attrs: { blockId: 'b7' }, content: [{ type: 'text', text: 'The last paragraph.' }] },
39
+ ],
40
+ }
41
+
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
+ /** The rows an app adds: here they only say which block they were pressed on. */
50
+ function rows(block: HandleBlock) {
51
+ return (
52
+ <>
53
+ <MenuItem label="Copy link" leading={<IconLink size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => console.info('copy link', block.id)} />
54
+ <MenuItem label="Comment" leading={<IconMessage size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => console.info('comment', block.id)} />
55
+ </>
56
+ )
57
+ }
58
+
59
+ function Editing({ editable }: { editable: boolean }) {
60
+ const editor = useEditor({
61
+ extensions: [StarterKit.configure({ dropcursor: blockDropCursor }), BlockId],
62
+ content: DOCUMENT,
63
+ editable,
64
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
65
+ })
66
+ return (
67
+ <EditorBlockHandle editor={editor} turnInto={TURN_INTO} actions={rows} className="ml-8 max-w-[560px]">
68
+ <EditorContent editor={editor} />
69
+ </EditorBlockHandle>
70
+ )
71
+ }
72
+
73
+ /** Drag to move; Turn into, Duplicate and Delete; then the app's rows. */
74
+ export const Default: Story = {
75
+ render: () => <Editing editable />,
76
+ }
77
+
78
+ /** An editor that is not editable: no drag, and only the app's rows. */
79
+ export const NotEditable: Story = {
80
+ render: () => <Editing editable={false} />,
81
+ }