wiki-viewer 1.0.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 (119) hide show
  1. package/.next/standalone/.github/workflows/publish.yml +37 -0
  2. package/.next/standalone/DESIGN.md +521 -0
  3. package/.next/standalone/README.md +283 -0
  4. package/.next/standalone/bin/wiki-viewer.js +158 -0
  5. package/.next/standalone/next.config.ts +11 -0
  6. package/.next/standalone/package.json +120 -0
  7. package/.next/standalone/pnpm-lock.yaml +5654 -0
  8. package/.next/standalone/pnpm-workspace.yaml +2 -0
  9. package/.next/standalone/postcss.config.mjs +2 -0
  10. package/.next/standalone/public/logo.svg +11 -0
  11. package/.next/standalone/public/public/logo.svg +11 -0
  12. package/.next/standalone/server.js +44 -0
  13. package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
  14. package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
  15. package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
  16. package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
  17. package/.next/standalone/src/app/api/system/config/route.ts +10 -0
  18. package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
  19. package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
  20. package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
  21. package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
  22. package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
  23. package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
  24. package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
  25. package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
  26. package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
  27. package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
  28. package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
  29. package/.next/standalone/src/app/api/wiki/route.ts +98 -0
  30. package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
  31. package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
  32. package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
  33. package/.next/standalone/src/app/globals.css +716 -0
  34. package/.next/standalone/src/app/icon.svg +11 -0
  35. package/.next/standalone/src/app/layout.tsx +35 -0
  36. package/.next/standalone/src/app/page.tsx +1509 -0
  37. package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
  38. package/.next/standalone/src/components/dir-picker.tsx +393 -0
  39. package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
  40. package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
  41. package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
  42. package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
  43. package/.next/standalone/src/components/editor/editor.tsx +633 -0
  44. package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
  45. package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
  46. package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
  47. package/.next/standalone/src/components/editor/extensions.ts +148 -0
  48. package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
  49. package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
  50. package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
  51. package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
  52. package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
  53. package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
  54. package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
  55. package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
  56. package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
  57. package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
  58. package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
  59. package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
  60. package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
  61. package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
  62. package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
  63. package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
  64. package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
  65. package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
  66. package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
  67. package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
  68. package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
  69. package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
  70. package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
  71. package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
  72. package/.next/standalone/src/components/theme-provider.tsx +16 -0
  73. package/.next/standalone/src/components/theme-toggle.tsx +24 -0
  74. package/.next/standalone/src/components/ui/badge.tsx +35 -0
  75. package/.next/standalone/src/components/ui/button.tsx +56 -0
  76. package/.next/standalone/src/components/ui/card.tsx +63 -0
  77. package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
  78. package/.next/standalone/src/components/ui/command.tsx +150 -0
  79. package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
  80. package/.next/standalone/src/components/ui/dialog.tsx +119 -0
  81. package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
  82. package/.next/standalone/src/components/ui/input.tsx +22 -0
  83. package/.next/standalone/src/components/ui/label.tsx +25 -0
  84. package/.next/standalone/src/components/ui/popover.tsx +33 -0
  85. package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
  86. package/.next/standalone/src/components/ui/select.tsx +156 -0
  87. package/.next/standalone/src/components/ui/separator.tsx +30 -0
  88. package/.next/standalone/src/components/ui/sheet.tsx +140 -0
  89. package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
  90. package/.next/standalone/src/components/ui/switch.tsx +28 -0
  91. package/.next/standalone/src/components/ui/tabs.tsx +54 -0
  92. package/.next/standalone/src/components/ui/textarea.tsx +21 -0
  93. package/.next/standalone/src/components/ui/tip.tsx +23 -0
  94. package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
  95. package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
  96. package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
  97. package/.next/standalone/src/lib/app-runner.ts +231 -0
  98. package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
  99. package/.next/standalone/src/lib/config.ts +32 -0
  100. package/.next/standalone/src/lib/embeds/detect.ts +142 -0
  101. package/.next/standalone/src/lib/google/detect.ts +107 -0
  102. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
  103. package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
  104. package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
  105. package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
  106. package/.next/standalone/src/lib/root-dir.ts +35 -0
  107. package/.next/standalone/src/lib/toast.ts +9 -0
  108. package/.next/standalone/src/lib/utils.ts +6 -0
  109. package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
  110. package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
  111. package/.next/standalone/src/stores/editor-store.ts +244 -0
  112. package/.next/standalone/src/stores/tree-store.ts +52 -0
  113. package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
  114. package/.next/standalone/src/types/index.ts +47 -0
  115. package/.next/standalone/tailwind.config.ts +201 -0
  116. package/.next/standalone/tsconfig.json +41 -0
  117. package/README.md +283 -0
  118. package/bin/wiki-viewer.js +158 -0
  119. package/package.json +120 -0
