@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.
- 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/NavItem.d.ts +14 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +93 -13
- package/dist/NavTree.d.ts.map +1 -1
- package/dist/UnreadDot.d.ts +12 -0
- package/dist/UnreadDot.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/eslint/index.js +1 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +801 -584
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +1 -1
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +362 -11
- 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/NavItem.mdx +14 -1
- package/src/NavItem.stories.tsx +15 -0
- package/src/NavItem.test.tsx +31 -0
- package/src/NavItem.tsx +49 -12
- package/src/NavTree.mdx +25 -0
- package/src/NavTree.stories.tsx +57 -1
- package/src/NavTree.test.tsx +78 -1
- package/src/NavTree.tsx +175 -40
- package/src/SectionLabel.stories.tsx +2 -3
- package/src/UnreadDot.mdx +57 -0
- package/src/UnreadDot.stories.tsx +22 -0
- package/src/UnreadDot.test.tsx +17 -0
- package/src/UnreadDot.tsx +17 -0
- package/src/editor.ts +1 -0
- package/src/eslint/no-native-title.ts +1 -1
- package/src/index.ts +3 -1
- package/src/looks.ts +2 -1
- package/src/registry/registry.test.ts +5 -5
- package/stories/Choosing.mdx +3 -0
- package/tailwind-preset.js +1 -0
- 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
|
+
}
|