@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.
@@ -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
+ }
@@ -0,0 +1,181 @@
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import type { Editor } from '@tiptap/core'
3
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
4
+ import type { EditorView } from '@tiptap/pm/view'
5
+ import { IconCopy, IconTrash } from '@tabler/icons-react'
6
+ import { BlockHandle, blockAtY, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
7
+ import { MenuItem, MenuSub } from './Menu'
8
+
9
+ /** A row of the block menu's Turn into: what the block becomes. */
10
+ export interface BlockTurnInto {
11
+ label: string
12
+ /** Turns the block the caret is in. The handle puts the caret there first. */
13
+ run: (editor: Editor) => void
14
+ /** Whether the block already is this — drawn as the selected row. */
15
+ active?: (node: ProseMirrorNode) => boolean
16
+ }
17
+
18
+ export interface EditorBlockHandleProps {
19
+ /** The editor. Not editable, or not there yet, and the handle reads: the caller's rows only. */
20
+ editor: Editor | null
21
+ /** Turn into's rows. Offered for a block of text — a paragraph, a heading, code. */
22
+ turnInto?: readonly BlockTurnInto[]
23
+ /** The caller's own rows, after the editing ones. */
24
+ actions?: (block: HandleBlock) => ReactNode
25
+ /** The `EditorContent`. */
26
+ children: ReactNode
27
+ className?: string
28
+ }
29
+
30
+ /** The editor's top-level blocks, where each starts and what it is. */
31
+ function editorBlocks(view: EditorView): HandleBlock[] {
32
+ const out: HandleBlock[] = []
33
+ view.state.doc.forEach((node, pos) => {
34
+ const element = view.nodeDOM(pos)
35
+ if (element instanceof HTMLElement) out.push({ id: (node.attrs.blockId as string | null) || undefined, element, pos })
36
+ })
37
+ return out
38
+ }
39
+
40
+ /*
41
+ Duplicate and Delete leave the editor focused, as Turn into and a drop do: the
42
+ person is still in the field, so an editor that saves when they leave it saves
43
+ these too — and one that loads someone else's edit while unfocused does not
44
+ load it over them.
45
+ */
46
+
47
+ /** A copy of the block straight after it, the caret in the copy. `BlockId` gives the copy a fresh id; the original keeps its own. */
48
+ export function duplicateBlock(editor: Editor, pos: number): boolean {
49
+ const node = editor.state.doc.nodeAt(pos)
50
+ if (!node) return false
51
+ editor.view.dispatch(editor.state.tr.insert(pos + node.nodeSize, node).scrollIntoView())
52
+ editor.commands.focus(pos + node.nodeSize + 1)
53
+ return true
54
+ }
55
+
56
+ /** Removes the block, the caret where it was. The last block of a document becomes an empty paragraph, since a document holds at least one. */
57
+ export function deleteBlock(editor: Editor, pos: number): boolean {
58
+ const { state } = editor
59
+ const node = state.doc.nodeAt(pos)
60
+ if (!node) return false
61
+ const tr = state.doc.childCount === 1 ? state.tr.replaceWith(pos, pos + node.nodeSize, state.schema.nodes.paragraph.create()) : state.tr.delete(pos, pos + node.nodeSize)
62
+ editor.view.dispatch(tr)
63
+ editor.commands.focus(Math.min(pos + 1, editor.state.doc.content.size))
64
+ return true
65
+ }
66
+
67
+ /**
68
+ * Moves the block at `from` to `to` (a position between top-level blocks), the
69
+ * caret in it: what a drop in the gutter does, where the editor has no drop of
70
+ * its own. The node moves whole, attributes and all, so it keeps its id, as
71
+ * ProseMirror's drop keeps it.
72
+ */
73
+ export function moveBlock(editor: Editor, from: number, to: number): boolean {
74
+ const node = editor.state.doc.nodeAt(from)
75
+ if (!node || (to >= from && to <= from + node.nodeSize)) return false
76
+ const tr = editor.state.tr.delete(from, from + node.nodeSize)
77
+ const at = tr.mapping.map(to)
78
+ editor.view.dispatch(tr.insert(at, node).scrollIntoView())
79
+ editor.commands.focus(at + 1)
80
+ return true
81
+ }
82
+
83
+ /**
84
+ * Starts the browser's own drag of the block, as ProseMirror starts one of a
85
+ * selected node: the block selected, its slice on `view.dragging` with `move`,
86
+ * and its HTML on the transfer. ProseMirror's drop then deletes the selection
87
+ * and inserts the slice with its attributes — so the block arrives with the
88
+ * id it left with, and every comment anchored to it still finds it.
89
+ */
90
+ export function startBlockDrag(editor: Editor, pos: number, event: Pick<globalThis.DragEvent, 'dataTransfer'>, image?: Element) {
91
+ editor.commands.setNodeSelection(pos)
92
+ const { view } = editor
93
+ const slice = view.state.selection.content()
94
+ const { dom, text } = view.serializeForClipboard(slice)
95
+ const transfer = event.dataTransfer
96
+ if (transfer) {
97
+ transfer.clearData()
98
+ transfer.setData('text/html', dom.innerHTML)
99
+ transfer.setData('text/plain', text)
100
+ transfer.effectAllowed = 'copyMove'
101
+ if (image) transfer.setDragImage(image, 0, 0)
102
+ }
103
+ view.dragging = { slice, move: true }
104
+ }
105
+
106
+ /**
107
+ * The drop line a dragged block shows, in the package's accent instead of the
108
+ * editor's default (the text's colour). Give it to the editor's StarterKit:
109
+ * `StarterKit.configure({ dropcursor: blockDropCursor })`.
110
+ */
111
+ export const blockDropCursor = { color: false, class: 'bg-accent-primary' } as const
112
+
113
+ const NOTHING_YET: BlockHandleEditing = { find: () => null, rows: () => null, startDrag: () => {}, drop: () => {}, endDrag: () => {}, onChange: () => () => {} }
114
+
115
+ /**
116
+ * `BlockHandle` in an editor: drag the handle to move the block, and the menu
117
+ * gains Turn into, Duplicate and Delete before the caller's rows.
118
+ */
119
+ export function EditorBlockHandle({ editor, turnInto = [], actions, children, className }: EditorBlockHandleProps) {
120
+ const editing = useMemo<BlockHandleEditing>(() => {
121
+ // Before the editor exists there is no block to find — not the editor's whole element read as one.
122
+ if (!editor) return NOTHING_YET
123
+ // Where the block being dragged starts.
124
+ let dragged: number | undefined
125
+ return {
126
+ find: (y) => blockAtY(editorBlocks(editor.view), y),
127
+ rows: (block) => {
128
+ const pos = block.pos
129
+ // Not editable: the caller's rows only, and no drag.
130
+ if (!editor.isEditable) return null
131
+ const node = pos === undefined ? null : editor.state.doc.nodeAt(pos)
132
+ if (pos === undefined || !node) return null
133
+ return (
134
+ <>
135
+ {node.isTextblock && turnInto.length > 0 && (
136
+ <MenuSub label="Turn into">
137
+ {turnInto.map((row) => (
138
+ <MenuItem
139
+ key={row.label}
140
+ label={row.label}
141
+ selected={row.active?.(node) ?? false}
142
+ onClick={() => {
143
+ // Inside the block's text, so the row's command turns this block and no other.
144
+ editor.chain().focus().setTextSelection(pos + 1).run()
145
+ row.run(editor)
146
+ }}
147
+ />
148
+ ))}
149
+ </MenuSub>
150
+ )}
151
+ <MenuItem label="Duplicate" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => duplicateBlock(editor, pos)} />
152
+ <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => deleteBlock(editor, pos)} />
153
+ </>
154
+ )
155
+ },
156
+ startDrag: (block, event) => {
157
+ dragged = block.pos
158
+ if (block.pos !== undefined) startBlockDrag(editor, block.pos, event, block.element)
159
+ },
160
+ drop: (target, after) => {
161
+ const node = target.pos === undefined ? null : editor.state.doc.nodeAt(target.pos)
162
+ if (dragged === undefined || target.pos === undefined || !node) return
163
+ moveBlock(editor, dragged, after ? target.pos + node.nodeSize : target.pos)
164
+ },
165
+ // ProseMirror clears `dragging` on its own dragend, which a drag started outside its element never reaches.
166
+ endDrag: () => setTimeout(() => (editor.view.dragging = null), 50),
167
+ onChange: (changed) => {
168
+ editor.on('update', changed)
169
+ return () => {
170
+ editor.off('update', changed)
171
+ }
172
+ },
173
+ }
174
+ }, [editor, turnInto])
175
+
176
+ return (
177
+ <BlockHandle editing={editing} actions={actions} className={className}>
178
+ {children}
179
+ </BlockHandle>
180
+ )
181
+ }
package/src/editor.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
11
11
  export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