@@ -0,0 +1,564 @@
1
+ "use client";
2
+
3
+ import type { Editor } from "@tiptap/react";
4
+ import {
5
+ AlignCenter,
6
+ AlignJustify,
7
+ AlignLeft,
8
+ AlignRight,
9
+ Bold,
10
+ CheckSquare,
11
+ ChevronLeft,
12
+ ChevronRight,
13
+ Code,
14
+ FileCode,
15
+ Heading1,
16
+ Heading2,
17
+ Heading3,
18
+ ImageIcon,
19
+ Italic,
20
+ Link as LinkIcon,
21
+ List,
22
+ ListOrdered,
23
+ Minus,
24
+ PilcrowLeft,
25
+ PilcrowRight,
26
+ Quote,
27
+ Redo,
28
+ Strikethrough,
29
+ Subscript as SubIcon,
30
+ Superscript as SuperIcon,
31
+ Underline as UnderlineIcon,
32
+ Undo,
33
+ Video as VideoIcon,
34
+ } from "lucide-react";
35
+ import { useCallback, useEffect, useRef, useState } from "react";
36
+ import { Separator } from "@/components/ui/separator";
37
+ import { cn } from "@/lib/utils";
38
+ import { useEditorStore } from "@/stores/editor-store";
39
+ import { LinkPopover } from "./link-popover";
40
+ import { type MediaKind, MediaPopover } from "./media-popover";
41
+
42
+ interface EditorToolbarProps {
43
+ editor: Editor | null;
44
+ }
45
+
46
+ type PopoverKind =
47
+ | null
48
+ | {
49
+ type: "link";
50
+ anchor: { top: number; left: number };
51
+ range: { from: number; to: number };
52
+ existing: string;
53
+ }
54
+ | { type: "media"; kind: MediaKind; anchor: { top: number; left: number } };
55
+
56
+ interface ToolButtonProps {
57
+ label: string;
58
+ icon: React.ComponentType<{ className?: string }>;
59
+ active?: boolean;
60
+ disabled?: boolean;
61
+ style?: React.CSSProperties;
62
+ onAction: (event: React.MouseEvent<HTMLButtonElement>) => void;
63
+ }
64
+
65
+ function ToolButton({
66
+ label,
67
+ icon: Icon,
68
+ active,
69
+ disabled,
70
+ style,
71
+ onAction,
72
+ }: ToolButtonProps) {
73
+ return (
74
+ <button
75
+ type="button"
76
+ aria-label={label}
77
+ title={label}
78
+ disabled={disabled}
79
+ style={style}
80
+ onMouseDown={(e) => {
81
+ e.preventDefault();
82
+ }}
83
+ onClick={(e) => {
84
+ e.preventDefault();
85
+ onAction(e);
86
+ }}
87
+ className={cn(
88
+ "h-8 w-8 shrink-0 inline-flex items-center justify-center rounded-md text-foreground/80 hover:bg-accent transition-colors disabled:opacity-40",
89
+ active &&
90
+ "bg-accent text-foreground ring-1 ring-inset ring-foreground/15",
91
+ )}
92
+ >
93
+ <Icon className="h-4 w-4" />
94
+ </button>
95
+ );
96
+ }
97
+
98
+ export function EditorToolbar({ editor }: EditorToolbarProps) {
99
+ const frontmatter = useEditorStore((s) => s.frontmatter);
100
+ const updateFrontmatter = useEditorStore((s) => s.updateFrontmatter);
101
+ const pagePath = useEditorStore((s) => s.currentPath);
102
+ const isRtl = frontmatter?.dir === "rtl";
103
+
104
+ const [popover, setPopover] = useState<PopoverKind>(null);
105
+ const scrollRef = useRef<HTMLDivElement | null>(null);
106
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
107
+ const [canScrollRight, setCanScrollRight] = useState(false);
108
+
109
+ const [, setTick] = useState(0);
110
+ useEffect(() => {
111
+ if (!editor) return;
112
+ const bump = () => setTick((t) => t + 1);
113
+ editor.on("selectionUpdate", bump);
114
+ editor.on("transaction", bump);
115
+ return () => {
116
+ editor.off("selectionUpdate", bump);
117
+ editor.off("transaction", bump);
118
+ };
119
+ }, [editor]);
120
+
121
+ const updateScrollState = useCallback(() => {
122
+ const el = scrollRef.current;
123
+ if (!el) return;
124
+ setCanScrollLeft(el.scrollLeft > 4);
125
+ setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
126
+ }, []);
127
+
128
+ useEffect(() => {
129
+ if (!editor) return;
130
+ const el = scrollRef.current;
131
+ if (!el) return;
132
+ const raf = requestAnimationFrame(updateScrollState);
133
+ const onResize = () => updateScrollState();
134
+ window.addEventListener("resize", onResize);
135
+ el.addEventListener("scroll", updateScrollState);
136
+ const ro = new ResizeObserver(() => updateScrollState());
137
+ ro.observe(el);
138
+ for (const child of Array.from(el.children)) ro.observe(child);
139
+ return () => {
140
+ cancelAnimationFrame(raf);
141
+ window.removeEventListener("resize", onResize);
142
+ el.removeEventListener("scroll", updateScrollState);
143
+ ro.disconnect();
144
+ };
145
+ }, [editor, updateScrollState]);
146
+
147
+ const onWheel = (e: React.WheelEvent<HTMLDivElement>) => {
148
+ const el = scrollRef.current;
149
+ if (!el) return;
150
+ if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
151
+ el.scrollLeft += e.deltaY;
152
+ }
153
+ };
154
+
155
+ const scrollBy = (dir: -1 | 1) => {
156
+ const el = scrollRef.current;
157
+ if (!el) return;
158
+ el.scrollBy({
159
+ left: dir * Math.max(160, el.clientWidth * 0.6),
160
+ behavior: "smooth",
161
+ });
162
+ };
163
+
164
+ if (!editor) return null;
165
+
166
+ const captureRange = () => {
167
+ const { from, to } = editor.state.selection;
168
+ return { from, to };
169
+ };
170
+
171
+ const applyToRange = (
172
+ range: { from: number; to: number },
173
+ run: () => void,
174
+ ) => {
175
+ editor.chain().focus().setTextSelection(range).run();
176
+ run();
177
+ };
178
+
179
+ const openPopoverFromButton = (
180
+ e: React.MouseEvent<HTMLElement>,
181
+ build: (
182
+ anchor: { top: number; left: number },
183
+ range: { from: number; to: number },
184
+ ) => PopoverKind,
185
+ ) => {
186
+ const btn = e.currentTarget.getBoundingClientRect();
187
+ const anchor = { top: btn.bottom + 6, left: btn.left };
188
+ const range = captureRange();
189
+ setPopover(build(anchor, range));
190
+ };
191
+
192
+ const toggleLink = (e: React.MouseEvent<HTMLButtonElement>) => {
193
+ const existing = editor.getAttributes("link")?.href ?? "";
194
+ openPopoverFromButton(e, (anchor, range) => ({
195
+ type: "link",
196
+ anchor,
197
+ range,
198
+ existing,
199
+ }));
200
+ };
201
+
202
+ const applyLink = (url: string) => {
203
+ if (popover?.type !== "link") return;
204
+ applyToRange(popover.range, () => {
205
+ editor
206
+ .chain()
207
+ .focus()
208
+ .extendMarkRange("link")
209
+ .setLink({ href: url })
210
+ .run();
211
+ });
212
+ setPopover(null);
213
+ };
214
+
215
+ const removeLink = () => {
216
+ if (popover?.type !== "link") return;
217
+ applyToRange(popover.range, () => {
218
+ editor.chain().focus().unsetLink().run();
219
+ });
220
+ setPopover(null);
221
+ };
222
+
223
+ const insertMedia = (
224
+ kind: MediaKind,
225
+ payload: { url: string; alt?: string; mimeType?: string },
226
+ ) => {
227
+ const { url, alt, mimeType } = payload;
228
+ const type = mimeType ?? "";
229
+ const isImage =
230
+ kind === "image" ||
231
+ type.startsWith("image/") ||
232
+ /\.(png|jpe?g|gif|webp|svg|avif)(\?|$)/i.test(url);
233
+ if (isImage) {
234
+ editor
235
+ .chain()
236
+ .focus()
237
+ .setImage({ src: url, alt: alt ?? "" })
238
+ .run();
239
+ } else {
240
+ editor
241
+ .chain()
242
+ .focus()
243
+ .insertContent(`<a href="${url}">${alt ?? url}</a>`)
244
+ .run();
245
+ }
246
+ setPopover(null);
247
+ };
248
+
249
+ type ButtonSpec =
250
+ | { separator: true }
251
+ | {
252
+ icon: React.ComponentType<{ className?: string }>;
253
+ action: (e: React.MouseEvent<HTMLButtonElement>) => void;
254
+ isActive: boolean;
255
+ label: string;
256
+ style?: React.CSSProperties;
257
+ };
258
+
259
+ const primaryItems: ButtonSpec[] = [
260
+ {
261
+ icon: Heading1,
262
+ action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
263
+ isActive: editor.isActive("heading", { level: 1 }),
264
+ label: "Heading 1",
265
+ },
266
+ {
267
+ icon: Heading2,
268
+ action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
269
+ isActive: editor.isActive("heading", { level: 2 }),
270
+ label: "Heading 2",
271
+ },
272
+ {
273
+ icon: Heading3,
274
+ action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
275
+ isActive: editor.isActive("heading", { level: 3 }),
276
+ label: "Heading 3",
277
+ },
278
+ { separator: true },
279
+ {
280
+ icon: Bold,
281
+ action: () => editor.chain().focus().toggleBold().run(),
282
+ isActive: editor.isActive("bold"),
283
+ label: "Bold",
284
+ },
285
+ {
286
+ icon: Italic,
287
+ action: () => editor.chain().focus().toggleItalic().run(),
288
+ isActive: editor.isActive("italic"),
289
+ label: "Italic",
290
+ },
291
+ {
292
+ icon: UnderlineIcon,
293
+ action: () => editor.chain().focus().toggleUnderline().run(),
294
+ isActive: editor.isActive("underline"),
295
+ label: "Underline",
296
+ },
297
+ {
298
+ icon: Strikethrough,
299
+ action: () => editor.chain().focus().toggleStrike().run(),
300
+ isActive: editor.isActive("strike"),
301
+ label: "Strikethrough",
302
+ },
303
+ {
304
+ icon: Code,
305
+ action: () => editor.chain().focus().toggleCode().run(),
306
+ isActive: editor.isActive("code"),
307
+ label: "Inline code",
308
+ },
309
+ {
310
+ icon: LinkIcon,
311
+ action: toggleLink,
312
+ isActive: editor.isActive("link"),
313
+ label: "Link",
314
+ },
315
+ { separator: true },
316
+ {
317
+ icon: List,
318
+ action: () => editor.chain().focus().toggleBulletList().run(),
319
+ isActive: editor.isActive("bulletList"),
320
+ label: "Bullet list",
321
+ },
322
+ {
323
+ icon: ListOrdered,
324
+ action: () => editor.chain().focus().toggleOrderedList().run(),
325
+ isActive: editor.isActive("orderedList"),
326
+ label: "Ordered list",
327
+ },
328
+ {
329
+ icon: Quote,
330
+ action: () => editor.chain().focus().toggleBlockquote().run(),
331
+ isActive: editor.isActive("blockquote"),
332
+ label: "Blockquote",
333
+ },
334
+ {
335
+ icon: CheckSquare,
336
+ action: () => editor.chain().focus().toggleTaskList().run(),
337
+ isActive: editor.isActive("taskList"),
338
+ label: "Checklist",
339
+ },
340
+ {
341
+ icon: FileCode,
342
+ action: () => editor.chain().focus().toggleCodeBlock().run(),
343
+ isActive: editor.isActive("codeBlock"),
344
+ label: "Code block",
345
+ },
346
+ {
347
+ icon: Minus,
348
+ action: () => editor.chain().focus().setHorizontalRule().run(),
349
+ isActive: false,
350
+ label: "Divider",
351
+ },
352
+ ];
353
+
354
+ const secondaryItems: ButtonSpec[] = [
355
+ {
356
+ icon: AlignLeft,
357
+ action: () => editor.chain().focus().setTextAlign("left").run(),
358
+ isActive: editor.isActive({ textAlign: "left" }),
359
+ label: "Align left",
360
+ },
361
+ {
362
+ icon: AlignCenter,
363
+ action: () => editor.chain().focus().setTextAlign("center").run(),
364
+ isActive: editor.isActive({ textAlign: "center" }),
365
+ label: "Align center",
366
+ },
367
+ {
368
+ icon: AlignRight,
369
+ action: () => editor.chain().focus().setTextAlign("right").run(),
370
+ isActive: editor.isActive({ textAlign: "right" }),
371
+ label: "Align right",
372
+ },
373
+ {
374
+ icon: AlignJustify,
375
+ action: () => editor.chain().focus().setTextAlign("justify").run(),
376
+ isActive: editor.isActive({ textAlign: "justify" }),
377
+ label: "Justify",
378
+ },
379
+ { separator: true },
380
+ {
381
+ icon: SuperIcon,
382
+ action: () => editor.chain().focus().toggleSuperscript().run(),
383
+ isActive: editor.isActive("superscript"),
384
+ label: "Superscript",
385
+ },
386
+ {
387
+ icon: SubIcon,
388
+ action: () => editor.chain().focus().toggleSubscript().run(),
389
+ isActive: editor.isActive("subscript"),
390
+ label: "Subscript",
391
+ },
392
+ { separator: true },
393
+ {
394
+ icon: ImageIcon,
395
+ action: (e) =>
396
+ openPopoverFromButton(e, (anchor) => ({
397
+ type: "media",
398
+ kind: "image",
399
+ anchor,
400
+ })),
401
+ isActive: false,
402
+ label: "Insert image",
403
+ },
404
+ {
405
+ icon: VideoIcon,
406
+ action: (e) =>
407
+ openPopoverFromButton(e, (anchor) => ({
408
+ type: "media",
409
+ kind: "video",
410
+ anchor,
411
+ })),
412
+ isActive: false,
413
+ label: "Insert video",
414
+ },
415
+ { separator: true },
416
+ {
417
+ icon: Undo,
418
+ action: () => editor.chain().focus().undo().run(),
419
+ isActive: false,
420
+ label: "Undo",
421
+ },
422
+ {
423
+ icon: Redo,
424
+ action: () => editor.chain().focus().redo().run(),
425
+ isActive: false,
426
+ label: "Redo",
427
+ },
428
+ { separator: true },
429
+ {
430
+ icon: isRtl ? PilcrowLeft : PilcrowRight,
431
+ action: () => updateFrontmatter({ dir: isRtl ? undefined : "rtl" }),
432
+ isActive: isRtl,
433
+ label: isRtl ? "Switch to LTR" : "Switch to RTL",
434
+ },
435
+ ];
436
+
437
+ return (
438
+ <>
439
+ <div className="relative border-b border-border bg-background/50">
440
+ {canScrollLeft && (
441
+ <button
442
+ type="button"
443
+ aria-label="Scroll toolbar left"
444
+ onMouseDown={(e) => e.preventDefault()}
445
+ onClick={() => scrollBy(-1)}
446
+ className="absolute left-0 top-0 bottom-0 w-6 z-10 flex items-center justify-start pl-0.5 bg-gradient-to-r from-background via-background/80 to-transparent text-muted-foreground hover:text-foreground transition-colors"
447
+ >
448
+ <ChevronLeft className="h-4 w-4" />
449
+ </button>
450
+ )}
451
+ {canScrollRight && (
452
+ <button
453
+ type="button"
454
+ aria-label="Scroll toolbar right"
455
+ onMouseDown={(e) => e.preventDefault()}
456
+ onClick={() => scrollBy(1)}
457
+ className="absolute right-0 top-0 bottom-0 w-6 z-10 flex items-center justify-end pr-0.5 bg-gradient-to-l from-background via-background/80 to-transparent text-muted-foreground hover:text-foreground transition-colors"
458
+ >
459
+ <ChevronRight className="h-4 w-4" />
460
+ </button>
461
+ )}
462
+ <div
463
+ ref={scrollRef}
464
+ onWheel={onWheel}
465
+ className="flex items-center gap-0.5 px-2 pt-1 pb-1.5 overflow-x-scroll overflow-y-hidden editor-toolbar-scroll"
466
+ >
467
+ {[
468
+ ...primaryItems,
469
+ { separator: true } as ButtonSpec,
470
+ ...secondaryItems,
471
+ ].map((item, i) => {
472
+ if ("separator" in item) {
473
+ return (
474
+ <Separator
475
+ key={i}
476
+ orientation="vertical"
477
+ className="mx-1 h-6 shrink-0"
478
+ />
479
+ );
480
+ }
481
+ return (
482
+ <ToolButton
483
+ key={i}
484
+ label={item.label}
485
+ icon={item.icon}
486
+ active={item.isActive}
487
+ style={item.style}
488
+ onAction={item.action}
489
+ />
490
+ );
491
+ })}
492
+ </div>
493
+ </div>
494
+
495
+ {popover?.type === "link" && (
496
+ <div
497
+ data-editor-popover="true"
498
+ style={{
499
+ position: "fixed",
500
+ top: popover.anchor.top,
501
+ left: popover.anchor.left,
502
+ zIndex: 60,
503
+ }}
504
+ >
505
+ <LinkPopover
506
+ anchor={{ top: 0, left: 0 }}
507
+ initialUrl={popover.existing}
508
+ onCancel={() => setPopover(null)}
509
+ onApply={applyLink}
510
+ onRemove={popover.existing ? removeLink : undefined}
511
+ />
512
+ </div>
513
+ )}
514
+
515
+ {popover?.type === "media" && pagePath && (
516
+ <div
517
+ data-editor-popover="true"
518
+ style={{
519
+ position: "fixed",
520
+ top: popover.anchor.top,
521
+ left: popover.anchor.left,
522
+ zIndex: 60,
523
+ }}
524
+ >
525
+ <MediaPopover
526
+ kind={popover.kind}
527
+ pagePath={pagePath}
528
+ anchor={{ top: 0, left: 0 }}
529
+ onCancel={() => setPopover(null)}
530
+ onInsert={(payload) => insertMedia(popover.kind, payload)}
531
+ />
532
+ </div>
533
+ )}
534
+
535
+ {popover && <ClickOutsideClose onClose={() => setPopover(null)} />}
536
+ </>
537
+ );
538
+ }
539
+
540
+ function ClickOutsideClose({ onClose }: { onClose: () => void }) {
541
+ useEffect(() => {
542
+ const mount = window.setTimeout(() => {
543
+ const handle = (e: MouseEvent) => {
544
+ const target = e.target as HTMLElement | null;
545
+ if (target?.closest('[data-editor-popover="true"]')) return;
546
+ onClose();
547
+ };
548
+ window.addEventListener("mousedown", handle);
549
+ (
550
+ window as unknown as { __editorPopClose?: () => void }
551
+ ).__editorPopClose = () =>
552
+ window.removeEventListener("mousedown", handle);
553
+ }, 10);
554
+ return () => {
555
+ window.clearTimeout(mount);
556
+ const w = window as unknown as { __editorPopClose?: () => void };
557
+ if (w.__editorPopClose) {
558
+ w.__editorPopClose();
559
+ w.__editorPopClose = undefined;
560
+ }
561
+ };
562
+ }, [onClose]);
563
+ return null;
564
+ }