@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.
Files changed (82) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +5 -2
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/attachment.tsx +5 -2
  5. package/src/autocomplete.tsx +4 -3
  6. package/src/block-editor.tsx +1747 -0
  7. package/src/brand.tsx +2 -2
  8. package/src/breadcrumb.tsx +14 -7
  9. package/src/button.tsx +22 -11
  10. package/src/calendar.tsx +6 -3
  11. package/src/carousel.tsx +220 -53
  12. package/src/checkbox.tsx +3 -2
  13. package/src/code-block.tsx +66 -5
  14. package/src/collapsible.tsx +4 -2
  15. package/src/combobox.tsx +4 -3
  16. package/src/command.tsx +27 -12
  17. package/src/dialog.tsx +33 -10
  18. package/src/empty.tsx +4 -4
  19. package/src/field.tsx +4 -3
  20. package/src/filter-group.tsx +143 -0
  21. package/src/icons.tsx +28 -1
  22. package/src/illustrations.tsx +219 -141
  23. package/src/index.ts +238 -18
  24. package/src/input.tsx +8 -5
  25. package/src/kbd.tsx +10 -4
  26. package/src/lib/focus.ts +41 -0
  27. package/src/lib/popup.ts +1 -1
  28. package/src/lifeline/company-icon.tsx +71 -0
  29. package/src/lifeline/index.ts +42 -0
  30. package/src/lifeline/lifeline-data.ts +97 -0
  31. package/src/lifeline/lifeline-desktop.tsx +204 -0
  32. package/src/lifeline/lifeline-event.tsx +108 -0
  33. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  34. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  35. package/src/lifeline/lifeline-icons.tsx +39 -0
  36. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  37. package/src/lifeline/lifeline-labels.tsx +24 -0
  38. package/src/lifeline/lifeline-layout.ts +1 -0
  39. package/src/lifeline/lifeline-legend.tsx +31 -0
  40. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  41. package/src/lifeline/lifeline-marker.tsx +183 -0
  42. package/src/lifeline/lifeline-people.tsx +99 -0
  43. package/src/lifeline/lifeline-photos.tsx +366 -0
  44. package/src/lifeline/lifeline-shell.tsx +135 -0
  45. package/src/lifeline/lifeline-utils.ts +83 -0
  46. package/src/lifeline/lifeline-vertical.tsx +482 -0
  47. package/src/lifeline/lifeline.tsx +69 -0
  48. package/src/lifeline/types.ts +112 -0
  49. package/src/lifeline/use-lifeline-intro.ts +130 -0
  50. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  51. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  52. package/src/menubar.tsx +9 -2
  53. package/src/minimap.tsx +296 -0
  54. package/src/modal.tsx +608 -0
  55. package/src/navigation-menu.tsx +338 -67
  56. package/src/number-field.tsx +336 -61
  57. package/src/otp-field.tsx +4 -3
  58. package/src/pagination.tsx +262 -42
  59. package/src/password-protection.tsx +345 -0
  60. package/src/popover.tsx +1 -1
  61. package/src/progress.tsx +5 -0
  62. package/src/questionnaire.tsx +284 -0
  63. package/src/radio.tsx +4 -2
  64. package/src/scroll-area.tsx +4 -1
  65. package/src/select.tsx +5 -2
  66. package/src/sidebar.tsx +113 -28
  67. package/src/slider.tsx +66 -6
  68. package/src/sounds.tsx +6 -10
  69. package/src/spinner.tsx +105 -32
  70. package/src/switch.tsx +4 -1
  71. package/src/table.tsx +82 -15
  72. package/src/tabs.tsx +189 -38
  73. package/src/text-animate.tsx +71 -28
  74. package/src/textarea.tsx +6 -1
  75. package/src/timeline.tsx +378 -0
  76. package/src/toast.tsx +214 -35
  77. package/src/toggle.tsx +4 -2
  78. package/src/toolbar.tsx +12 -7
  79. package/src/tooltip.tsx +43 -3
  80. package/src/video-player.tsx +396 -127
  81. package/src/image-modal.tsx +0 -110
  82. 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(/&nbsp;/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(/&nbsp;/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
+ }