@boyernick/standard-ui-react 0.1.1-canary.9 → 0.2.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/package.json +5 -3
- package/src/accordion.tsx +5 -2
- package/src/alert-dialog.tsx +1 -1
- package/src/attachment.tsx +5 -2
- package/src/autocomplete.tsx +4 -3
- package/src/block-editor.tsx +1747 -0
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +14 -7
- package/src/button.tsx +22 -11
- package/src/calendar.tsx +6 -3
- package/src/carousel.tsx +220 -53
- package/src/checkbox.tsx +3 -2
- package/src/code-block.tsx +66 -5
- package/src/collapsible.tsx +4 -2
- package/src/combobox.tsx +4 -3
- package/src/command.tsx +27 -12
- package/src/dialog.tsx +33 -10
- package/src/empty.tsx +4 -4
- package/src/field.tsx +4 -3
- package/src/filter-group.tsx +143 -0
- package/src/icons.tsx +28 -1
- package/src/illustrations.tsx +219 -141
- package/src/index.ts +238 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +10 -4
- package/src/lib/focus.ts +41 -0
- package/src/lib/popup.ts +1 -1
- package/src/lifeline/company-icon.tsx +71 -0
- package/src/lifeline/index.ts +42 -0
- package/src/lifeline/lifeline-data.ts +97 -0
- package/src/lifeline/lifeline-desktop.tsx +204 -0
- package/src/lifeline/lifeline-event.tsx +108 -0
- package/src/lifeline/lifeline-fireworks.tsx +302 -0
- package/src/lifeline/lifeline-hover-image.tsx +269 -0
- package/src/lifeline/lifeline-icons.tsx +39 -0
- package/src/lifeline/lifeline-intro-timing.ts +105 -0
- package/src/lifeline/lifeline-labels.tsx +24 -0
- package/src/lifeline/lifeline-layout.ts +1 -0
- package/src/lifeline/lifeline-legend.tsx +31 -0
- package/src/lifeline/lifeline-lightbox.tsx +309 -0
- package/src/lifeline/lifeline-marker.tsx +183 -0
- package/src/lifeline/lifeline-people.tsx +99 -0
- package/src/lifeline/lifeline-photos.tsx +366 -0
- package/src/lifeline/lifeline-shell.tsx +135 -0
- package/src/lifeline/lifeline-utils.ts +83 -0
- package/src/lifeline/lifeline-vertical.tsx +482 -0
- package/src/lifeline/lifeline.tsx +69 -0
- package/src/lifeline/types.ts +112 -0
- package/src/lifeline/use-lifeline-intro.ts +130 -0
- package/src/lifeline/use-lifeline-scroll.ts +1011 -0
- package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
- package/src/menubar.tsx +9 -2
- package/src/minimap.tsx +296 -0
- package/src/modal.tsx +608 -0
- package/src/navigation-menu.tsx +338 -67
- package/src/number-field.tsx +336 -61
- package/src/otp-field.tsx +4 -3
- package/src/pagination.tsx +262 -42
- package/src/password-protection.tsx +345 -0
- package/src/popover.tsx +1 -1
- package/src/progress.tsx +5 -0
- package/src/questionnaire.tsx +284 -0
- package/src/radio.tsx +4 -2
- package/src/scroll-area.tsx +4 -1
- package/src/select.tsx +5 -2
- package/src/sidebar.tsx +113 -28
- package/src/slider.tsx +66 -6
- package/src/sounds.tsx +6 -10
- package/src/spinner.tsx +105 -32
- package/src/switch.tsx +4 -1
- package/src/table.tsx +82 -15
- package/src/tabs.tsx +189 -38
- package/src/text-animate.tsx +71 -28
- package/src/textarea.tsx +6 -1
- package/src/timeline.tsx +378 -0
- package/src/toast.tsx +214 -35
- package/src/toggle.tsx +4 -2
- package/src/toolbar.tsx +12 -7
- package/src/tooltip.tsx +43 -3
- package/src/video-player.tsx +396 -127
- package/src/image-modal.tsx +0 -110
- package/src/markdown-editor.tsx +0 -302
|
@@ -0,0 +1,1747 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type HTMLAttributes,
|
|
10
|
+
type PointerEvent as ReactPointerEvent,
|
|
11
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
12
|
+
type MouseEvent as ReactMouseEvent,
|
|
13
|
+
type ReactElement,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from "react"
|
|
16
|
+
import { Checkbox } from "./checkbox"
|
|
17
|
+
import { IconChainLink3, IconDotGrid2x3, IconHighlight, IconStrikeThrough } from "./icons"
|
|
18
|
+
import { Kbd, KbdGroup } from "./kbd"
|
|
19
|
+
import { cn } from "./lib/cn"
|
|
20
|
+
import { motion } from "./lib/motion"
|
|
21
|
+
import { popupInset, popupItem, popupSurface } from "./lib/popup"
|
|
22
|
+
import {
|
|
23
|
+
Menu,
|
|
24
|
+
MenuItem,
|
|
25
|
+
MenuPopup,
|
|
26
|
+
MenuPortal,
|
|
27
|
+
MenuPositioner,
|
|
28
|
+
MenuTrigger,
|
|
29
|
+
} from "./menu"
|
|
30
|
+
import {
|
|
31
|
+
Toolbar,
|
|
32
|
+
ToolbarButton,
|
|
33
|
+
ToolbarGroup,
|
|
34
|
+
ToolbarSeparator,
|
|
35
|
+
} from "./toolbar"
|
|
36
|
+
import {
|
|
37
|
+
Tooltip,
|
|
38
|
+
TooltipPopup,
|
|
39
|
+
TooltipPortal,
|
|
40
|
+
TooltipPositioner,
|
|
41
|
+
TooltipProvider,
|
|
42
|
+
TooltipTrigger,
|
|
43
|
+
} from "./tooltip"
|
|
44
|
+
|
|
45
|
+
export type BlockEditorBlockType =
|
|
46
|
+
| "text"
|
|
47
|
+
| "heading1"
|
|
48
|
+
| "heading2"
|
|
49
|
+
| "heading3"
|
|
50
|
+
| "divider"
|
|
51
|
+
| "checklist"
|
|
52
|
+
| "numbered"
|
|
53
|
+
| "bulleted"
|
|
54
|
+
|
|
55
|
+
export type BlockEditorBlock = {
|
|
56
|
+
id: string
|
|
57
|
+
type: BlockEditorBlockType
|
|
58
|
+
content: string
|
|
59
|
+
checked?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type SlashCommand = {
|
|
63
|
+
type: BlockEditorBlockType
|
|
64
|
+
label: string
|
|
65
|
+
description: string
|
|
66
|
+
keywords: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SLASH_COMMANDS: SlashCommand[] = [
|
|
70
|
+
{
|
|
71
|
+
type: "heading1",
|
|
72
|
+
label: "Heading 1",
|
|
73
|
+
description: "Large section title",
|
|
74
|
+
keywords: ["heading", "h1", "title"],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: "heading2",
|
|
78
|
+
label: "Heading 2",
|
|
79
|
+
description: "Medium section title",
|
|
80
|
+
keywords: ["heading", "h2", "subtitle"],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: "heading3",
|
|
84
|
+
label: "Heading 3",
|
|
85
|
+
description: "Small section title",
|
|
86
|
+
keywords: ["heading", "h3"],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: "numbered",
|
|
90
|
+
label: "Numbered list",
|
|
91
|
+
description: "Ordered list item",
|
|
92
|
+
keywords: ["numbered", "ordered", "ol", "list"],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
type: "bulleted",
|
|
96
|
+
label: "Bulleted list",
|
|
97
|
+
description: "Unordered list item",
|
|
98
|
+
keywords: ["bulleted", "bullet", "ul", "list"],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: "checklist",
|
|
102
|
+
label: "Checklist",
|
|
103
|
+
description: "To-do with a checkbox",
|
|
104
|
+
keywords: ["checklist", "todo", "task", "checkbox"],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: "divider",
|
|
108
|
+
label: "Divider",
|
|
109
|
+
description: "Horizontal rule",
|
|
110
|
+
keywords: ["divider", "separator", "line", "hr", "rule"],
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
/** Pixels of vertical travel before a handle press becomes a drag. */
|
|
115
|
+
const DRAG_THRESHOLD = 4
|
|
116
|
+
|
|
117
|
+
const createId = () =>
|
|
118
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
119
|
+
? crypto.randomUUID()
|
|
120
|
+
: `block-${Math.random().toString(36).slice(2, 10)}`
|
|
121
|
+
|
|
122
|
+
export const createBlockEditorBlock = (
|
|
123
|
+
type: BlockEditorBlockType = "text",
|
|
124
|
+
content = "",
|
|
125
|
+
): BlockEditorBlock => ({
|
|
126
|
+
id: createId(),
|
|
127
|
+
type,
|
|
128
|
+
content,
|
|
129
|
+
checked: type === "checklist" ? false : undefined,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
export const defaultBlockEditorBlocks: BlockEditorBlock[] = [
|
|
133
|
+
{
|
|
134
|
+
id: "demo-h1",
|
|
135
|
+
type: "heading1",
|
|
136
|
+
content: "Heading 1",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "demo-h2",
|
|
140
|
+
type: "heading2",
|
|
141
|
+
content: "Heading 2",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "demo-h3",
|
|
145
|
+
type: "heading3",
|
|
146
|
+
content: "Heading 3",
|
|
147
|
+
},
|
|
148
|
+
{ id: "demo-divider", type: "divider", content: "" },
|
|
149
|
+
{
|
|
150
|
+
id: "demo-p1",
|
|
151
|
+
type: "text",
|
|
152
|
+
content:
|
|
153
|
+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "demo-p2",
|
|
157
|
+
type: "text",
|
|
158
|
+
content:
|
|
159
|
+
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "demo-check-1",
|
|
163
|
+
type: "checklist",
|
|
164
|
+
content: "Review the opening section",
|
|
165
|
+
checked: false,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "demo-check-2",
|
|
169
|
+
type: "checklist",
|
|
170
|
+
content: "Tighten the supporting copy",
|
|
171
|
+
checked: false,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "demo-num-1",
|
|
175
|
+
type: "numbered",
|
|
176
|
+
content: "Gather source material",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: "demo-num-2",
|
|
180
|
+
type: "numbered",
|
|
181
|
+
content: "Draft the first pass",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: "demo-num-3",
|
|
185
|
+
type: "numbered",
|
|
186
|
+
content: "Share for review",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: "demo-bullet-1",
|
|
190
|
+
type: "bulleted",
|
|
191
|
+
content: "Keep sentences short",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "demo-bullet-2",
|
|
195
|
+
type: "bulleted",
|
|
196
|
+
content: "Prefer concrete verbs",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: "demo-bullet-3",
|
|
200
|
+
type: "bulleted",
|
|
201
|
+
content: "Cut anything that repeats",
|
|
202
|
+
},
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
const blockTextClassName: Record<
|
|
206
|
+
Exclude<BlockEditorBlockType, "divider">,
|
|
207
|
+
string
|
|
208
|
+
> = {
|
|
209
|
+
text: "text-base leading-7 text-fg-primary",
|
|
210
|
+
heading1: "heading-2xl-sans text-fg-primary",
|
|
211
|
+
heading2: "heading-xl-sans text-fg-primary",
|
|
212
|
+
heading3: "heading-md text-fg-primary",
|
|
213
|
+
checklist: "text-base leading-7 text-fg-primary",
|
|
214
|
+
numbered: "text-base leading-7 text-fg-primary",
|
|
215
|
+
bulleted: "text-base leading-7 text-fg-primary",
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const placeholderFor = (type: BlockEditorBlockType) => {
|
|
219
|
+
switch (type) {
|
|
220
|
+
case "heading1":
|
|
221
|
+
return "Heading 1"
|
|
222
|
+
case "heading2":
|
|
223
|
+
return "Heading 2"
|
|
224
|
+
case "heading3":
|
|
225
|
+
return "Heading 3"
|
|
226
|
+
case "checklist":
|
|
227
|
+
return "To-do"
|
|
228
|
+
case "numbered":
|
|
229
|
+
case "bulleted":
|
|
230
|
+
return "List item"
|
|
231
|
+
case "divider":
|
|
232
|
+
return ""
|
|
233
|
+
default:
|
|
234
|
+
return "Write or type / for commands..."
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const isBlankHtml = (html: string) => {
|
|
239
|
+
const text = html
|
|
240
|
+
.replace(/<br\s*\/?>/gi, "")
|
|
241
|
+
.replace(/ /gi, " ")
|
|
242
|
+
.replace(/<[^>]+>/g, "")
|
|
243
|
+
.replace(/\u00A0/g, " ")
|
|
244
|
+
.trim()
|
|
245
|
+
return text.length === 0
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const isSlashOnlyHtml = (html: string) => {
|
|
249
|
+
const text = html
|
|
250
|
+
.replace(/<br\s*\/?>/gi, "")
|
|
251
|
+
.replace(/ /gi, " ")
|
|
252
|
+
.replace(/<[^>]+>/g, "")
|
|
253
|
+
.replace(/\u00A0/g, " ")
|
|
254
|
+
.trim()
|
|
255
|
+
return /^\/[^\n]*$/.test(text)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const fieldPlainText = (field: HTMLElement) =>
|
|
259
|
+
(field.textContent ?? "").replace(/\u00A0/g, " ")
|
|
260
|
+
|
|
261
|
+
const placeCaretAtEnd = (field: HTMLElement) => {
|
|
262
|
+
const selection = window.getSelection()
|
|
263
|
+
if (!selection) return
|
|
264
|
+
const range = document.createRange()
|
|
265
|
+
range.selectNodeContents(field)
|
|
266
|
+
range.collapse(false)
|
|
267
|
+
selection.removeAllRanges()
|
|
268
|
+
selection.addRange(range)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const textBeforeCaret = (field: HTMLElement) => {
|
|
272
|
+
const selection = window.getSelection()
|
|
273
|
+
if (!selection || selection.rangeCount === 0) return fieldPlainText(field)
|
|
274
|
+
if (!field.contains(selection.anchorNode)) return fieldPlainText(field)
|
|
275
|
+
const range = selection.getRangeAt(0).cloneRange()
|
|
276
|
+
range.selectNodeContents(field)
|
|
277
|
+
range.setEnd(selection.getRangeAt(0).endContainer, selection.getRangeAt(0).endOffset)
|
|
278
|
+
return range.toString().replace(/\u00A0/g, " ")
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const isCaretAtStart = (field: HTMLElement) => {
|
|
282
|
+
const selection = window.getSelection()
|
|
283
|
+
if (!selection || !selection.isCollapsed || selection.rangeCount === 0) {
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
if (!field.contains(selection.anchorNode)) return false
|
|
287
|
+
const range = selection.getRangeAt(0).cloneRange()
|
|
288
|
+
range.selectNodeContents(field)
|
|
289
|
+
range.setEnd(selection.anchorNode as Node, selection.anchorOffset)
|
|
290
|
+
return range.toString().length === 0
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const isCaretAtEnd = (field: HTMLElement) => {
|
|
294
|
+
const selection = window.getSelection()
|
|
295
|
+
if (!selection || !selection.isCollapsed || selection.rangeCount === 0) {
|
|
296
|
+
return false
|
|
297
|
+
}
|
|
298
|
+
if (!field.contains(selection.anchorNode)) return false
|
|
299
|
+
const range = selection.getRangeAt(0).cloneRange()
|
|
300
|
+
range.selectNodeContents(field)
|
|
301
|
+
range.setStart(selection.anchorNode as Node, selection.anchorOffset)
|
|
302
|
+
return range.toString().length === 0
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const selectionIsExpandedIn = (field: HTMLElement) => {
|
|
306
|
+
const selection = window.getSelection()
|
|
307
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
return field.contains(selection.anchorNode) && field.contains(selection.focusNode)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const highlightSelection = () => {
|
|
314
|
+
const selection = window.getSelection()
|
|
315
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
|
|
316
|
+
|
|
317
|
+
const range = selection.getRangeAt(0)
|
|
318
|
+
const existing = range.commonAncestorContainer.parentElement?.closest("mark")
|
|
319
|
+
if (existing && existing.isContentEditable !== false) {
|
|
320
|
+
const parent = existing.parentNode
|
|
321
|
+
if (!parent) return
|
|
322
|
+
while (existing.firstChild) parent.insertBefore(existing.firstChild, existing)
|
|
323
|
+
parent.removeChild(existing)
|
|
324
|
+
parent.normalize()
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const mark = document.createElement("mark")
|
|
329
|
+
mark.className = "rounded-2xs bg-status-warning-background text-inherit"
|
|
330
|
+
mark.appendChild(range.extractContents())
|
|
331
|
+
range.insertNode(mark)
|
|
332
|
+
selection.removeAllRanges()
|
|
333
|
+
const next = document.createRange()
|
|
334
|
+
next.selectNodeContents(mark)
|
|
335
|
+
selection.addRange(next)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const filterCommands = (query: string) => {
|
|
339
|
+
const normalized = query.trim().toLowerCase()
|
|
340
|
+
if (!normalized) return SLASH_COMMANDS
|
|
341
|
+
return SLASH_COMMANDS.filter(
|
|
342
|
+
(command) =>
|
|
343
|
+
command.label.toLowerCase().includes(normalized) ||
|
|
344
|
+
command.description.toLowerCase().includes(normalized) ||
|
|
345
|
+
command.keywords.some((keyword) => keyword.includes(normalized)),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const numberedIndex = (blocks: BlockEditorBlock[], index: number) => {
|
|
350
|
+
let count = 0
|
|
351
|
+
for (let i = 0; i <= index; i += 1) {
|
|
352
|
+
if (blocks[i]?.type === "numbered") count += 1
|
|
353
|
+
else if (i < index) count = 0
|
|
354
|
+
}
|
|
355
|
+
return count
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const blockTypeLabel = (type: BlockEditorBlockType) => {
|
|
359
|
+
switch (type) {
|
|
360
|
+
case "heading1":
|
|
361
|
+
return "Heading 1"
|
|
362
|
+
case "heading2":
|
|
363
|
+
return "Heading 2"
|
|
364
|
+
case "heading3":
|
|
365
|
+
return "Heading 3"
|
|
366
|
+
case "numbered":
|
|
367
|
+
return "Numbered list"
|
|
368
|
+
case "bulleted":
|
|
369
|
+
return "Ordered list"
|
|
370
|
+
case "checklist":
|
|
371
|
+
return "Checklist"
|
|
372
|
+
case "divider":
|
|
373
|
+
return "Divider"
|
|
374
|
+
default:
|
|
375
|
+
return "Text"
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const blockTypeTriggerClassName = (type: BlockEditorBlockType) => {
|
|
380
|
+
switch (type) {
|
|
381
|
+
case "heading1":
|
|
382
|
+
return "text-[15px] leading-5 font-semibold"
|
|
383
|
+
case "heading2":
|
|
384
|
+
return "font-semibold"
|
|
385
|
+
case "heading3":
|
|
386
|
+
return "text-[13px] leading-4 font-semibold"
|
|
387
|
+
default:
|
|
388
|
+
return undefined
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const Shortcut = ({
|
|
393
|
+
keys,
|
|
394
|
+
inverted,
|
|
395
|
+
className = "ml-auto",
|
|
396
|
+
}: {
|
|
397
|
+
keys: string[]
|
|
398
|
+
inverted?: boolean
|
|
399
|
+
className?: string
|
|
400
|
+
}) => (
|
|
401
|
+
<KbdGroup className={className}>
|
|
402
|
+
{keys.map((key) => (
|
|
403
|
+
<Kbd key={key} size="sm" variant={inverted ? "inverted" : "default"}>
|
|
404
|
+
{key}
|
|
405
|
+
</Kbd>
|
|
406
|
+
))}
|
|
407
|
+
</KbdGroup>
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
const StyleItem = ({
|
|
411
|
+
label,
|
|
412
|
+
keys,
|
|
413
|
+
labelClassName,
|
|
414
|
+
onClick,
|
|
415
|
+
}: {
|
|
416
|
+
label: ReactNode
|
|
417
|
+
keys: string[]
|
|
418
|
+
labelClassName?: string
|
|
419
|
+
onClick?: () => void
|
|
420
|
+
}) => (
|
|
421
|
+
<MenuItem className="gap-3" onClick={onClick}>
|
|
422
|
+
<span className={labelClassName}>{label}</span>
|
|
423
|
+
<Shortcut keys={keys} />
|
|
424
|
+
</MenuItem>
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
const SLASH_MENU_ITEMS: {
|
|
428
|
+
type: BlockEditorBlockType
|
|
429
|
+
label: string
|
|
430
|
+
keys: string[]
|
|
431
|
+
labelClassName?: string
|
|
432
|
+
}[] = [
|
|
433
|
+
{
|
|
434
|
+
type: "heading1",
|
|
435
|
+
label: "Heading 1",
|
|
436
|
+
keys: ["⌥", "⌘", "1"],
|
|
437
|
+
labelClassName: "text-[15px] leading-5 font-semibold",
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
type: "heading2",
|
|
441
|
+
label: "Heading 2",
|
|
442
|
+
keys: ["⌥", "⌘", "2"],
|
|
443
|
+
labelClassName: "text-sm font-semibold",
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
type: "heading3",
|
|
447
|
+
label: "Heading 3",
|
|
448
|
+
keys: ["⌥", "⌘", "3"],
|
|
449
|
+
labelClassName: "text-[13px] leading-4 font-semibold",
|
|
450
|
+
},
|
|
451
|
+
{ type: "numbered", label: "Numbered list", keys: ["⌥", "⌘", "4"] },
|
|
452
|
+
{ type: "bulleted", label: "Ordered list", keys: ["⌥", "⌘", "5"] },
|
|
453
|
+
{ type: "checklist", label: "Checklist", keys: ["⌥", "⌘", "6"] },
|
|
454
|
+
{ type: "divider", label: "Divider", keys: ["⌥", "⌘", "7"] },
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
const BLOCK_TYPE_SHORTCUTS: Record<string, BlockEditorBlockType> = {
|
|
458
|
+
Digit0: "text",
|
|
459
|
+
Digit1: "heading1",
|
|
460
|
+
Digit2: "heading2",
|
|
461
|
+
Digit3: "heading3",
|
|
462
|
+
Digit4: "numbered",
|
|
463
|
+
Digit5: "bulleted",
|
|
464
|
+
Digit6: "checklist",
|
|
465
|
+
Digit7: "divider",
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const FormatTip = ({
|
|
469
|
+
label,
|
|
470
|
+
keys,
|
|
471
|
+
children,
|
|
472
|
+
}: {
|
|
473
|
+
label: string
|
|
474
|
+
keys: string[]
|
|
475
|
+
children: ReactElement
|
|
476
|
+
}) => (
|
|
477
|
+
<Tooltip>
|
|
478
|
+
<TooltipTrigger render={children} />
|
|
479
|
+
<TooltipPortal>
|
|
480
|
+
<TooltipPositioner>
|
|
481
|
+
<TooltipPopup variant="inverted">
|
|
482
|
+
{label}
|
|
483
|
+
<Shortcut keys={keys} inverted className="ml-1.5" />
|
|
484
|
+
</TooltipPopup>
|
|
485
|
+
</TooltipPositioner>
|
|
486
|
+
</TooltipPortal>
|
|
487
|
+
</Tooltip>
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
const AutoGrowField = ({
|
|
491
|
+
value,
|
|
492
|
+
onValueChange,
|
|
493
|
+
onKeyDown,
|
|
494
|
+
onFocus,
|
|
495
|
+
onBlur,
|
|
496
|
+
onSelect,
|
|
497
|
+
className,
|
|
498
|
+
placeholder,
|
|
499
|
+
slashPlaceholder = false,
|
|
500
|
+
id,
|
|
501
|
+
}: {
|
|
502
|
+
id: string
|
|
503
|
+
value: string
|
|
504
|
+
onValueChange: (value: string) => void
|
|
505
|
+
onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void
|
|
506
|
+
onFocus: () => void
|
|
507
|
+
onBlur?: () => void
|
|
508
|
+
onSelect?: (field: HTMLDivElement) => void
|
|
509
|
+
className?: string
|
|
510
|
+
placeholder?: string
|
|
511
|
+
slashPlaceholder?: boolean
|
|
512
|
+
}) => {
|
|
513
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
514
|
+
const lastHtml = useRef<string | null>(null)
|
|
515
|
+
const empty = isBlankHtml(value)
|
|
516
|
+
const showSlashHint = slashPlaceholder && empty
|
|
517
|
+
const showPlaceholder = !slashPlaceholder && empty
|
|
518
|
+
|
|
519
|
+
useLayoutEffect(() => {
|
|
520
|
+
const node = ref.current
|
|
521
|
+
if (!node) return
|
|
522
|
+
if (lastHtml.current === null || value !== lastHtml.current) {
|
|
523
|
+
const wasExternal =
|
|
524
|
+
lastHtml.current !== null && value !== lastHtml.current
|
|
525
|
+
node.innerHTML = value
|
|
526
|
+
lastHtml.current = value
|
|
527
|
+
if (wasExternal && document.activeElement === node) {
|
|
528
|
+
placeCaretAtEnd(node)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
node.style.height = "0px"
|
|
532
|
+
const lineHeight = Number.parseFloat(getComputedStyle(node).lineHeight)
|
|
533
|
+
node.style.height = `${Math.max(
|
|
534
|
+
node.scrollHeight,
|
|
535
|
+
Number.isFinite(lineHeight) ? lineHeight : 28,
|
|
536
|
+
)}px`
|
|
537
|
+
}, [value, className])
|
|
538
|
+
|
|
539
|
+
const emitValue = () => {
|
|
540
|
+
const node = ref.current
|
|
541
|
+
if (!node) return
|
|
542
|
+
const html = isBlankHtml(node.innerHTML) ? "" : node.innerHTML
|
|
543
|
+
lastHtml.current = html
|
|
544
|
+
onValueChange(html)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return (
|
|
548
|
+
<div className="relative min-w-0 grow">
|
|
549
|
+
{showSlashHint ? (
|
|
550
|
+
<div
|
|
551
|
+
aria-hidden
|
|
552
|
+
className="pointer-events-none absolute inset-0 flex items-center gap-1 text-base leading-7 text-fg-quaternary"
|
|
553
|
+
>
|
|
554
|
+
<span>Write or type</span>
|
|
555
|
+
<Kbd size="sm">/</Kbd>
|
|
556
|
+
<span>for commands...</span>
|
|
557
|
+
</div>
|
|
558
|
+
) : null}
|
|
559
|
+
{showPlaceholder ? (
|
|
560
|
+
<div
|
|
561
|
+
aria-hidden
|
|
562
|
+
className={cn(
|
|
563
|
+
"pointer-events-none absolute inset-0 flex items-center",
|
|
564
|
+
className,
|
|
565
|
+
)}
|
|
566
|
+
style={{
|
|
567
|
+
color:
|
|
568
|
+
"color-mix(in oklab, var(--text-quaternary) 55%, transparent)",
|
|
569
|
+
}}
|
|
570
|
+
>
|
|
571
|
+
{placeholder}
|
|
572
|
+
</div>
|
|
573
|
+
) : null}
|
|
574
|
+
<div
|
|
575
|
+
ref={ref}
|
|
576
|
+
id={id}
|
|
577
|
+
role="textbox"
|
|
578
|
+
aria-multiline="true"
|
|
579
|
+
aria-label={placeholder}
|
|
580
|
+
contentEditable
|
|
581
|
+
suppressContentEditableWarning
|
|
582
|
+
className={cn(
|
|
583
|
+
"min-h-7 w-full grow cursor-text bg-transparent outline-none break-words whitespace-pre-wrap",
|
|
584
|
+
"[&_a]:underline [&_a]:underline-offset-2",
|
|
585
|
+
className,
|
|
586
|
+
)}
|
|
587
|
+
onInput={emitValue}
|
|
588
|
+
onKeyDown={onKeyDown}
|
|
589
|
+
onFocus={onFocus}
|
|
590
|
+
onBlur={onBlur}
|
|
591
|
+
onKeyUp={(event) => onSelect?.(event.currentTarget)}
|
|
592
|
+
onMouseUp={(event) => onSelect?.(event.currentTarget)}
|
|
593
|
+
/>
|
|
594
|
+
</div>
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export type BlockEditorProps = Omit<
|
|
599
|
+
HTMLAttributes<HTMLDivElement>,
|
|
600
|
+
"children" | "onChange"
|
|
601
|
+
> & {
|
|
602
|
+
blocks?: BlockEditorBlock[]
|
|
603
|
+
defaultBlocks?: BlockEditorBlock[]
|
|
604
|
+
onBlocksChange?: (blocks: BlockEditorBlock[]) => void
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export const BlockEditor = ({
|
|
608
|
+
blocks: controlledBlocks,
|
|
609
|
+
defaultBlocks = defaultBlockEditorBlocks,
|
|
610
|
+
onBlocksChange,
|
|
611
|
+
className,
|
|
612
|
+
...props
|
|
613
|
+
}: BlockEditorProps) => {
|
|
614
|
+
const [uncontrolledBlocks, setUncontrolledBlocks] = useState(defaultBlocks)
|
|
615
|
+
const blocks = controlledBlocks ?? uncontrolledBlocks
|
|
616
|
+
const [, setActiveId] = useState<string | null>(
|
|
617
|
+
defaultBlocks[0]?.id ?? null,
|
|
618
|
+
)
|
|
619
|
+
const [slash, setSlash] = useState<{
|
|
620
|
+
blockId: string
|
|
621
|
+
query: string
|
|
622
|
+
index: number
|
|
623
|
+
top: number
|
|
624
|
+
left: number
|
|
625
|
+
} | null>(null)
|
|
626
|
+
const [formatting, setFormatting] = useState<{
|
|
627
|
+
blockId: string
|
|
628
|
+
top: number
|
|
629
|
+
left: number
|
|
630
|
+
} | null>(null)
|
|
631
|
+
const [dragId, setDragId] = useState<string | null>(null)
|
|
632
|
+
const [dropInsertIndex, setDropInsertIndex] = useState<number | null>(null)
|
|
633
|
+
const dragIdRef = useRef<string | null>(null)
|
|
634
|
+
const dropInsertIndexRef = useRef<number | null>(null)
|
|
635
|
+
/** Pointer press that has not yet cleared the movement threshold. */
|
|
636
|
+
const pendingDragRef = useRef<{
|
|
637
|
+
blockId: string
|
|
638
|
+
index: number
|
|
639
|
+
pointerId: number
|
|
640
|
+
startY: number
|
|
641
|
+
handle: HTMLElement
|
|
642
|
+
} | null>(null)
|
|
643
|
+
const editorRef = useRef<HTMLDivElement>(null)
|
|
644
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
645
|
+
|
|
646
|
+
const setBlocks = useCallback(
|
|
647
|
+
(
|
|
648
|
+
next:
|
|
649
|
+
| BlockEditorBlock[]
|
|
650
|
+
| ((current: BlockEditorBlock[]) => BlockEditorBlock[]),
|
|
651
|
+
) => {
|
|
652
|
+
if (controlledBlocks !== undefined) {
|
|
653
|
+
const resolved =
|
|
654
|
+
typeof next === "function" ? next(controlledBlocks) : next
|
|
655
|
+
onBlocksChange?.(resolved)
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
setUncontrolledBlocks((current) => {
|
|
659
|
+
const resolved = typeof next === "function" ? next(current) : next
|
|
660
|
+
onBlocksChange?.(resolved)
|
|
661
|
+
return resolved
|
|
662
|
+
})
|
|
663
|
+
},
|
|
664
|
+
[controlledBlocks, onBlocksChange],
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
const measureFieldPosition = useCallback(
|
|
668
|
+
(blockId: string, edge: "top" | "bottom") => {
|
|
669
|
+
const editor = editorRef.current
|
|
670
|
+
const field = editor?.querySelector<HTMLElement>(
|
|
671
|
+
`#block-${CSS.escape(blockId)}`,
|
|
672
|
+
)
|
|
673
|
+
if (!editor || !field) return { top: 40, left: 0 }
|
|
674
|
+
const editorRect = editor.getBoundingClientRect()
|
|
675
|
+
const fieldRect = field.getBoundingClientRect()
|
|
676
|
+
return {
|
|
677
|
+
top:
|
|
678
|
+
(edge === "top" ? fieldRect.top : fieldRect.bottom) -
|
|
679
|
+
editorRect.top +
|
|
680
|
+
editor.scrollTop,
|
|
681
|
+
left: Math.max(0, fieldRect.left - editorRect.left),
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
[],
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
const openSlashMenu = useCallback(
|
|
688
|
+
(blockId: string, query: string) => {
|
|
689
|
+
const position = measureFieldPosition(blockId, "bottom")
|
|
690
|
+
setFormatting(null)
|
|
691
|
+
setSlash((current) => ({
|
|
692
|
+
blockId,
|
|
693
|
+
query,
|
|
694
|
+
index:
|
|
695
|
+
current?.blockId === blockId
|
|
696
|
+
? Math.min(
|
|
697
|
+
current.index,
|
|
698
|
+
Math.max(0, filterCommands(query).length - 1),
|
|
699
|
+
)
|
|
700
|
+
: 0,
|
|
701
|
+
...position,
|
|
702
|
+
}))
|
|
703
|
+
requestAnimationFrame(() => {
|
|
704
|
+
const node = editorRef.current?.querySelector<HTMLElement>(
|
|
705
|
+
`#block-${CSS.escape(blockId)}`,
|
|
706
|
+
)
|
|
707
|
+
if (!node) return
|
|
708
|
+
node.focus()
|
|
709
|
+
placeCaretAtEnd(node)
|
|
710
|
+
})
|
|
711
|
+
},
|
|
712
|
+
[measureFieldPosition],
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
const syncFormattingToolbar = useCallback(
|
|
716
|
+
(blockId: string, field: HTMLElement) => {
|
|
717
|
+
if (slash?.blockId === blockId) {
|
|
718
|
+
setFormatting(null)
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
if (!selectionIsExpandedIn(field)) {
|
|
722
|
+
setFormatting(null)
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
setFormatting({
|
|
726
|
+
blockId,
|
|
727
|
+
...measureFieldPosition(blockId, "top"),
|
|
728
|
+
})
|
|
729
|
+
},
|
|
730
|
+
[measureFieldPosition, slash?.blockId],
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
const focusBlock = useCallback((id: string) => {
|
|
734
|
+
requestAnimationFrame(() => {
|
|
735
|
+
const node = editorRef.current?.querySelector<HTMLElement>(
|
|
736
|
+
`#block-${CSS.escape(id)}`,
|
|
737
|
+
)
|
|
738
|
+
if (!node) return
|
|
739
|
+
node.focus()
|
|
740
|
+
placeCaretAtEnd(node)
|
|
741
|
+
})
|
|
742
|
+
}, [])
|
|
743
|
+
|
|
744
|
+
const updateBlock = useCallback(
|
|
745
|
+
(id: string, patch: Partial<BlockEditorBlock>) => {
|
|
746
|
+
setBlocks((current) =>
|
|
747
|
+
current.map((block) =>
|
|
748
|
+
block.id === id ? { ...block, ...patch } : block,
|
|
749
|
+
),
|
|
750
|
+
)
|
|
751
|
+
},
|
|
752
|
+
[setBlocks],
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
const insertBlockAfter = useCallback(
|
|
756
|
+
(id: string, type: BlockEditorBlockType = "text") => {
|
|
757
|
+
const next = createBlockEditorBlock(type)
|
|
758
|
+
setBlocks((current) => {
|
|
759
|
+
const index = current.findIndex((block) => block.id === id)
|
|
760
|
+
if (index === -1) return [...current, next]
|
|
761
|
+
const copy = [...current]
|
|
762
|
+
copy.splice(index + 1, 0, next)
|
|
763
|
+
return copy
|
|
764
|
+
})
|
|
765
|
+
setActiveId(next.id)
|
|
766
|
+
focusBlock(next.id)
|
|
767
|
+
return next.id
|
|
768
|
+
},
|
|
769
|
+
[focusBlock, setBlocks],
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
const removeBlock = useCallback(
|
|
773
|
+
(id: string) => {
|
|
774
|
+
setBlocks((current) => {
|
|
775
|
+
if (current.length <= 1) {
|
|
776
|
+
const alone = createBlockEditorBlock("text")
|
|
777
|
+
setActiveId(alone.id)
|
|
778
|
+
focusBlock(alone.id)
|
|
779
|
+
return [alone]
|
|
780
|
+
}
|
|
781
|
+
const index = current.findIndex((block) => block.id === id)
|
|
782
|
+
if (index === -1) return current
|
|
783
|
+
const copy = current.filter((block) => block.id !== id)
|
|
784
|
+
const focusId = copy[Math.max(0, index - 1)]?.id ?? copy[0]?.id
|
|
785
|
+
if (focusId) {
|
|
786
|
+
setActiveId(focusId)
|
|
787
|
+
focusBlock(focusId)
|
|
788
|
+
}
|
|
789
|
+
return copy
|
|
790
|
+
})
|
|
791
|
+
},
|
|
792
|
+
[focusBlock, setBlocks],
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
const moveBlockToIndex = useCallback(
|
|
796
|
+
(fromId: string, insertIndex: number) => {
|
|
797
|
+
setBlocks((current) => {
|
|
798
|
+
const fromIndex = current.findIndex((block) => block.id === fromId)
|
|
799
|
+
if (fromIndex === -1) return current
|
|
800
|
+
|
|
801
|
+
let target = insertIndex
|
|
802
|
+
if (fromIndex < target) target -= 1
|
|
803
|
+
if (fromIndex === target) return current
|
|
804
|
+
|
|
805
|
+
const next = [...current]
|
|
806
|
+
const [moved] = next.splice(fromIndex, 1)
|
|
807
|
+
if (!moved) return current
|
|
808
|
+
next.splice(target, 0, moved)
|
|
809
|
+
return next
|
|
810
|
+
})
|
|
811
|
+
setActiveId(fromId)
|
|
812
|
+
},
|
|
813
|
+
[setBlocks],
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
const resolveInsertIndexFromY = useCallback((clientY: number) => {
|
|
817
|
+
const list = listRef.current
|
|
818
|
+
const fromId = dragIdRef.current
|
|
819
|
+
if (!list || !fromId) return null
|
|
820
|
+
|
|
821
|
+
const els = [
|
|
822
|
+
...list.querySelectorAll<HTMLElement>("[data-slot=block-editor-block]"),
|
|
823
|
+
]
|
|
824
|
+
const fromIndex = els.findIndex((el) => el.dataset.blockId === fromId)
|
|
825
|
+
if (fromIndex === -1) return null
|
|
826
|
+
|
|
827
|
+
let insertIndex = els.length
|
|
828
|
+
for (let i = 0; i < els.length; i += 1) {
|
|
829
|
+
const rect = els[i]!.getBoundingClientRect()
|
|
830
|
+
if (clientY < rect.top + rect.height / 2) {
|
|
831
|
+
insertIndex = i
|
|
832
|
+
break
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Already in this slot (before or after self).
|
|
837
|
+
if (insertIndex === fromIndex || insertIndex === fromIndex + 1) {
|
|
838
|
+
return fromIndex
|
|
839
|
+
}
|
|
840
|
+
return insertIndex
|
|
841
|
+
}, [])
|
|
842
|
+
|
|
843
|
+
const setDropIndex = useCallback((insertIndex: number) => {
|
|
844
|
+
dropInsertIndexRef.current = insertIndex
|
|
845
|
+
setDropInsertIndex((current) =>
|
|
846
|
+
current === insertIndex ? current : insertIndex,
|
|
847
|
+
)
|
|
848
|
+
}, [])
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Reordering runs on pointer events, not HTML5 drag-and-drop.
|
|
852
|
+
*
|
|
853
|
+
* Blocks sit next to `contenteditable` fields, and Chromium will happily
|
|
854
|
+
* start its own text drag from those instead of firing `dragstart` on the
|
|
855
|
+
* handle. Pointer capture sidesteps that entirely: the handle owns the
|
|
856
|
+
* pointer for the whole gesture, so moves keep arriving even when the
|
|
857
|
+
* cursor leaves the list, and touch works for free.
|
|
858
|
+
*/
|
|
859
|
+
const endBlockDrag = useCallback(
|
|
860
|
+
(commit: boolean) => {
|
|
861
|
+
const fromId = dragIdRef.current
|
|
862
|
+
const insertIndex = dropInsertIndexRef.current
|
|
863
|
+
|
|
864
|
+
pendingDragRef.current = null
|
|
865
|
+
dragIdRef.current = null
|
|
866
|
+
dropInsertIndexRef.current = null
|
|
867
|
+
setDragId(null)
|
|
868
|
+
setDropInsertIndex(null)
|
|
869
|
+
document.body.style.removeProperty("cursor")
|
|
870
|
+
|
|
871
|
+
if (commit && fromId && insertIndex != null) {
|
|
872
|
+
moveBlockToIndex(fromId, insertIndex)
|
|
873
|
+
}
|
|
874
|
+
},
|
|
875
|
+
[moveBlockToIndex],
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
const handleHandlePointerDown = useCallback(
|
|
879
|
+
(event: ReactPointerEvent<HTMLElement>, blockId: string, index: number) => {
|
|
880
|
+
if (event.button !== 0) return
|
|
881
|
+
// Stop the press from moving the caret or selecting text mid-drag.
|
|
882
|
+
event.preventDefault()
|
|
883
|
+
const handle = event.currentTarget
|
|
884
|
+
pendingDragRef.current = {
|
|
885
|
+
blockId,
|
|
886
|
+
index,
|
|
887
|
+
pointerId: event.pointerId,
|
|
888
|
+
startY: event.clientY,
|
|
889
|
+
handle,
|
|
890
|
+
}
|
|
891
|
+
// Capture can throw if the pointer is already gone; the drag still works
|
|
892
|
+
// off the handle's own move events, so treat it as best-effort.
|
|
893
|
+
try {
|
|
894
|
+
handle.setPointerCapture(event.pointerId)
|
|
895
|
+
} catch {
|
|
896
|
+
/* no-op */
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
[],
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
const handleHandlePointerMove = useCallback(
|
|
903
|
+
(event: ReactPointerEvent<HTMLElement>) => {
|
|
904
|
+
const pending = pendingDragRef.current
|
|
905
|
+
|
|
906
|
+
// Arm the drag only once the pointer has actually travelled, so a plain
|
|
907
|
+
// click on the handle stays a click.
|
|
908
|
+
if (
|
|
909
|
+
pending &&
|
|
910
|
+
pending.pointerId === event.pointerId &&
|
|
911
|
+
!dragIdRef.current
|
|
912
|
+
) {
|
|
913
|
+
if (Math.abs(event.clientY - pending.startY) < DRAG_THRESHOLD) return
|
|
914
|
+
dragIdRef.current = pending.blockId
|
|
915
|
+
dropInsertIndexRef.current = pending.index
|
|
916
|
+
setDragId(pending.blockId)
|
|
917
|
+
setDropInsertIndex(pending.index)
|
|
918
|
+
document.body.style.cursor = "grabbing"
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (!dragIdRef.current) return
|
|
922
|
+
const insertIndex = resolveInsertIndexFromY(event.clientY)
|
|
923
|
+
if (insertIndex != null) setDropIndex(insertIndex)
|
|
924
|
+
},
|
|
925
|
+
[resolveInsertIndexFromY, setDropIndex],
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
const handleHandlePointerUp = useCallback(() => {
|
|
929
|
+
endBlockDrag(dragIdRef.current != null)
|
|
930
|
+
}, [endBlockDrag])
|
|
931
|
+
|
|
932
|
+
const moveBlockByStep = useCallback(
|
|
933
|
+
(index: number, direction: -1 | 1) => {
|
|
934
|
+
const block = blocks[index]
|
|
935
|
+
if (!block) return
|
|
936
|
+
const target = direction === -1 ? index - 1 : index + 2
|
|
937
|
+
if (target < 0 || target > blocks.length) return
|
|
938
|
+
moveBlockToIndex(block.id, target)
|
|
939
|
+
},
|
|
940
|
+
[blocks, moveBlockToIndex],
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
const applySlashCommand = useCallback(
|
|
944
|
+
(blockId: string, type: BlockEditorBlockType, query: string) => {
|
|
945
|
+
const block = blocks.find((item) => item.id === blockId)
|
|
946
|
+
if (!block) return
|
|
947
|
+
|
|
948
|
+
const field = editorRef.current?.querySelector<HTMLElement>(
|
|
949
|
+
`#block-${CSS.escape(blockId)}`,
|
|
950
|
+
)
|
|
951
|
+
const plain = field ? fieldPlainText(field) : block.content
|
|
952
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
953
|
+
const contentWithoutSlash = plain
|
|
954
|
+
.replace(new RegExp(`/${escaped}$`), "")
|
|
955
|
+
.trimEnd()
|
|
956
|
+
|
|
957
|
+
if (type === "divider") {
|
|
958
|
+
setBlocks((current) => {
|
|
959
|
+
const index = current.findIndex((item) => item.id === blockId)
|
|
960
|
+
if (index === -1) return current
|
|
961
|
+
const copy = [...current]
|
|
962
|
+
copy[index] = {
|
|
963
|
+
...copy[index]!,
|
|
964
|
+
type: "divider",
|
|
965
|
+
content: "",
|
|
966
|
+
checked: undefined,
|
|
967
|
+
}
|
|
968
|
+
const trailing = createBlockEditorBlock("text")
|
|
969
|
+
copy.splice(index + 1, 0, trailing)
|
|
970
|
+
setActiveId(trailing.id)
|
|
971
|
+
focusBlock(trailing.id)
|
|
972
|
+
return copy
|
|
973
|
+
})
|
|
974
|
+
} else {
|
|
975
|
+
if (field) {
|
|
976
|
+
field.innerHTML = contentWithoutSlash
|
|
977
|
+
}
|
|
978
|
+
updateBlock(blockId, {
|
|
979
|
+
type,
|
|
980
|
+
content: contentWithoutSlash,
|
|
981
|
+
checked: type === "checklist" ? false : undefined,
|
|
982
|
+
})
|
|
983
|
+
focusBlock(blockId)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
setSlash(null)
|
|
987
|
+
setFormatting(null)
|
|
988
|
+
},
|
|
989
|
+
[blocks, focusBlock, setBlocks, updateBlock],
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
const applyBlockType = useCallback(
|
|
993
|
+
(blockId: string, type: BlockEditorBlockType) => {
|
|
994
|
+
if (type === "divider") {
|
|
995
|
+
applySlashCommand(blockId, type, "")
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
updateBlock(blockId, {
|
|
999
|
+
type,
|
|
1000
|
+
checked: type === "checklist" ? false : undefined,
|
|
1001
|
+
})
|
|
1002
|
+
setFormatting(null)
|
|
1003
|
+
focusBlock(blockId)
|
|
1004
|
+
},
|
|
1005
|
+
[applySlashCommand, focusBlock, updateBlock],
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
const applyInlineFormat = useCallback(
|
|
1009
|
+
(
|
|
1010
|
+
blockId: string,
|
|
1011
|
+
format:
|
|
1012
|
+
| "bold"
|
|
1013
|
+
| "italic"
|
|
1014
|
+
| "underline"
|
|
1015
|
+
| "strikethrough"
|
|
1016
|
+
| "highlight"
|
|
1017
|
+
| "link",
|
|
1018
|
+
) => {
|
|
1019
|
+
const field = editorRef.current?.querySelector<HTMLElement>(
|
|
1020
|
+
`#block-${CSS.escape(blockId)}`,
|
|
1021
|
+
)
|
|
1022
|
+
if (!field || !selectionIsExpandedIn(field)) return
|
|
1023
|
+
|
|
1024
|
+
field.focus()
|
|
1025
|
+
|
|
1026
|
+
if (format === "highlight") {
|
|
1027
|
+
highlightSelection()
|
|
1028
|
+
} else if (format === "link") {
|
|
1029
|
+
const url = window.prompt("Enter URL", "https://")
|
|
1030
|
+
if (url == null || url.trim() === "") return
|
|
1031
|
+
document.execCommand("createLink", false, url.trim())
|
|
1032
|
+
} else {
|
|
1033
|
+
document.execCommand("styleWithCSS", false, "true")
|
|
1034
|
+
const command = {
|
|
1035
|
+
bold: "bold",
|
|
1036
|
+
italic: "italic",
|
|
1037
|
+
underline: "underline",
|
|
1038
|
+
strikethrough: "strikeThrough",
|
|
1039
|
+
}[format]
|
|
1040
|
+
document.execCommand(command)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const html = isBlankHtml(field.innerHTML) ? "" : field.innerHTML
|
|
1044
|
+
updateBlock(blockId, { content: html })
|
|
1045
|
+
requestAnimationFrame(() => {
|
|
1046
|
+
syncFormattingToolbar(blockId, field)
|
|
1047
|
+
})
|
|
1048
|
+
},
|
|
1049
|
+
[syncFormattingToolbar, updateBlock],
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
const openCommands = slash ? filterCommands(slash.query) : []
|
|
1053
|
+
|
|
1054
|
+
useEffect(() => {
|
|
1055
|
+
if (!slash) return
|
|
1056
|
+
const handlePointer = (event: MouseEvent) => {
|
|
1057
|
+
if (!(event.target instanceof Node)) return
|
|
1058
|
+
if (editorRef.current?.contains(event.target)) return
|
|
1059
|
+
if (
|
|
1060
|
+
event.target instanceof Element &&
|
|
1061
|
+
event.target.closest("[data-slot=menu-popup], [role=menu]")
|
|
1062
|
+
) {
|
|
1063
|
+
return
|
|
1064
|
+
}
|
|
1065
|
+
setSlash(null)
|
|
1066
|
+
}
|
|
1067
|
+
document.addEventListener("mousedown", handlePointer)
|
|
1068
|
+
return () => document.removeEventListener("mousedown", handlePointer)
|
|
1069
|
+
}, [slash])
|
|
1070
|
+
|
|
1071
|
+
const handleBlockInput = (block: BlockEditorBlock, html: string) => {
|
|
1072
|
+
updateBlock(block.id, { content: html })
|
|
1073
|
+
|
|
1074
|
+
const field = editorRef.current?.querySelector<HTMLElement>(
|
|
1075
|
+
`#block-${CSS.escape(block.id)}`,
|
|
1076
|
+
)
|
|
1077
|
+
const plain = field ? fieldPlainText(field) : html.replace(/<[^>]+>/g, "")
|
|
1078
|
+
const slashMatch = plain.match(/(?:^|[\s\u00A0])\/([^\n]*)$/)
|
|
1079
|
+
if (slashMatch) {
|
|
1080
|
+
openSlashMenu(block.id, slashMatch[1] ?? "")
|
|
1081
|
+
return
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (slash?.blockId === block.id) setSlash(null)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const handleBlockKeyDown = (
|
|
1088
|
+
event: ReactKeyboardEvent<HTMLDivElement>,
|
|
1089
|
+
block: BlockEditorBlock,
|
|
1090
|
+
index: number,
|
|
1091
|
+
) => {
|
|
1092
|
+
const field = event.currentTarget
|
|
1093
|
+
|
|
1094
|
+
if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
1095
|
+
const before = textBeforeCaret(field)
|
|
1096
|
+
const atSlashStart = before === "" || /[\s\u00A0]$/.test(before)
|
|
1097
|
+
if (atSlashStart) {
|
|
1098
|
+
requestAnimationFrame(() => {
|
|
1099
|
+
const plain = fieldPlainText(field)
|
|
1100
|
+
const match = plain.match(/(?:^|[\s\u00A0])\/([^\n]*)$/)
|
|
1101
|
+
if (match) openSlashMenu(block.id, match[1] ?? "")
|
|
1102
|
+
})
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (slash?.blockId === block.id && openCommands.length > 0) {
|
|
1107
|
+
if (event.key === "ArrowDown") {
|
|
1108
|
+
event.preventDefault()
|
|
1109
|
+
setSlash((current) =>
|
|
1110
|
+
current
|
|
1111
|
+
? {
|
|
1112
|
+
...current,
|
|
1113
|
+
index: (current.index + 1) % openCommands.length,
|
|
1114
|
+
}
|
|
1115
|
+
: current,
|
|
1116
|
+
)
|
|
1117
|
+
return
|
|
1118
|
+
}
|
|
1119
|
+
if (event.key === "ArrowUp") {
|
|
1120
|
+
event.preventDefault()
|
|
1121
|
+
setSlash((current) =>
|
|
1122
|
+
current
|
|
1123
|
+
? {
|
|
1124
|
+
...current,
|
|
1125
|
+
index:
|
|
1126
|
+
(current.index - 1 + openCommands.length) %
|
|
1127
|
+
openCommands.length,
|
|
1128
|
+
}
|
|
1129
|
+
: current,
|
|
1130
|
+
)
|
|
1131
|
+
return
|
|
1132
|
+
}
|
|
1133
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
1134
|
+
event.preventDefault()
|
|
1135
|
+
const command = openCommands[slash.index] ?? openCommands[0]
|
|
1136
|
+
if (command) applySlashCommand(block.id, command.type, slash.query)
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
if (event.key === "Escape") {
|
|
1140
|
+
event.preventDefault()
|
|
1141
|
+
setSlash(null)
|
|
1142
|
+
return
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const mod = event.metaKey || event.ctrlKey
|
|
1147
|
+
if (mod && event.altKey && !event.shiftKey) {
|
|
1148
|
+
const type = BLOCK_TYPE_SHORTCUTS[event.code]
|
|
1149
|
+
if (type) {
|
|
1150
|
+
event.preventDefault()
|
|
1151
|
+
setSlash(null)
|
|
1152
|
+
applyBlockType(block.id, type)
|
|
1153
|
+
return
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (mod && !event.altKey) {
|
|
1158
|
+
const key = event.key.toLowerCase()
|
|
1159
|
+
if (key === "b") {
|
|
1160
|
+
event.preventDefault()
|
|
1161
|
+
applyInlineFormat(block.id, "bold")
|
|
1162
|
+
return
|
|
1163
|
+
}
|
|
1164
|
+
if (key === "i") {
|
|
1165
|
+
event.preventDefault()
|
|
1166
|
+
applyInlineFormat(block.id, "italic")
|
|
1167
|
+
return
|
|
1168
|
+
}
|
|
1169
|
+
if (key === "u") {
|
|
1170
|
+
event.preventDefault()
|
|
1171
|
+
applyInlineFormat(block.id, "underline")
|
|
1172
|
+
return
|
|
1173
|
+
}
|
|
1174
|
+
if (key === "k") {
|
|
1175
|
+
event.preventDefault()
|
|
1176
|
+
applyInlineFormat(block.id, "link")
|
|
1177
|
+
return
|
|
1178
|
+
}
|
|
1179
|
+
if (event.shiftKey && key === "x") {
|
|
1180
|
+
event.preventDefault()
|
|
1181
|
+
applyInlineFormat(block.id, "strikethrough")
|
|
1182
|
+
return
|
|
1183
|
+
}
|
|
1184
|
+
if (event.shiftKey && key === "h") {
|
|
1185
|
+
event.preventDefault()
|
|
1186
|
+
applyInlineFormat(block.id, "highlight")
|
|
1187
|
+
return
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
1192
|
+
event.preventDefault()
|
|
1193
|
+
if (slash) setSlash(null)
|
|
1194
|
+
const isList =
|
|
1195
|
+
block.type === "checklist" ||
|
|
1196
|
+
block.type === "numbered" ||
|
|
1197
|
+
block.type === "bulleted"
|
|
1198
|
+
if (isList && isBlankHtml(field.innerHTML)) {
|
|
1199
|
+
updateBlock(block.id, { type: "text", checked: undefined })
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
insertBlockAfter(block.id, isList ? block.type : "text")
|
|
1203
|
+
return
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (event.key === "Tab" && !event.metaKey && !event.ctrlKey) {
|
|
1207
|
+
if (slash?.blockId === block.id) return
|
|
1208
|
+
event.preventDefault()
|
|
1209
|
+
const isList =
|
|
1210
|
+
block.type === "checklist" ||
|
|
1211
|
+
block.type === "numbered" ||
|
|
1212
|
+
block.type === "bulleted"
|
|
1213
|
+
if (!isList) return
|
|
1214
|
+
if (isBlankHtml(field.innerHTML)) {
|
|
1215
|
+
updateBlock(block.id, { type: "text", checked: undefined })
|
|
1216
|
+
return
|
|
1217
|
+
}
|
|
1218
|
+
insertBlockAfter(block.id, block.type)
|
|
1219
|
+
return
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (
|
|
1223
|
+
(event.key === "Backspace" || event.key === "Delete") &&
|
|
1224
|
+
isBlankHtml(field.innerHTML)
|
|
1225
|
+
) {
|
|
1226
|
+
if (block.type !== "text") {
|
|
1227
|
+
event.preventDefault()
|
|
1228
|
+
setSlash(null)
|
|
1229
|
+
setFormatting(null)
|
|
1230
|
+
updateBlock(block.id, { type: "text", checked: undefined })
|
|
1231
|
+
return
|
|
1232
|
+
}
|
|
1233
|
+
if (blocks.length > 1) {
|
|
1234
|
+
event.preventDefault()
|
|
1235
|
+
removeBlock(block.id)
|
|
1236
|
+
return
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (event.key === "ArrowUp" && index > 0 && !slash && isCaretAtStart(field)) {
|
|
1241
|
+
const previous = blocks[index - 1]
|
|
1242
|
+
if (previous && previous.type !== "divider") {
|
|
1243
|
+
event.preventDefault()
|
|
1244
|
+
setActiveId(previous.id)
|
|
1245
|
+
focusBlock(previous.id)
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (
|
|
1250
|
+
event.key === "ArrowDown" &&
|
|
1251
|
+
index < blocks.length - 1 &&
|
|
1252
|
+
!slash &&
|
|
1253
|
+
isCaretAtEnd(field)
|
|
1254
|
+
) {
|
|
1255
|
+
const next = blocks[index + 1]
|
|
1256
|
+
if (next && next.type !== "divider") {
|
|
1257
|
+
event.preventDefault()
|
|
1258
|
+
setActiveId(next.id)
|
|
1259
|
+
focusBlock(next.id)
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const handleEditorClick = (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
1265
|
+
const target = event.target
|
|
1266
|
+
if (!(target instanceof Element)) return
|
|
1267
|
+
if (
|
|
1268
|
+
target.closest("[data-slot=block-editor-block]") ||
|
|
1269
|
+
target.closest("[data-slot=block-editor-handle]") ||
|
|
1270
|
+
target.closest("[data-slot=menu-popup]") ||
|
|
1271
|
+
target.closest('[role="toolbar"]') ||
|
|
1272
|
+
target.closest("[role=listbox]") ||
|
|
1273
|
+
target.closest("[role=menu]") ||
|
|
1274
|
+
target.closest("[contenteditable=true]")
|
|
1275
|
+
) {
|
|
1276
|
+
return
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const trailingEmpty: BlockEditorBlock[] = []
|
|
1280
|
+
for (let index = blocks.length - 1; index >= 0; index -= 1) {
|
|
1281
|
+
const block = blocks[index]
|
|
1282
|
+
if (!block || block.type === "divider") break
|
|
1283
|
+
if (!isBlankHtml(block.content)) break
|
|
1284
|
+
trailingEmpty.push(block)
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (trailingEmpty.length > 0) {
|
|
1288
|
+
const keep = trailingEmpty[trailingEmpty.length - 1]!
|
|
1289
|
+
const removeIds = new Set(
|
|
1290
|
+
trailingEmpty.filter((block) => block.id !== keep.id).map((block) => block.id),
|
|
1291
|
+
)
|
|
1292
|
+
if (removeIds.size > 0 || keep.type !== "text") {
|
|
1293
|
+
setBlocks((current) =>
|
|
1294
|
+
current
|
|
1295
|
+
.filter((block) => !removeIds.has(block.id))
|
|
1296
|
+
.map((block) =>
|
|
1297
|
+
block.id === keep.id
|
|
1298
|
+
? { ...block, type: "text" as const, checked: undefined }
|
|
1299
|
+
: block,
|
|
1300
|
+
),
|
|
1301
|
+
)
|
|
1302
|
+
}
|
|
1303
|
+
setActiveId(keep.id)
|
|
1304
|
+
focusBlock(keep.id)
|
|
1305
|
+
return
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const last = blocks[blocks.length - 1]
|
|
1309
|
+
if (last) {
|
|
1310
|
+
insertBlockAfter(last.id, "text")
|
|
1311
|
+
return
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const next = createBlockEditorBlock("text")
|
|
1315
|
+
setBlocks([next])
|
|
1316
|
+
setActiveId(next.id)
|
|
1317
|
+
focusBlock(next.id)
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
return (
|
|
1321
|
+
<div
|
|
1322
|
+
ref={editorRef}
|
|
1323
|
+
data-slot="block-editor"
|
|
1324
|
+
className={cn(
|
|
1325
|
+
"relative overflow-visible cursor-text bg-surface",
|
|
1326
|
+
className,
|
|
1327
|
+
)}
|
|
1328
|
+
onClick={handleEditorClick}
|
|
1329
|
+
{...props}
|
|
1330
|
+
>
|
|
1331
|
+
<div
|
|
1332
|
+
ref={listRef}
|
|
1333
|
+
className={cn(
|
|
1334
|
+
"relative flex w-full max-w-[720px] flex-col gap-0.5 overflow-visible",
|
|
1335
|
+
// Pointer capture keeps the gesture on the handle; this just stops
|
|
1336
|
+
// the surrounding copy from highlighting as the cursor sweeps it.
|
|
1337
|
+
dragId && "select-none",
|
|
1338
|
+
)}
|
|
1339
|
+
>
|
|
1340
|
+
{(() => {
|
|
1341
|
+
const dragFromIndex = dragId
|
|
1342
|
+
? blocks.findIndex((item) => item.id === dragId)
|
|
1343
|
+
: -1
|
|
1344
|
+
const dropLineAt = (() => {
|
|
1345
|
+
if (dropInsertIndex == null || blocks.length === 0) return null
|
|
1346
|
+
if (dropInsertIndex >= blocks.length) {
|
|
1347
|
+
return {
|
|
1348
|
+
blockId: blocks[blocks.length - 1]!.id,
|
|
1349
|
+
edge: "after" as const,
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
if (
|
|
1353
|
+
dragFromIndex >= 0 &&
|
|
1354
|
+
dropInsertIndex === dragFromIndex &&
|
|
1355
|
+
dropInsertIndex > 0
|
|
1356
|
+
) {
|
|
1357
|
+
return {
|
|
1358
|
+
blockId: blocks[dropInsertIndex - 1]!.id,
|
|
1359
|
+
edge: "after" as const,
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
return {
|
|
1363
|
+
blockId: blocks[dropInsertIndex]!.id,
|
|
1364
|
+
edge: "before" as const,
|
|
1365
|
+
}
|
|
1366
|
+
})()
|
|
1367
|
+
|
|
1368
|
+
return blocks.map((block, index) => {
|
|
1369
|
+
const showBlockChrome =
|
|
1370
|
+
!isBlankHtml(block.content) && !isSlashOnlyHtml(block.content)
|
|
1371
|
+
const isListBlock =
|
|
1372
|
+
block.type === "checklist" ||
|
|
1373
|
+
block.type === "numbered" ||
|
|
1374
|
+
block.type === "bulleted"
|
|
1375
|
+
const isDragging = dragId === block.id
|
|
1376
|
+
|
|
1377
|
+
const dropLine =
|
|
1378
|
+
dropLineAt?.blockId === block.id ? (
|
|
1379
|
+
<div
|
|
1380
|
+
aria-hidden
|
|
1381
|
+
className={cn(
|
|
1382
|
+
"pointer-events-none absolute right-0 left-0 z-20 h-0.5 rounded-full bg-fg-primary",
|
|
1383
|
+
dropLineAt.edge === "before" ? "-top-px" : "-bottom-px",
|
|
1384
|
+
)}
|
|
1385
|
+
/>
|
|
1386
|
+
) : null
|
|
1387
|
+
|
|
1388
|
+
const dragHandle = (
|
|
1389
|
+
<button
|
|
1390
|
+
type="button"
|
|
1391
|
+
data-slot="block-editor-handle"
|
|
1392
|
+
aria-label={`Reorder ${blockTypeLabel(block.type)}`}
|
|
1393
|
+
title="Drag to reorder"
|
|
1394
|
+
className={cn(
|
|
1395
|
+
"absolute right-full z-10 mr-1 flex h-7 cursor-grab items-center justify-center rounded-sm px-1 text-fg-quaternary",
|
|
1396
|
+
// Widen the grab target without moving the dots.
|
|
1397
|
+
"before:absolute before:-inset-y-1 before:-inset-x-1 before:content-['']",
|
|
1398
|
+
block.type === "text"
|
|
1399
|
+
? "top-0.5"
|
|
1400
|
+
: "top-1/2 -translate-y-1/2",
|
|
1401
|
+
"opacity-0 transition-opacity duration-[var(--duration-sm)] ease-enter group-hover:opacity-100",
|
|
1402
|
+
"hover:bg-background-tertiary hover:text-fg-secondary",
|
|
1403
|
+
"touch-none active:cursor-grabbing",
|
|
1404
|
+
"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
|
|
1405
|
+
isDragging && "cursor-grabbing opacity-100",
|
|
1406
|
+
)}
|
|
1407
|
+
onClick={(event) => event.stopPropagation()}
|
|
1408
|
+
onPointerDown={(event) => {
|
|
1409
|
+
event.stopPropagation()
|
|
1410
|
+
handleHandlePointerDown(event, block.id, index)
|
|
1411
|
+
}}
|
|
1412
|
+
onPointerMove={handleHandlePointerMove}
|
|
1413
|
+
onPointerUp={handleHandlePointerUp}
|
|
1414
|
+
onPointerCancel={handleHandlePointerUp}
|
|
1415
|
+
onKeyDown={(event) => {
|
|
1416
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return
|
|
1417
|
+
event.preventDefault()
|
|
1418
|
+
moveBlockByStep(index, event.key === "ArrowUp" ? -1 : 1)
|
|
1419
|
+
}}
|
|
1420
|
+
>
|
|
1421
|
+
<IconDotGrid2x3 size={16} className="size-4" aria-hidden />
|
|
1422
|
+
</button>
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
if (block.type === "divider") {
|
|
1426
|
+
return (
|
|
1427
|
+
<div
|
|
1428
|
+
key={block.id}
|
|
1429
|
+
data-slot="block-editor-block"
|
|
1430
|
+
data-block-id={block.id}
|
|
1431
|
+
data-type="divider"
|
|
1432
|
+
className={cn(
|
|
1433
|
+
"group relative -mx-3 my-3 flex cursor-pointer items-center rounded-md px-3 py-2 outline-none",
|
|
1434
|
+
"[&:not(:focus-within):hover]:bg-background-quaternary/20",
|
|
1435
|
+
isDragging && "opacity-40",
|
|
1436
|
+
)}
|
|
1437
|
+
onClick={() => setActiveId(block.id)}
|
|
1438
|
+
onKeyDown={(event) => {
|
|
1439
|
+
if (event.key === "Enter") {
|
|
1440
|
+
event.preventDefault()
|
|
1441
|
+
insertBlockAfter(block.id, "text")
|
|
1442
|
+
}
|
|
1443
|
+
if (event.key === "Backspace" || event.key === "Delete") {
|
|
1444
|
+
event.preventDefault()
|
|
1445
|
+
removeBlock(block.id)
|
|
1446
|
+
}
|
|
1447
|
+
}}
|
|
1448
|
+
tabIndex={0}
|
|
1449
|
+
role="separator"
|
|
1450
|
+
aria-orientation="horizontal"
|
|
1451
|
+
>
|
|
1452
|
+
{dropLine}
|
|
1453
|
+
{dragHandle}
|
|
1454
|
+
<div className="h-px w-full bg-border-primary" />
|
|
1455
|
+
</div>
|
|
1456
|
+
)
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const prefix =
|
|
1460
|
+
block.type === "checklist" ? (
|
|
1461
|
+
<Checkbox
|
|
1462
|
+
checked={Boolean(block.checked)}
|
|
1463
|
+
onCheckedChange={(checked) =>
|
|
1464
|
+
updateBlock(block.id, { checked: Boolean(checked) })
|
|
1465
|
+
}
|
|
1466
|
+
aria-label="Toggle checklist item"
|
|
1467
|
+
/>
|
|
1468
|
+
) : block.type === "numbered" ? (
|
|
1469
|
+
<span className="tabular-nums text-fg-primary">
|
|
1470
|
+
{numberedIndex(blocks, index)}.
|
|
1471
|
+
</span>
|
|
1472
|
+
) : block.type === "bulleted" ? (
|
|
1473
|
+
<span
|
|
1474
|
+
className="text-[1.25rem] leading-none text-fg-primary"
|
|
1475
|
+
aria-hidden
|
|
1476
|
+
>
|
|
1477
|
+
•
|
|
1478
|
+
</span>
|
|
1479
|
+
) : null
|
|
1480
|
+
|
|
1481
|
+
const previous = index > 0 ? blocks[index - 1] : null
|
|
1482
|
+
const sameListAsPrevious =
|
|
1483
|
+
previous != null && isListBlock && previous.type === block.type
|
|
1484
|
+
|
|
1485
|
+
return (
|
|
1486
|
+
<div
|
|
1487
|
+
key={block.id}
|
|
1488
|
+
data-slot="block-editor-block"
|
|
1489
|
+
data-block-id={block.id}
|
|
1490
|
+
data-type={block.type}
|
|
1491
|
+
className={cn(
|
|
1492
|
+
"group relative -mx-3 items-center rounded-md px-3",
|
|
1493
|
+
isListBlock ? "py-0" : "py-0.5",
|
|
1494
|
+
showBlockChrome &&
|
|
1495
|
+
"[&:not(:focus-within):hover]:bg-background-quaternary/20",
|
|
1496
|
+
isDragging && "opacity-40",
|
|
1497
|
+
sameListAsPrevious && "-mt-0.5",
|
|
1498
|
+
previous &&
|
|
1499
|
+
previous.type !== block.type &&
|
|
1500
|
+
previous.type !== "divider" &&
|
|
1501
|
+
"mt-3",
|
|
1502
|
+
prefix
|
|
1503
|
+
? "grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-2"
|
|
1504
|
+
: "flex",
|
|
1505
|
+
)}
|
|
1506
|
+
>
|
|
1507
|
+
{dropLine}
|
|
1508
|
+
{dragHandle}
|
|
1509
|
+
{prefix ? (
|
|
1510
|
+
<div className="flex h-7 items-center justify-start">{prefix}</div>
|
|
1511
|
+
) : null}
|
|
1512
|
+
<AutoGrowField
|
|
1513
|
+
id={`block-${block.id}`}
|
|
1514
|
+
value={block.content}
|
|
1515
|
+
placeholder={placeholderFor(block.type)}
|
|
1516
|
+
slashPlaceholder={block.type === "text"}
|
|
1517
|
+
className={cn(
|
|
1518
|
+
blockTextClassName[block.type],
|
|
1519
|
+
block.checked && "text-fg-tertiary line-through",
|
|
1520
|
+
)}
|
|
1521
|
+
onFocus={() => setActiveId(block.id)}
|
|
1522
|
+
onBlur={() => {
|
|
1523
|
+
window.setTimeout(() => {
|
|
1524
|
+
const active = document.activeElement
|
|
1525
|
+
if (
|
|
1526
|
+
active instanceof Element &&
|
|
1527
|
+
(active.closest('[role="toolbar"]') ||
|
|
1528
|
+
active.closest("[role=menu]"))
|
|
1529
|
+
) {
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
setFormatting((current) =>
|
|
1533
|
+
current?.blockId === block.id ? null : current,
|
|
1534
|
+
)
|
|
1535
|
+
}, 0)
|
|
1536
|
+
}}
|
|
1537
|
+
onSelect={(field) => syncFormattingToolbar(block.id, field)}
|
|
1538
|
+
onValueChange={(value) => handleBlockInput(block, value)}
|
|
1539
|
+
onKeyDown={(event) => handleBlockKeyDown(event, block, index)}
|
|
1540
|
+
/>
|
|
1541
|
+
</div>
|
|
1542
|
+
)
|
|
1543
|
+
})
|
|
1544
|
+
})()}
|
|
1545
|
+
</div>
|
|
1546
|
+
|
|
1547
|
+
{/* Toolbar format handlers only run on click; react-hooks/refs is overly
|
|
1548
|
+
strict about onClick closures that close over editorRef. */}
|
|
1549
|
+
{/* eslint-disable react-hooks/refs */}
|
|
1550
|
+
{formatting && !slash
|
|
1551
|
+
? (() => {
|
|
1552
|
+
const formattingType =
|
|
1553
|
+
blocks.find((block) => block.id === formatting.blockId)?.type ??
|
|
1554
|
+
"text"
|
|
1555
|
+
return (
|
|
1556
|
+
<TooltipProvider delay={200}>
|
|
1557
|
+
<Toolbar
|
|
1558
|
+
aria-label="Formatting"
|
|
1559
|
+
style={{ top: formatting.top, left: formatting.left }}
|
|
1560
|
+
className={cn(
|
|
1561
|
+
"absolute z-20 -translate-y-[calc(100%+0.5rem)] border border-border-primary bg-surface",
|
|
1562
|
+
motion.popupCenter,
|
|
1563
|
+
)}
|
|
1564
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
1565
|
+
>
|
|
1566
|
+
<ToolbarGroup>
|
|
1567
|
+
<FormatTip label="Link" keys={["⌘", "K"]}>
|
|
1568
|
+
<ToolbarButton
|
|
1569
|
+
aria-label="Link"
|
|
1570
|
+
onClick={() => applyInlineFormat(formatting.blockId, "link")}
|
|
1571
|
+
>
|
|
1572
|
+
<IconChainLink3 size={14} className="size-3.5" aria-hidden />
|
|
1573
|
+
</ToolbarButton>
|
|
1574
|
+
</FormatTip>
|
|
1575
|
+
<FormatTip label="Bold" keys={["⌘", "B"]}>
|
|
1576
|
+
<ToolbarButton
|
|
1577
|
+
aria-label="Bold"
|
|
1578
|
+
className="font-semibold"
|
|
1579
|
+
onClick={() => applyInlineFormat(formatting.blockId, "bold")}
|
|
1580
|
+
>
|
|
1581
|
+
B
|
|
1582
|
+
</ToolbarButton>
|
|
1583
|
+
</FormatTip>
|
|
1584
|
+
<FormatTip label="Italic" keys={["⌘", "I"]}>
|
|
1585
|
+
<ToolbarButton
|
|
1586
|
+
aria-label="Italic"
|
|
1587
|
+
className="italic"
|
|
1588
|
+
onClick={() => applyInlineFormat(formatting.blockId, "italic")}
|
|
1589
|
+
>
|
|
1590
|
+
I
|
|
1591
|
+
</ToolbarButton>
|
|
1592
|
+
</FormatTip>
|
|
1593
|
+
<FormatTip label="Underline" keys={["⌘", "U"]}>
|
|
1594
|
+
<ToolbarButton
|
|
1595
|
+
aria-label="Underline"
|
|
1596
|
+
className="underline"
|
|
1597
|
+
onClick={() =>
|
|
1598
|
+
applyInlineFormat(formatting.blockId, "underline")
|
|
1599
|
+
}
|
|
1600
|
+
>
|
|
1601
|
+
U
|
|
1602
|
+
</ToolbarButton>
|
|
1603
|
+
</FormatTip>
|
|
1604
|
+
<FormatTip label="Strikethrough" keys={["⇧", "⌘", "X"]}>
|
|
1605
|
+
<ToolbarButton
|
|
1606
|
+
aria-label="Strikethrough"
|
|
1607
|
+
onClick={() =>
|
|
1608
|
+
applyInlineFormat(formatting.blockId, "strikethrough")
|
|
1609
|
+
}
|
|
1610
|
+
>
|
|
1611
|
+
<IconStrikeThrough size={14} className="size-3.5" aria-hidden />
|
|
1612
|
+
</ToolbarButton>
|
|
1613
|
+
</FormatTip>
|
|
1614
|
+
<FormatTip label="Highlight" keys={["⇧", "⌘", "H"]}>
|
|
1615
|
+
<ToolbarButton
|
|
1616
|
+
aria-label="Highlight"
|
|
1617
|
+
onClick={() =>
|
|
1618
|
+
applyInlineFormat(formatting.blockId, "highlight")
|
|
1619
|
+
}
|
|
1620
|
+
>
|
|
1621
|
+
<IconHighlight size={14} className="size-3.5" aria-hidden />
|
|
1622
|
+
</ToolbarButton>
|
|
1623
|
+
</FormatTip>
|
|
1624
|
+
</ToolbarGroup>
|
|
1625
|
+
<ToolbarSeparator />
|
|
1626
|
+
<Menu>
|
|
1627
|
+
<MenuTrigger
|
|
1628
|
+
render={
|
|
1629
|
+
<ToolbarButton
|
|
1630
|
+
className={blockTypeTriggerClassName(formattingType)}
|
|
1631
|
+
/>
|
|
1632
|
+
}
|
|
1633
|
+
>
|
|
1634
|
+
{blockTypeLabel(formattingType)}
|
|
1635
|
+
</MenuTrigger>
|
|
1636
|
+
<MenuPortal>
|
|
1637
|
+
<MenuPositioner align="start">
|
|
1638
|
+
<MenuPopup>
|
|
1639
|
+
<StyleItem
|
|
1640
|
+
label="Text"
|
|
1641
|
+
keys={["⌥", "⌘", "0"]}
|
|
1642
|
+
onClick={() => applyBlockType(formatting.blockId, "text")}
|
|
1643
|
+
/>
|
|
1644
|
+
<StyleItem
|
|
1645
|
+
label="Heading 1"
|
|
1646
|
+
labelClassName="text-[15px] leading-5 font-semibold"
|
|
1647
|
+
keys={["⌥", "⌘", "1"]}
|
|
1648
|
+
onClick={() =>
|
|
1649
|
+
applyBlockType(formatting.blockId, "heading1")
|
|
1650
|
+
}
|
|
1651
|
+
/>
|
|
1652
|
+
<StyleItem
|
|
1653
|
+
label="Heading 2"
|
|
1654
|
+
labelClassName="text-sm font-semibold"
|
|
1655
|
+
keys={["⌥", "⌘", "2"]}
|
|
1656
|
+
onClick={() =>
|
|
1657
|
+
applyBlockType(formatting.blockId, "heading2")
|
|
1658
|
+
}
|
|
1659
|
+
/>
|
|
1660
|
+
<StyleItem
|
|
1661
|
+
label="Heading 3"
|
|
1662
|
+
labelClassName="text-[13px] leading-4 font-semibold"
|
|
1663
|
+
keys={["⌥", "⌘", "3"]}
|
|
1664
|
+
onClick={() =>
|
|
1665
|
+
applyBlockType(formatting.blockId, "heading3")
|
|
1666
|
+
}
|
|
1667
|
+
/>
|
|
1668
|
+
<StyleItem
|
|
1669
|
+
label="Numbered list"
|
|
1670
|
+
keys={["⌥", "⌘", "4"]}
|
|
1671
|
+
onClick={() =>
|
|
1672
|
+
applyBlockType(formatting.blockId, "numbered")
|
|
1673
|
+
}
|
|
1674
|
+
/>
|
|
1675
|
+
<StyleItem
|
|
1676
|
+
label="Ordered list"
|
|
1677
|
+
keys={["⌥", "⌘", "5"]}
|
|
1678
|
+
onClick={() =>
|
|
1679
|
+
applyBlockType(formatting.blockId, "bulleted")
|
|
1680
|
+
}
|
|
1681
|
+
/>
|
|
1682
|
+
<StyleItem
|
|
1683
|
+
label="Checklist"
|
|
1684
|
+
keys={["⌥", "⌘", "6"]}
|
|
1685
|
+
onClick={() =>
|
|
1686
|
+
applyBlockType(formatting.blockId, "checklist")
|
|
1687
|
+
}
|
|
1688
|
+
/>
|
|
1689
|
+
</MenuPopup>
|
|
1690
|
+
</MenuPositioner>
|
|
1691
|
+
</MenuPortal>
|
|
1692
|
+
</Menu>
|
|
1693
|
+
</Toolbar>
|
|
1694
|
+
</TooltipProvider>
|
|
1695
|
+
)
|
|
1696
|
+
})()
|
|
1697
|
+
: null}
|
|
1698
|
+
{/* eslint-enable react-hooks/refs */}
|
|
1699
|
+
|
|
1700
|
+
{slash ? (
|
|
1701
|
+
<div
|
|
1702
|
+
role="listbox"
|
|
1703
|
+
aria-label="Insert block"
|
|
1704
|
+
style={{ top: slash.top + 8, left: slash.left }}
|
|
1705
|
+
className={cn(
|
|
1706
|
+
"absolute z-50 min-w-48 overflow-hidden",
|
|
1707
|
+
popupSurface,
|
|
1708
|
+
popupInset,
|
|
1709
|
+
motion.popupAnchor,
|
|
1710
|
+
)}
|
|
1711
|
+
>
|
|
1712
|
+
{SLASH_MENU_ITEMS.filter((item) =>
|
|
1713
|
+
openCommands.some((command) => command.type === item.type),
|
|
1714
|
+
).map((item, commandIndex) => {
|
|
1715
|
+
const selected = commandIndex === slash.index
|
|
1716
|
+
return (
|
|
1717
|
+
<button
|
|
1718
|
+
key={item.type}
|
|
1719
|
+
type="button"
|
|
1720
|
+
role="option"
|
|
1721
|
+
aria-selected={selected}
|
|
1722
|
+
className={cn(
|
|
1723
|
+
popupItem,
|
|
1724
|
+
"w-full gap-3",
|
|
1725
|
+
motion.colors,
|
|
1726
|
+
selected && "bg-background-tertiary",
|
|
1727
|
+
)}
|
|
1728
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
1729
|
+
onMouseEnter={() =>
|
|
1730
|
+
setSlash((current) =>
|
|
1731
|
+
current ? { ...current, index: commandIndex } : current,
|
|
1732
|
+
)
|
|
1733
|
+
}
|
|
1734
|
+
onClick={() =>
|
|
1735
|
+
applySlashCommand(slash.blockId, item.type, slash.query)
|
|
1736
|
+
}
|
|
1737
|
+
>
|
|
1738
|
+
<span className={item.labelClassName}>{item.label}</span>
|
|
1739
|
+
<Shortcut keys={item.keys} />
|
|
1740
|
+
</button>
|
|
1741
|
+
)
|
|
1742
|
+
})}
|
|
1743
|
+
</div>
|
|
1744
|
+
) : null}
|
|
1745
|
+
</div>
|
|
1746
|
+
)
|
|
1747
|
+
}
|