12
+ export { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
12
13
  export {
13
14
  PersonMention,
14
15
  UrgentPersonMention,
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export { AttachmentCard, type AttachmentCardProps, type AttachmentCardState } fr
14
14
  export { Avatar, hueFor, initialsFor, type AvatarProps } from './Avatar'
15
15
  export { AvatarGroup, type AvatarGroupMember, type AvatarGroupProps } from './AvatarGroup'
16
16
  export { Banner, type BannerProps, type BannerTone } from './Banner'
17
+ export { BlockHandle, blockAtY, type BlockHandleProps, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
17
18
  export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button'
18
19
  export { Card, type CardAttention, type CardFill, type CardHover, type CardProps } from './Card'
19
20
  export { Checkbox, type CheckboxProps } from './Checkbox'
@@ -48,20 +48,20 @@ describe('the registry builds', () => {
48
48
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
49
49
  })
50
50
 
51
- it('counts 122 names over 67 files: 98 components, 23 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October)', () => {
51
+ it('counts 130 names over 69 files: 100 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, and moveBlock and blockDropCursor, the drop in the gutter, on 5 October)', () => {
52
52
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
53
53
  // The ten `EDITOR_EXTENSIONS` count as components by their names; they are
54
54
  // editor extensions, documented on SelectionToolbar's, BlockNodes' and
55
55
  // ComposerTriggers' pages.
56
56
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
57
- expect(kinds).toEqual({ component: 98, helper: 23, hook: 1 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(67)
57
+ expect(kinds).toEqual({ component: 100, helper: 29, hook: 1 })
58
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(69)
59
59
  })
60
60
 
61
61
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
62
62
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
63
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(63)
64
- expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(59)
63
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(65)
64
+ expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(65)
65
65
  })
66
66
 
67
67
  it('takes a name with a page of its own from the page, and a name without one from the code', () => {
@@ -70,6 +70,8 @@ fork freely, owe nothing back, mention it on the package's ticket.
70
70
  | Bold, italic and the like over selected text in an editor | **SelectionToolbar**, from `@estiva-app/ui/editor` — it follows the selection by itself |
71
71
  | A composer's `@`, `!@`, `[` and `/` — mention a person, reference something, format or insert | **ComposerTriggers** — `PersonMention`, `UrgentPersonMention`, `CaptionedReference`, `ReferenceTrigger` and `SlashCommands` from `@estiva-app/ui/editor`; the app passes its people, its items and its own `/` rows |
72
72
  | An editor that saves a block document and must keep its block ids | **BlockNodes** — `BlockId`, `UnknownBlock`, `ReferenceNode` and `AttachmentNode` from `@estiva-app/ui/editor`, beside StarterKit and TableKit |
73
+ | Move, turn, duplicate or delete one block of a document being edited | **EditorBlockHandle**, from `@estiva-app/ui/editor`, with your rows after its own |
74
+ | Offer a link or a comment on one block of a document being read | **BlockHandle** — your rows only, and no Tiptap loaded |
73
75
  | Anything with a title, body and footer | **DialogShell** |
74
76
  | "Are you sure?" | **ConfirmDialog** |
75
77
  | A picture full screen, from a thumbnail | **Lightbox** — an **AttachmentCard** opens one by itself |