@textcortex/slidewise 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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/__vite-browser-external-DYxpcVy9.js +5 -0
  4. package/dist/__vite-browser-external-DYxpcVy9.js.map +1 -0
  5. package/dist/file.svg +1 -0
  6. package/dist/globe.svg +1 -0
  7. package/dist/index.mjs +16697 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/slidewise.css +1 -0
  10. package/dist/types/SlidewiseEditor.d.ts +47 -0
  11. package/dist/types/SlidewiseFileEditor.d.ts +54 -0
  12. package/dist/types/components/editor/BottomToolbar.d.ts +1 -0
  13. package/dist/types/components/editor/Canvas.d.ts +1 -0
  14. package/dist/types/components/editor/Editor.d.ts +8 -0
  15. package/dist/types/components/editor/ElementView.d.ts +6 -0
  16. package/dist/types/components/editor/FloatingToolbar.d.ts +6 -0
  17. package/dist/types/components/editor/GridView.d.ts +1 -0
  18. package/dist/types/components/editor/PlayMode.d.ts +1 -0
  19. package/dist/types/components/editor/SelectionFrame.d.ts +8 -0
  20. package/dist/types/components/editor/SlideRail.d.ts +1 -0
  21. package/dist/types/components/editor/SlideView.d.ts +5 -0
  22. package/dist/types/components/editor/TopBar.d.ts +7 -0
  23. package/dist/types/index.d.ts +7 -0
  24. package/dist/types/lib/StoreProvider.d.ts +8 -0
  25. package/dist/types/lib/fonts.d.ts +9 -0
  26. package/dist/types/lib/pptx/deckToPptx.d.ts +9 -0
  27. package/dist/types/lib/pptx/index.d.ts +3 -0
  28. package/dist/types/lib/pptx/pptxToDeck.d.ts +18 -0
  29. package/dist/types/lib/pptx/types.d.ts +15 -0
  30. package/dist/types/lib/pptx/units.d.ts +25 -0
  31. package/dist/types/lib/schema/migrate.d.ts +25 -0
  32. package/dist/types/lib/seed.d.ts +2 -0
  33. package/dist/types/lib/store.d.ts +55 -0
  34. package/dist/types/lib/types.d.ts +141 -0
  35. package/dist/window.svg +1 -0
  36. package/package.json +86 -0
  37. package/src/App.tsx +261 -0
  38. package/src/SlidewiseEditor.css +146 -0
  39. package/src/SlidewiseEditor.tsx +214 -0
  40. package/src/SlidewiseFileEditor.tsx +242 -0
  41. package/src/components/editor/BottomToolbar.tsx +216 -0
  42. package/src/components/editor/Canvas.tsx +467 -0
  43. package/src/components/editor/Editor.tsx +53 -0
  44. package/src/components/editor/ElementView.tsx +588 -0
  45. package/src/components/editor/FloatingToolbar.tsx +729 -0
  46. package/src/components/editor/GridView.tsx +232 -0
  47. package/src/components/editor/PlayMode.tsx +260 -0
  48. package/src/components/editor/SelectionFrame.tsx +241 -0
  49. package/src/components/editor/SlideRail.tsx +285 -0
  50. package/src/components/editor/SlideView.tsx +55 -0
  51. package/src/components/editor/TopBar.tsx +240 -0
  52. package/src/fonts.css +2 -0
  53. package/src/index.css +13 -0
  54. package/src/index.ts +36 -0
  55. package/src/lib/StoreProvider.tsx +43 -0
  56. package/src/lib/__tests__/css-scope.test.ts +133 -0
  57. package/src/lib/fonts.ts +104 -0
  58. package/src/lib/pptx/__tests__/roundtrip.test.ts +240 -0
  59. package/src/lib/pptx/deckToPptx.ts +300 -0
  60. package/src/lib/pptx/index.ts +3 -0
  61. package/src/lib/pptx/pptxToDeck.ts +1515 -0
  62. package/src/lib/pptx/types.ts +17 -0
  63. package/src/lib/pptx/units.ts +32 -0
  64. package/src/lib/schema/__tests__/migrate.test.ts +70 -0
  65. package/src/lib/schema/migrate.ts +102 -0
  66. package/src/lib/seed.ts +777 -0
  67. package/src/lib/store.ts +384 -0
  68. package/src/lib/types.ts +185 -0
  69. package/src/main.tsx +10 -0
  70. package/src/vite-env.d.ts +3 -0
@@ -0,0 +1,729 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ Bold,
4
+ Italic,
5
+ Underline,
6
+ Strikethrough,
7
+ AlignLeft,
8
+ AlignCenter,
9
+ AlignRight,
10
+ ChevronDown,
11
+ Layers,
12
+ Sigma,
13
+ ArrowUpToLine,
14
+ ArrowDownToLine,
15
+ AlignVerticalJustifyCenter,
16
+ } from "lucide-react";
17
+ import type { SlideElement } from "@/lib/types";
18
+ import { useEditor } from "@/lib/StoreProvider";
19
+
20
+ const FONTS = [
21
+ "Inter",
22
+ "Coda",
23
+ "Geist",
24
+ "JetBrains Mono",
25
+ "Georgia",
26
+ "Helvetica",
27
+ "system-ui",
28
+ ];
29
+
30
+ const COLORS = [
31
+ "#0E1330",
32
+ "#FFFFFF",
33
+ "#4F5BD5",
34
+ "#E8504C",
35
+ "#F2B544",
36
+ "#3DB270",
37
+ "#9CA3AF",
38
+ ];
39
+
40
+ const MATH_SYMBOLS = [
41
+ { glyph: "∑", label: "Sum" },
42
+ { glyph: "∏", label: "Product" },
43
+ { glyph: "∫", label: "Integral" },
44
+ { glyph: "√", label: "Sqrt" },
45
+ { glyph: "π", label: "Pi" },
46
+ { glyph: "∞", label: "Infinity" },
47
+ { glyph: "≈", label: "Approx" },
48
+ { glyph: "≠", label: "Not equal" },
49
+ { glyph: "≤", label: "Less or eq" },
50
+ { glyph: "≥", label: "Greater or eq" },
51
+ { glyph: "→", label: "Arrow" },
52
+ { glyph: "±", label: "Plus minus" },
53
+ { glyph: "Δ", label: "Delta" },
54
+ { glyph: "λ", label: "Lambda" },
55
+ ];
56
+
57
+ export function FloatingToolbar({
58
+ element,
59
+ scale,
60
+ surfaceRef,
61
+ }: {
62
+ element: SlideElement;
63
+ scale: number;
64
+ surfaceRef: React.RefObject<HTMLDivElement | null>;
65
+ }) {
66
+ const updateElement = useEditor((s) => s.updateElement);
67
+ const bringForward = useEditor((s) => s.bringForward);
68
+ const sendBackward = useEditor((s) => s.sendBackward);
69
+
70
+ const ref = useRef<HTMLDivElement>(null);
71
+ const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
72
+
73
+ useEffect(() => {
74
+ const update = () => {
75
+ if (!surfaceRef.current) return;
76
+ const surf = surfaceRef.current.getBoundingClientRect();
77
+ const parent = surfaceRef.current.parentElement!.getBoundingClientRect();
78
+ const left =
79
+ surf.left - parent.left + (element.x + element.w / 2) * scale;
80
+ const top = surf.top - parent.top + element.y * scale - 56;
81
+ setPos({ left, top: Math.max(8, top) });
82
+ };
83
+ update();
84
+ window.addEventListener("scroll", update, true);
85
+ window.addEventListener("resize", update);
86
+ return () => {
87
+ window.removeEventListener("scroll", update, true);
88
+ window.removeEventListener("resize", update);
89
+ };
90
+ }, [element, scale, surfaceRef]);
91
+
92
+ if (!pos) return null;
93
+
94
+ const isText = element.type === "text";
95
+ const isShape = element.type === "shape";
96
+ const isImage = element.type === "image";
97
+ const isLine = element.type === "line";
98
+
99
+ return (
100
+ <div
101
+ ref={ref}
102
+ style={{
103
+ position: "absolute",
104
+ left: pos.left,
105
+ top: pos.top,
106
+ transform: "translateX(-50%)",
107
+ zIndex: 30,
108
+ }}
109
+ >
110
+ <div
111
+ style={{
112
+ display: "flex",
113
+ alignItems: "center",
114
+ gap: 4,
115
+ padding: 6,
116
+ background: "var(--toolbar-bg)",
117
+ backdropFilter: "blur(20px)",
118
+ WebkitBackdropFilter: "blur(20px)",
119
+ border: "1px solid var(--border)",
120
+ borderRadius: 14,
121
+ boxShadow: "var(--toolbar-shadow)",
122
+ fontSize: 13,
123
+ color: "var(--ink)",
124
+ fontFamily: "Inter, system-ui, sans-serif",
125
+ }}
126
+ >
127
+ {isText && (
128
+ <>
129
+ <ColorBtn
130
+ label="Text color"
131
+ value={element.color}
132
+ onChange={(c) => updateElement(element.id, { color: c })}
133
+ />
134
+ <Sep />
135
+ <Select
136
+ label="Font family"
137
+ value={element.fontFamily}
138
+ onChange={(v) => updateElement(element.id, { fontFamily: v })}
139
+ options={FONTS}
140
+ width={108}
141
+ />
142
+ <Sep />
143
+ <NumberInput
144
+ label="Font size"
145
+ value={element.fontSize}
146
+ onChange={(v) => updateElement(element.id, { fontSize: v })}
147
+ suffix="px"
148
+ width={66}
149
+ />
150
+ <Sep />
151
+ <Toggle
152
+ label="Bold"
153
+ active={element.fontWeight >= 700}
154
+ onClick={() =>
155
+ updateElement(element.id, {
156
+ fontWeight: element.fontWeight >= 700 ? 400 : 700,
157
+ })
158
+ }
159
+ >
160
+ <Bold size={15} />
161
+ </Toggle>
162
+ <Toggle
163
+ label="Italic"
164
+ active={element.italic}
165
+ onClick={() =>
166
+ updateElement(element.id, { italic: !element.italic })
167
+ }
168
+ >
169
+ <Italic size={15} />
170
+ </Toggle>
171
+ <Toggle
172
+ label="Underline"
173
+ active={element.underline}
174
+ onClick={() =>
175
+ updateElement(element.id, { underline: !element.underline })
176
+ }
177
+ >
178
+ <Underline size={15} />
179
+ </Toggle>
180
+ <Toggle
181
+ label="Strikethrough"
182
+ active={element.strike}
183
+ onClick={() =>
184
+ updateElement(element.id, { strike: !element.strike })
185
+ }
186
+ >
187
+ <Strikethrough size={15} />
188
+ </Toggle>
189
+ <Sep />
190
+ <Menu
191
+ label="Insert math symbol"
192
+ icon={<Sigma size={15} />}
193
+ options={MATH_SYMBOLS.map((sym) => ({
194
+ label: `${sym.glyph} ${sym.label}`,
195
+ onClick: () =>
196
+ updateElement(element.id, {
197
+ text: (element.text ?? "") + sym.glyph,
198
+ }),
199
+ }))}
200
+ />
201
+ <Menu
202
+ label="Horizontal alignment"
203
+ icon={
204
+ element.align === "center" ? (
205
+ <AlignCenter size={15} />
206
+ ) : element.align === "right" ? (
207
+ <AlignRight size={15} />
208
+ ) : (
209
+ <AlignLeft size={15} />
210
+ )
211
+ }
212
+ options={[
213
+ {
214
+ label: "Left",
215
+ icon: <AlignLeft size={14} />,
216
+ onClick: () => updateElement(element.id, { align: "left" }),
217
+ },
218
+ {
219
+ label: "Center",
220
+ icon: <AlignCenter size={14} />,
221
+ onClick: () => updateElement(element.id, { align: "center" }),
222
+ },
223
+ {
224
+ label: "Right",
225
+ icon: <AlignRight size={14} />,
226
+ onClick: () => updateElement(element.id, { align: "right" }),
227
+ },
228
+ ]}
229
+ />
230
+ <Menu
231
+ label="Vertical alignment"
232
+ icon={
233
+ element.vAlign === "middle" ? (
234
+ <AlignVerticalJustifyCenter size={15} />
235
+ ) : element.vAlign === "bottom" ? (
236
+ <ArrowDownToLine size={15} />
237
+ ) : (
238
+ <ArrowUpToLine size={15} />
239
+ )
240
+ }
241
+ options={[
242
+ {
243
+ label: "Top",
244
+ icon: <ArrowUpToLine size={14} />,
245
+ onClick: () => updateElement(element.id, { vAlign: "top" }),
246
+ },
247
+ {
248
+ label: "Middle",
249
+ icon: <AlignVerticalJustifyCenter size={14} />,
250
+ onClick: () =>
251
+ updateElement(element.id, { vAlign: "middle" }),
252
+ },
253
+ {
254
+ label: "Bottom",
255
+ icon: <ArrowDownToLine size={14} />,
256
+ onClick: () =>
257
+ updateElement(element.id, { vAlign: "bottom" }),
258
+ },
259
+ ]}
260
+ />
261
+ <NumberInput
262
+ label="Line height"
263
+ value={element.lineHeight}
264
+ onChange={(v) => updateElement(element.id, { lineHeight: v })}
265
+ step={0.05}
266
+ suffix=""
267
+ width={56}
268
+ min={0.6}
269
+ max={3}
270
+ />
271
+ <Sep />
272
+ </>
273
+ )}
274
+
275
+ {isShape && (
276
+ <>
277
+ <ColorBtn
278
+ label="Fill color"
279
+ value={element.fill}
280
+ onChange={(c) => updateElement(element.id, { fill: c })}
281
+ />
282
+ <Sep />
283
+ <NumberInput
284
+ label="Corner radius"
285
+ value={element.radius ?? 0}
286
+ onChange={(v) => updateElement(element.id, { radius: v })}
287
+ suffix="r"
288
+ width={56}
289
+ />
290
+ <Sep />
291
+ </>
292
+ )}
293
+
294
+ {isImage && (
295
+ <>
296
+ <NumberInput
297
+ label="Corner radius"
298
+ value={element.radius ?? 0}
299
+ onChange={(v) => updateElement(element.id, { radius: v })}
300
+ suffix="r"
301
+ width={56}
302
+ />
303
+ <Sep />
304
+ </>
305
+ )}
306
+
307
+ {isLine && (
308
+ <>
309
+ <ColorBtn
310
+ label="Stroke color"
311
+ value={element.stroke}
312
+ onChange={(c) => updateElement(element.id, { stroke: c })}
313
+ />
314
+ <NumberInput
315
+ label="Stroke width"
316
+ value={element.strokeWidth}
317
+ onChange={(v) => updateElement(element.id, { strokeWidth: v })}
318
+ suffix="w"
319
+ width={56}
320
+ />
321
+ <Sep />
322
+ </>
323
+ )}
324
+
325
+ <Menu
326
+ label="Layer order"
327
+ icon={<Layers size={15} />}
328
+ options={[
329
+ {
330
+ label: "Bring forward",
331
+ onClick: () => bringForward(element.id),
332
+ },
333
+ {
334
+ label: "Send backward",
335
+ onClick: () => sendBackward(element.id),
336
+ },
337
+ ]}
338
+ />
339
+ </div>
340
+ </div>
341
+ );
342
+ }
343
+
344
+ function Sep() {
345
+ return (
346
+ <div
347
+ aria-hidden="true"
348
+ style={{
349
+ width: 1,
350
+ height: 18,
351
+ background: "var(--border)",
352
+ margin: "0 2px",
353
+ }}
354
+ />
355
+ );
356
+ }
357
+
358
+ function Toggle({
359
+ active,
360
+ children,
361
+ onClick,
362
+ label,
363
+ }: {
364
+ active?: boolean;
365
+ children: React.ReactNode;
366
+ onClick: () => void;
367
+ label: string;
368
+ }) {
369
+ return (
370
+ <button
371
+ onClick={onClick}
372
+ title={label}
373
+ aria-label={label}
374
+ aria-pressed={!!active}
375
+ style={{
376
+ height: 28,
377
+ minWidth: 28,
378
+ padding: "0 6px",
379
+ background: active ? "var(--active)" : "transparent",
380
+ border: "none",
381
+ borderRadius: 8,
382
+ cursor: "pointer",
383
+ display: "flex",
384
+ alignItems: "center",
385
+ justifyContent: "center",
386
+ color: "var(--ink)",
387
+ }}
388
+ onMouseEnter={(e) =>
389
+ (e.currentTarget.style.background = active
390
+ ? "var(--active)"
391
+ : "var(--hover)")
392
+ }
393
+ onMouseLeave={(e) =>
394
+ (e.currentTarget.style.background = active
395
+ ? "var(--active)"
396
+ : "transparent")
397
+ }
398
+ >
399
+ {children}
400
+ </button>
401
+ );
402
+ }
403
+
404
+ function NumberInput({
405
+ value,
406
+ onChange,
407
+ width = 64,
408
+ suffix,
409
+ step = 1,
410
+ min,
411
+ max,
412
+ label,
413
+ }: {
414
+ value: number;
415
+ onChange: (v: number) => void;
416
+ width?: number;
417
+ suffix?: string;
418
+ step?: number;
419
+ min?: number;
420
+ max?: number;
421
+ label: string;
422
+ }) {
423
+ return (
424
+ <div
425
+ style={{
426
+ display: "flex",
427
+ alignItems: "center",
428
+ height: 28,
429
+ padding: "0 8px",
430
+ background: "var(--input-bg)",
431
+ borderRadius: 8,
432
+ width,
433
+ gap: 4,
434
+ }}
435
+ >
436
+ <input
437
+ type="number"
438
+ aria-label={label}
439
+ title={label}
440
+ step={step}
441
+ min={min}
442
+ max={max}
443
+ value={value}
444
+ onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
445
+ style={{
446
+ width: "100%",
447
+ background: "transparent",
448
+ border: "none",
449
+ fontSize: 13,
450
+ color: "var(--ink)",
451
+ fontFamily: "inherit",
452
+ }}
453
+ />
454
+ {suffix && (
455
+ <span style={{ fontSize: 11, color: "var(--ink-muted)" }}>{suffix}</span>
456
+ )}
457
+ </div>
458
+ );
459
+ }
460
+
461
+ function Select({
462
+ value,
463
+ onChange,
464
+ options,
465
+ width = 100,
466
+ label,
467
+ }: {
468
+ value: string;
469
+ onChange: (v: string) => void;
470
+ options: string[];
471
+ width?: number;
472
+ label: string;
473
+ }) {
474
+ return (
475
+ <div
476
+ style={{
477
+ position: "relative",
478
+ height: 28,
479
+ background: "var(--input-bg)",
480
+ borderRadius: 8,
481
+ width,
482
+ display: "flex",
483
+ alignItems: "center",
484
+ padding: "0 8px",
485
+ gap: 4,
486
+ }}
487
+ >
488
+ <select
489
+ aria-label={label}
490
+ title={label}
491
+ value={value}
492
+ onChange={(e) => onChange(e.target.value)}
493
+ style={{
494
+ appearance: "none",
495
+ width: "100%",
496
+ background: "transparent",
497
+ border: "none",
498
+ fontSize: 13,
499
+ color: "var(--ink)",
500
+ fontFamily: "inherit",
501
+ }}
502
+ >
503
+ {options.map((o) => (
504
+ <option key={o} value={o}>
505
+ {o}
506
+ </option>
507
+ ))}
508
+ </select>
509
+ <ChevronDown
510
+ size={12}
511
+ style={{ color: "var(--ink-muted)", pointerEvents: "none" }}
512
+ />
513
+ </div>
514
+ );
515
+ }
516
+
517
+ function ColorBtn({
518
+ value,
519
+ onChange,
520
+ label,
521
+ }: {
522
+ value: string;
523
+ onChange: (v: string) => void;
524
+ label: string;
525
+ }) {
526
+ const [open, setOpen] = useState(false);
527
+ return (
528
+ <div style={{ position: "relative" }}>
529
+ <button
530
+ title={label}
531
+ aria-label={label}
532
+ aria-haspopup="dialog"
533
+ aria-expanded={open}
534
+ onClick={() => setOpen((o) => !o)}
535
+ style={{
536
+ height: 28,
537
+ padding: "0 8px",
538
+ background: "transparent",
539
+ border: "none",
540
+ borderRadius: 8,
541
+ cursor: "pointer",
542
+ display: "flex",
543
+ alignItems: "center",
544
+ gap: 6,
545
+ color: "var(--ink)",
546
+ }}
547
+ onMouseEnter={(e) =>
548
+ (e.currentTarget.style.background = "var(--hover-strong)")
549
+ }
550
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
551
+ >
552
+ <div
553
+ style={{
554
+ width: 16,
555
+ height: 16,
556
+ borderRadius: 4,
557
+ background: value,
558
+ border: "1px solid var(--border-strong)",
559
+ }}
560
+ />
561
+ <span style={{ fontSize: 11, fontWeight: 700 }}>A</span>
562
+ <ChevronDown size={11} style={{ opacity: 0.5 }} />
563
+ </button>
564
+ {open && (
565
+ <>
566
+ <div
567
+ onClick={() => setOpen(false)}
568
+ style={{ position: "fixed", inset: 0, zIndex: 40 }}
569
+ />
570
+ <div
571
+ style={{
572
+ position: "absolute",
573
+ top: 32,
574
+ left: 0,
575
+ background: "var(--menu-bg)",
576
+ border: "1px solid var(--border-strong)",
577
+ borderRadius: 12,
578
+ padding: 10,
579
+ boxShadow: "var(--menu-shadow)",
580
+ zIndex: 41,
581
+ display: "grid",
582
+ gridTemplateColumns: "repeat(7, 22px)",
583
+ gap: 6,
584
+ }}
585
+ >
586
+ {COLORS.map((c) => (
587
+ <button
588
+ key={c}
589
+ aria-label={`Set color ${c}`}
590
+ aria-pressed={c === value}
591
+ title={c}
592
+ onClick={() => {
593
+ onChange(c);
594
+ setOpen(false);
595
+ }}
596
+ style={{
597
+ width: 22,
598
+ height: 22,
599
+ borderRadius: 6,
600
+ background: c,
601
+ border:
602
+ c === value
603
+ ? "2px solid var(--accent)"
604
+ : "1px solid var(--border-strong)",
605
+ cursor: "pointer",
606
+ padding: 0,
607
+ }}
608
+ />
609
+ ))}
610
+ <input
611
+ type="color"
612
+ aria-label="Pick custom color"
613
+ title="Pick custom color"
614
+ value={value}
615
+ onChange={(e) => onChange(e.target.value)}
616
+ style={{
617
+ gridColumn: "span 7",
618
+ marginTop: 4,
619
+ width: "100%",
620
+ height: 28,
621
+ border: "none",
622
+ background: "transparent",
623
+ cursor: "pointer",
624
+ }}
625
+ />
626
+ </div>
627
+ </>
628
+ )}
629
+ </div>
630
+ );
631
+ }
632
+
633
+ function Menu({
634
+ icon,
635
+ options,
636
+ label,
637
+ }: {
638
+ icon: React.ReactNode;
639
+ options: { label: string; icon?: React.ReactNode; onClick: () => void }[];
640
+ label: string;
641
+ }) {
642
+ const [open, setOpen] = useState(false);
643
+ return (
644
+ <div style={{ position: "relative" }}>
645
+ <button
646
+ title={label}
647
+ aria-label={label}
648
+ aria-haspopup="menu"
649
+ aria-expanded={open}
650
+ onClick={() => setOpen((o) => !o)}
651
+ style={{
652
+ height: 28,
653
+ padding: "0 8px",
654
+ background: "transparent",
655
+ border: "none",
656
+ borderRadius: 8,
657
+ cursor: "pointer",
658
+ display: "flex",
659
+ alignItems: "center",
660
+ gap: 2,
661
+ color: "var(--ink)",
662
+ }}
663
+ onMouseEnter={(e) =>
664
+ (e.currentTarget.style.background = "var(--hover-strong)")
665
+ }
666
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
667
+ >
668
+ {icon}
669
+ <ChevronDown size={11} style={{ opacity: 0.5, marginLeft: 2 }} />
670
+ </button>
671
+ {open && (
672
+ <>
673
+ <div
674
+ onClick={() => setOpen(false)}
675
+ style={{ position: "fixed", inset: 0, zIndex: 40 }}
676
+ />
677
+ <div
678
+ style={{
679
+ position: "absolute",
680
+ top: 32,
681
+ left: 0,
682
+ background: "var(--menu-bg)",
683
+ border: "1px solid var(--border-strong)",
684
+ borderRadius: 10,
685
+ padding: 6,
686
+ boxShadow: "var(--menu-shadow)",
687
+ zIndex: 41,
688
+ minWidth: 160,
689
+ color: "var(--ink)",
690
+ }}
691
+ >
692
+ {options.map((o) => (
693
+ <button
694
+ key={o.label}
695
+ onClick={() => {
696
+ o.onClick();
697
+ setOpen(false);
698
+ }}
699
+ style={{
700
+ width: "100%",
701
+ display: "flex",
702
+ alignItems: "center",
703
+ gap: 8,
704
+ padding: "6px 8px",
705
+ border: "none",
706
+ background: "transparent",
707
+ cursor: "pointer",
708
+ borderRadius: 6,
709
+ fontSize: 13,
710
+ color: "var(--ink)",
711
+ textAlign: "left",
712
+ }}
713
+ onMouseEnter={(e) =>
714
+ (e.currentTarget.style.background = "var(--hover)")
715
+ }
716
+ onMouseLeave={(e) =>
717
+ (e.currentTarget.style.background = "transparent")
718
+ }
719
+ >
720
+ {o.icon}
721
+ {o.label}
722
+ </button>
723
+ ))}
724
+ </div>
725
+ </>
726
+ )}
727
+ </div>
728
+ );
729
+ }