@agent-native/core 0.137.1 → 0.137.2

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 (34) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/slides/app/components/editor/AddSlidePopover.tsx +258 -0
  3. package/corpus/templates/slides/app/components/editor/EditorActionCluster.tsx +130 -0
  4. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +25 -337
  5. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +73 -53
  6. package/corpus/templates/slides/app/components/editor/SlideContextToolbar.tsx +804 -0
  7. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +101 -45
  8. package/corpus/templates/slides/app/components/editor/SlideOverflowWarning.tsx +7 -4
  9. package/corpus/templates/slides/app/components/editor/bullet-editing.ts +11 -3
  10. package/corpus/templates/slides/app/components/editor/commit-active-edit.ts +21 -0
  11. package/corpus/templates/slides/app/components/editor/list-editing.ts +219 -0
  12. package/corpus/templates/slides/app/components/editor/selection-overlay-measurement.ts +0 -3
  13. package/corpus/templates/slides/app/components/editor/slide-style.ts +199 -0
  14. package/corpus/templates/slides/app/global.css +14 -3
  15. package/corpus/templates/slides/app/i18n/en-US.ts +8 -1
  16. package/corpus/templates/slides/app/pages/DeckEditor.tsx +48 -22
  17. package/corpus/templates/slides/changelog/2026-08-01-add-slide-moved-to-the-toolbar-and-the-slide-rail-is-now-mor.md +9 -0
  18. package/corpus/templates/slides/changelog/2026-08-01-add-slide-undo-redo-and-the-text-tool-now-lead-the-slide-too.md +9 -0
  19. package/corpus/templates/slides/changelog/2026-08-01-slide-styling-now-appears-in-a-contextual-toolbar-above-the-.md +6 -0
  20. package/corpus/templates/slides/changelog/2026-08-01-the-slide-style-side-panel-is-retired-all-styling-now-lives-.md +6 -0
  21. package/corpus/templates/slides/changelog/2026-08-03-the-slide-toolbar-is-denser-swatch-only-colors-dropdowns-for.md +10 -0
  22. package/corpus/templates/slides/changelog/2026-08-04-bullet-and-numbered-list-buttons-in-the-slide-toolbar-conver.md +6 -0
  23. package/corpus/templates/slides/changelog/2026-08-04-italic-and-underline-are-now-one-click-away-in-the-slide-too.md +6 -0
  24. package/dist/collab/awareness.d.ts +2 -2
  25. package/dist/collab/struct-routes.d.ts +1 -1
  26. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  27. package/dist/observability/routes.d.ts +3 -3
  28. package/dist/progress/routes.d.ts +1 -1
  29. package/dist/resources/handlers.d.ts +1 -1
  30. package/dist/secrets/routes.d.ts +3 -3
  31. package/dist/server/realtime-token.d.ts +1 -1
  32. package/dist/server/transcribe-voice.d.ts +1 -1
  33. package/package.json +2 -2
  34. package/corpus/templates/slides/app/components/editor/SlideStyleInspector.tsx +0 -763
@@ -0,0 +1,804 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import {
3
+ VisualColorPicker,
4
+ VisualControlRow,
5
+ VisualScrubInput,
6
+ VisualSegmentedControl,
7
+ } from "@agent-native/toolkit/design-tweaks";
8
+ import type { DesignSystemData } from "@shared/api";
9
+ import {
10
+ IconAlignCenter,
11
+ IconAlignJustified,
12
+ IconAlignLeft,
13
+ IconAlignRight,
14
+ IconAngle,
15
+ IconArrowAutofitHeight,
16
+ IconArrowAutofitWidth,
17
+ IconBorderRadius,
18
+ IconBorderStyle,
19
+ IconBoxPadding,
20
+ IconDots,
21
+ IconGridDots,
22
+ IconItalic,
23
+ IconLayoutAlignLeft,
24
+ IconLetterCase,
25
+ IconList,
26
+ IconListNumbers,
27
+ IconSpacingHorizontal,
28
+ IconSpacingVertical,
29
+ IconStackBack,
30
+ IconStackFront,
31
+ IconUnderline,
32
+ } from "@tabler/icons-react";
33
+ import type { ReactNode } from "react";
34
+
35
+ import { Button } from "@/components/ui/button";
36
+ import {
37
+ DropdownMenu,
38
+ DropdownMenuContent,
39
+ DropdownMenuItem,
40
+ DropdownMenuTrigger,
41
+ } from "@/components/ui/dropdown-menu";
42
+ import {
43
+ Popover,
44
+ PopoverContent,
45
+ PopoverTrigger,
46
+ } from "@/components/ui/popover";
47
+ import {
48
+ Tooltip,
49
+ TooltipContent,
50
+ TooltipTrigger,
51
+ } from "@/components/ui/tooltip";
52
+ import { cn, shortcutLabel } from "@/lib/utils";
53
+
54
+ import type { SlideListKind } from "./list-editing";
55
+ import {
56
+ backgroundCssValue,
57
+ formatValue,
58
+ horizontalAlignPatch,
59
+ resolveHorizontalAlignment,
60
+ resolveVerticalAlignment,
61
+ rotationTransform,
62
+ tokenPalette,
63
+ verticalAlignPatch,
64
+ type SlideStylePatch,
65
+ type SlideStyleSnapshot,
66
+ } from "./slide-style";
67
+
68
+ const TOOLBAR_DIVIDER = "mx-1 h-4 w-px shrink-0 bg-border";
69
+ const SCRUB_CLASS = "w-24 shrink-0";
70
+ const SIZE_SCRUB_CLASS = "w-28 shrink-0 gap-0.5";
71
+ const MENU_BUTTON_CLASS =
72
+ "size-7 shrink-0 cursor-pointer text-muted-foreground hover:text-foreground";
73
+ const TOGGLE_ACTIVE_CLASS = "bg-accent text-foreground";
74
+ const MENU_TRIGGER_BASE =
75
+ "flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
76
+ const VALUE_MENU_CLASS = `${MENU_TRIGGER_BASE} min-w-14`;
77
+ const ICON_MENU_CLASS = `${MENU_TRIGGER_BASE} text-muted-foreground hover:text-foreground`;
78
+ const CARET_CLASS =
79
+ "size-0 shrink-0 border-x-[4px] border-t-[5px] border-x-transparent border-t-muted-foreground/70";
80
+
81
+ type Translate = ReturnType<typeof useT>;
82
+
83
+ function fontWeightOptions(t: Translate) {
84
+ return [
85
+ { label: t("styleInspector.regular"), value: "400" },
86
+ { label: t("styleInspector.medium"), value: "500" },
87
+ { label: t("styleInspector.semi"), value: "600" },
88
+ { label: t("styleInspector.bold"), value: "700" },
89
+ ];
90
+ }
91
+
92
+ function weightLabel(fontWeight: string, t: Translate) {
93
+ const match = fontWeightOptions(t).find(
94
+ (option) => option.value === fontWeight,
95
+ );
96
+ return match ? match.label : fontWeight;
97
+ }
98
+
99
+ function textAlignOptions(t: Translate) {
100
+ return [
101
+ { label: t("styleInspector.left"), value: "left", icon: IconAlignLeft },
102
+ {
103
+ label: t("styleInspector.center"),
104
+ value: "center",
105
+ icon: IconAlignCenter,
106
+ },
107
+ { label: t("styleInspector.right"), value: "right", icon: IconAlignRight },
108
+ {
109
+ label: t("styleInspector.justify"),
110
+ value: "justify",
111
+ icon: IconAlignJustified,
112
+ },
113
+ ];
114
+ }
115
+
116
+ function alignIcon(textAlign: string) {
117
+ if (textAlign === "center") return IconAlignCenter;
118
+ if (textAlign === "right") return IconAlignRight;
119
+ if (textAlign === "justify") return IconAlignJustified;
120
+ return IconAlignLeft;
121
+ }
122
+
123
+ /**
124
+ * Horizontal counterpart to the style dock: the same snapshot and patch
125
+ * callback, presented as a row above the canvas so the slide keeps full width.
126
+ * Controls past the first few live in grouped popovers — a flat row overflows
127
+ * once the agent sidebar and slide rail take their share of the width.
128
+ */
129
+ export function SlideContextToolbar({
130
+ snapshot,
131
+ background,
132
+ designSystem,
133
+ className,
134
+ leading,
135
+ onChange,
136
+ onBackgroundChange,
137
+ onArrange,
138
+ onToggleList,
139
+ }: {
140
+ snapshot: SlideStyleSnapshot | null;
141
+ background: string | undefined;
142
+ designSystem?: DesignSystemData;
143
+ className?: string;
144
+ /** Selection-independent actions pinned to the head of the row. */
145
+ leading?: ReactNode;
146
+ onChange: (patch: SlideStylePatch) => void;
147
+ onBackgroundChange: (background: string) => void;
148
+ onArrange?: (target: "front" | "back") => void;
149
+ onToggleList?: (kind: SlideListKind) => void;
150
+ }) {
151
+ const t = useT();
152
+ const documentColors = tokenPalette(designSystem, t).map(
153
+ (option) => option.value,
154
+ );
155
+ const inlineEditSurfaceProps = {
156
+ "data-slide-inline-edit-surface": "true",
157
+ };
158
+ const mixedTextStyles = snapshot?.mixedTextStyles ?? [];
159
+ // A mixed selection has no single state to reflect, so the toggle reads as
160
+ // off and one click makes the whole selection consistent.
161
+ const isItalic =
162
+ !mixedTextStyles.includes("fontStyle") &&
163
+ (snapshot?.fontStyle ?? "").startsWith("italic");
164
+ // A mixed selection has no single size, so the scrub input reports a step as
165
+ // a relative delta rather than a value. Writing that delta as an absolute
166
+ // size would set the whole selection to a few pixels; step from the block's
167
+ // own size instead, which also makes the selection consistent in one click.
168
+ const sizeFor = (value: number, meta?: { relativeDelta?: number }) => {
169
+ const delta = meta?.relativeDelta;
170
+ if (typeof delta !== "number") return value;
171
+ return Math.min(160, Math.max(8, (snapshot?.fontSize ?? 0) + delta));
172
+ };
173
+ const decorationMixed = mixedTextStyles.includes("textDecoration");
174
+ const isUnderline =
175
+ !decorationMixed && (snapshot?.textDecoration ?? "").includes("underline");
176
+ // Text can carry more than one decoration, and the agent writes
177
+ // line-through even though no control exposes it. Editing the underline
178
+ // token in place keeps the rest; writing a bare "none" would erase them.
179
+ const underlinePatch = () => {
180
+ if (decorationMixed) return "underline";
181
+ const tokens = (snapshot?.textDecoration ?? "")
182
+ .split(/\s+/)
183
+ .filter((token) => token && token !== "none");
184
+ const next = isUnderline
185
+ ? tokens.filter((token) => token !== "underline")
186
+ : [...tokens, "underline"];
187
+ return next.length > 0 ? next.join(" ") : "none";
188
+ };
189
+ // Null means the slide uses a background this picker cannot represent (named
190
+ // utility, gradient); surface that as Mixed rather than guessing a hex.
191
+ const slideBackground = backgroundCssValue(background);
192
+
193
+ return (
194
+ <div
195
+ className={cn(
196
+ "slide-context-toolbar flex h-10 shrink-0 items-center gap-1 overflow-x-auto whitespace-nowrap border-b border-border/70 bg-muted/60 px-2 sm:px-3",
197
+ className,
198
+ )}
199
+ data-slide-context-toolbar="true"
200
+ role="toolbar"
201
+ aria-label={t("styleInspector.title")}
202
+ >
203
+ {leading && (
204
+ <>
205
+ {leading}
206
+ <div className={TOOLBAR_DIVIDER} />
207
+ </>
208
+ )}
209
+ {!snapshot ? (
210
+ <VisualColorPicker
211
+ label={t("styleInspector.slideBackground")}
212
+ value={slideBackground ?? ""}
213
+ mixed={slideBackground === null}
214
+ mixedLabel={t("styleInspector.mixed")}
215
+ documentColors={documentColors}
216
+ variant="swatch"
217
+ contentProps={inlineEditSurfaceProps}
218
+ onChange={onBackgroundChange}
219
+ />
220
+ ) : (
221
+ <>
222
+ {snapshot.isText ? (
223
+ <>
224
+ <VisualScrubInput
225
+ label={t("styleInspector.size")}
226
+ icon={IconLetterCase}
227
+ prefix="icon"
228
+ steppers
229
+ decrementLabel={t("styleInspector.decreaseSize")}
230
+ incrementLabel={t("styleInspector.increaseSize")}
231
+ value={snapshot.fontSize}
232
+ min={8}
233
+ max={160}
234
+ unit="px"
235
+ mixed={mixedTextStyles.includes("fontSize")}
236
+ mixedLabel={t("styleInspector.mixed")}
237
+ className={SIZE_SCRUB_CLASS}
238
+ onChange={(fontSize, meta) =>
239
+ onChange({
240
+ fontSize: `${formatValue(sizeFor(fontSize, meta))}px`,
241
+ })
242
+ }
243
+ />
244
+ <div className={TOOLBAR_DIVIDER} />
245
+ <DropdownMenu>
246
+ <Tooltip>
247
+ <TooltipTrigger asChild>
248
+ <DropdownMenuTrigger asChild>
249
+ <button
250
+ type="button"
251
+ className={VALUE_MENU_CLASS}
252
+ aria-label={t("styleInspector.weight")}
253
+ >
254
+ <span className="truncate">
255
+ {mixedTextStyles.includes("fontWeight")
256
+ ? t("styleInspector.mixed")
257
+ : weightLabel(snapshot.fontWeight, t)}
258
+ </span>
259
+ <span aria-hidden="true" className={CARET_CLASS} />
260
+ </button>
261
+ </DropdownMenuTrigger>
262
+ </TooltipTrigger>
263
+ <TooltipContent>{t("styleInspector.weight")}</TooltipContent>
264
+ </Tooltip>
265
+ <DropdownMenuContent align="start" className="w-36">
266
+ {fontWeightOptions(t).map((option) => (
267
+ <DropdownMenuItem
268
+ key={option.value}
269
+ onSelect={() => onChange({ fontWeight: option.value })}
270
+ >
271
+ <span style={{ fontWeight: Number(option.value) }}>
272
+ {option.label}
273
+ </span>
274
+ </DropdownMenuItem>
275
+ ))}
276
+ </DropdownMenuContent>
277
+ </DropdownMenu>
278
+
279
+ <Tooltip>
280
+ <TooltipTrigger asChild>
281
+ <Button
282
+ type="button"
283
+ variant="ghost"
284
+ size="icon"
285
+ className={cn(
286
+ MENU_BUTTON_CLASS,
287
+ isItalic && TOGGLE_ACTIVE_CLASS,
288
+ )}
289
+ aria-label={t("styleInspector.italic")}
290
+ aria-pressed={isItalic}
291
+ onClick={() =>
292
+ onChange({ fontStyle: isItalic ? "normal" : "italic" })
293
+ }
294
+ >
295
+ <IconItalic className="size-4" />
296
+ </Button>
297
+ </TooltipTrigger>
298
+ <TooltipContent>
299
+ {`${t("styleInspector.italic")} (${shortcutLabel("cmd+i")})`}
300
+ </TooltipContent>
301
+ </Tooltip>
302
+
303
+ <Tooltip>
304
+ <TooltipTrigger asChild>
305
+ <Button
306
+ type="button"
307
+ variant="ghost"
308
+ size="icon"
309
+ className={cn(
310
+ MENU_BUTTON_CLASS,
311
+ isUnderline && TOGGLE_ACTIVE_CLASS,
312
+ )}
313
+ aria-label={t("styleInspector.underline")}
314
+ aria-pressed={isUnderline}
315
+ onClick={() =>
316
+ onChange({ textDecoration: underlinePatch() })
317
+ }
318
+ >
319
+ <IconUnderline className="size-4" />
320
+ </Button>
321
+ </TooltipTrigger>
322
+ <TooltipContent>
323
+ {`${t("styleInspector.underline")} (${shortcutLabel("cmd+u")})`}
324
+ </TooltipContent>
325
+ </Tooltip>
326
+
327
+ <VisualColorPicker
328
+ label={t("styleInspector.textColor")}
329
+ value={snapshot.color}
330
+ documentColors={documentColors}
331
+ mixed={mixedTextStyles.includes("color")}
332
+ mixedLabel={t("styleInspector.mixed")}
333
+ variant="swatch"
334
+ glyph="A"
335
+ contentProps={inlineEditSurfaceProps}
336
+ onChange={(value) => onChange({ color: value })}
337
+ />
338
+
339
+ <div className={TOOLBAR_DIVIDER} />
340
+
341
+ <DropdownMenu>
342
+ <Tooltip>
343
+ <TooltipTrigger asChild>
344
+ <DropdownMenuTrigger asChild>
345
+ <button
346
+ type="button"
347
+ className={ICON_MENU_CLASS}
348
+ aria-label={t("styleInspector.align")}
349
+ >
350
+ {(() => {
351
+ const Icon = alignIcon(snapshot.textAlign);
352
+ return <Icon className="size-4" />;
353
+ })()}
354
+ <span aria-hidden="true" className={CARET_CLASS} />
355
+ </button>
356
+ </DropdownMenuTrigger>
357
+ </TooltipTrigger>
358
+ <TooltipContent>{t("styleInspector.align")}</TooltipContent>
359
+ </Tooltip>
360
+ <DropdownMenuContent align="start" className="w-36">
361
+ {textAlignOptions(t).map((option) => (
362
+ <DropdownMenuItem
363
+ key={option.value}
364
+ onSelect={() => onChange({ textAlign: option.value })}
365
+ >
366
+ <option.icon className="size-4" />
367
+ {option.label}
368
+ </DropdownMenuItem>
369
+ ))}
370
+ </DropdownMenuContent>
371
+ </DropdownMenu>
372
+
373
+ {onToggleList && (
374
+ <>
375
+ <Tooltip>
376
+ <TooltipTrigger asChild>
377
+ <Button
378
+ type="button"
379
+ variant="ghost"
380
+ size="icon"
381
+ className={cn(
382
+ MENU_BUTTON_CLASS,
383
+ snapshot.listKind === "bullet" && TOGGLE_ACTIVE_CLASS,
384
+ )}
385
+ aria-label={t("styleInspector.bulletList")}
386
+ aria-pressed={snapshot.listKind === "bullet"}
387
+ onClick={() => onToggleList("bullet")}
388
+ >
389
+ <IconList className="size-4" />
390
+ </Button>
391
+ </TooltipTrigger>
392
+ <TooltipContent>
393
+ {t("styleInspector.bulletList")}
394
+ </TooltipContent>
395
+ </Tooltip>
396
+
397
+ <Tooltip>
398
+ <TooltipTrigger asChild>
399
+ <Button
400
+ type="button"
401
+ variant="ghost"
402
+ size="icon"
403
+ className={cn(
404
+ MENU_BUTTON_CLASS,
405
+ snapshot.listKind === "ordered" &&
406
+ TOGGLE_ACTIVE_CLASS,
407
+ )}
408
+ aria-label={t("styleInspector.numberedList")}
409
+ aria-pressed={snapshot.listKind === "ordered"}
410
+ onClick={() => onToggleList("ordered")}
411
+ >
412
+ <IconListNumbers className="size-4" />
413
+ </Button>
414
+ </TooltipTrigger>
415
+ <TooltipContent>
416
+ {t("styleInspector.numberedList")}
417
+ </TooltipContent>
418
+ </Tooltip>
419
+ </>
420
+ )}
421
+ </>
422
+ ) : (
423
+ <>
424
+ <VisualColorPicker
425
+ label={
426
+ snapshot.isImage
427
+ ? t("styleInspector.tint")
428
+ : t("styleInspector.fill")
429
+ }
430
+ value={snapshot.backgroundColor}
431
+ documentColors={documentColors}
432
+ allowTransparent
433
+ variant="swatch"
434
+ contentProps={inlineEditSurfaceProps}
435
+ onChange={(value) => onChange({ backgroundColor: value })}
436
+ />
437
+ <VisualScrubInput
438
+ label={t("styleInspector.opacity")}
439
+ icon={IconGridDots}
440
+ prefix="icon"
441
+ value={snapshot.opacity}
442
+ min={0}
443
+ max={100}
444
+ step={5}
445
+ unit="%"
446
+ className={SCRUB_CLASS}
447
+ onChange={(opacity) =>
448
+ onChange({ opacity: String(opacity / 100) })
449
+ }
450
+ />
451
+ <VisualScrubInput
452
+ label={t("styleInspector.cornerRadius")}
453
+ icon={IconBorderRadius}
454
+ prefix="icon"
455
+ value={snapshot.borderRadius}
456
+ min={0}
457
+ max={96}
458
+ unit="px"
459
+ className={SCRUB_CLASS}
460
+ onChange={(radius) =>
461
+ onChange({ borderRadius: `${formatValue(radius)}px` })
462
+ }
463
+ />
464
+
465
+ <div className={TOOLBAR_DIVIDER} />
466
+ <VisualScrubInput
467
+ label={t("styleInspector.strokeWeight")}
468
+ icon={IconBorderStyle}
469
+ prefix="icon"
470
+ value={snapshot.borderWidth}
471
+ min={0}
472
+ max={16}
473
+ unit="px"
474
+ className={SCRUB_CLASS}
475
+ onChange={(width) =>
476
+ onChange({ borderWidth: `${formatValue(width)}px` })
477
+ }
478
+ />
479
+ <VisualColorPicker
480
+ label={t("styleInspector.strokeColor")}
481
+ value={snapshot.borderColor}
482
+ documentColors={documentColors}
483
+ variant="swatch"
484
+ contentProps={inlineEditSurfaceProps}
485
+ onChange={(value) => onChange({ borderColor: value })}
486
+ />
487
+ </>
488
+ )}
489
+
490
+ <div className={TOOLBAR_DIVIDER} />
491
+
492
+ {snapshot.isAbsolute && (
493
+ <Popover>
494
+ <Tooltip>
495
+ <TooltipTrigger asChild>
496
+ <PopoverTrigger asChild>
497
+ <Button
498
+ type="button"
499
+ variant="ghost"
500
+ size="icon"
501
+ className={MENU_BUTTON_CLASS}
502
+ aria-label={t("styleInspector.position")}
503
+ >
504
+ <IconLayoutAlignLeft className="size-3.5" />
505
+ </Button>
506
+ </PopoverTrigger>
507
+ </TooltipTrigger>
508
+ <TooltipContent>{t("styleInspector.position")}</TooltipContent>
509
+ </Tooltip>
510
+ <PopoverContent
511
+ align="start"
512
+ className="w-60 space-y-2 p-2"
513
+ {...inlineEditSurfaceProps}
514
+ >
515
+ {snapshot.isAbsolute && (
516
+ <>
517
+ <VisualControlRow label={t("styleInspector.horizontal")}>
518
+ <VisualSegmentedControl
519
+ value={resolveHorizontalAlignment(snapshot)}
520
+ onChange={(alignment) =>
521
+ onChange(horizontalAlignPatch(snapshot, alignment))
522
+ }
523
+ className="slides-inspector-segment"
524
+ options={[
525
+ { label: t("styleInspector.left"), value: "left" },
526
+ {
527
+ label: t("styleInspector.center"),
528
+ value: "center",
529
+ },
530
+ { label: t("styleInspector.right"), value: "right" },
531
+ ]}
532
+ />
533
+ </VisualControlRow>
534
+ <VisualControlRow label={t("styleInspector.vertical")}>
535
+ <VisualSegmentedControl
536
+ value={resolveVerticalAlignment(snapshot)}
537
+ onChange={(alignment) =>
538
+ onChange(verticalAlignPatch(snapshot, alignment))
539
+ }
540
+ className="slides-inspector-segment"
541
+ options={[
542
+ { label: t("styleInspector.top"), value: "top" },
543
+ {
544
+ label: t("styleInspector.middle"),
545
+ value: "middle",
546
+ },
547
+ {
548
+ label: t("styleInspector.bottom"),
549
+ value: "bottom",
550
+ },
551
+ ]}
552
+ />
553
+ </VisualControlRow>
554
+ </>
555
+ )}
556
+ </PopoverContent>
557
+ </Popover>
558
+ )}
559
+
560
+ {snapshot.isAbsolute && onArrange && (
561
+ <>
562
+ <Tooltip>
563
+ <TooltipTrigger asChild>
564
+ <Button
565
+ type="button"
566
+ variant="ghost"
567
+ size="icon"
568
+ className={MENU_BUTTON_CLASS}
569
+ onClick={() => onArrange("back")}
570
+ aria-label={t("styleInspector.sendToBack")}
571
+ >
572
+ <IconStackBack className="size-3.5" />
573
+ </Button>
574
+ </TooltipTrigger>
575
+ <TooltipContent>
576
+ {t("styleInspector.sendToBack")}
577
+ </TooltipContent>
578
+ </Tooltip>
579
+ <Tooltip>
580
+ <TooltipTrigger asChild>
581
+ <Button
582
+ type="button"
583
+ variant="ghost"
584
+ size="icon"
585
+ className={MENU_BUTTON_CLASS}
586
+ onClick={() => onArrange("front")}
587
+ aria-label={t("styleInspector.bringToFront")}
588
+ >
589
+ <IconStackFront className="size-3.5" />
590
+ </Button>
591
+ </TooltipTrigger>
592
+ <TooltipContent>
593
+ {t("styleInspector.bringToFront")}
594
+ </TooltipContent>
595
+ </Tooltip>
596
+ </>
597
+ )}
598
+
599
+ <Popover>
600
+ <Tooltip>
601
+ <TooltipTrigger asChild>
602
+ <PopoverTrigger asChild>
603
+ <Button
604
+ type="button"
605
+ variant="ghost"
606
+ size="icon"
607
+ className={MENU_BUTTON_CLASS}
608
+ aria-label={t("styleInspector.controls")}
609
+ >
610
+ <IconDots className="size-3.5" />
611
+ </Button>
612
+ </PopoverTrigger>
613
+ </TooltipTrigger>
614
+ <TooltipContent>{t("styleInspector.controls")}</TooltipContent>
615
+ </Tooltip>
616
+ <PopoverContent
617
+ align="end"
618
+ className="w-64 space-y-3 p-2"
619
+ {...inlineEditSurfaceProps}
620
+ >
621
+ <div className="grid grid-cols-2 gap-2">
622
+ <VisualScrubInput
623
+ label={t("styleInspector.width")}
624
+ icon={IconArrowAutofitWidth}
625
+ prefix="icon"
626
+ value={snapshot.width}
627
+ min={0}
628
+ unit="px"
629
+ onChange={(width) =>
630
+ onChange({ width: `${formatValue(width)}px` })
631
+ }
632
+ />
633
+ <VisualScrubInput
634
+ label={t("styleInspector.height")}
635
+ icon={IconArrowAutofitHeight}
636
+ prefix="icon"
637
+ value={snapshot.height}
638
+ min={0}
639
+ unit="px"
640
+ onChange={(height) =>
641
+ onChange({ height: `${formatValue(height)}px` })
642
+ }
643
+ />
644
+ </div>
645
+
646
+ {snapshot.isAbsolute && (
647
+ <>
648
+ <div className="grid grid-cols-2 gap-2">
649
+ <VisualScrubInput
650
+ label={t("styleInspector.x")}
651
+ icon={null}
652
+ labelClassName="w-8 justify-center"
653
+ value={snapshot.x}
654
+ unit="px"
655
+ onChange={(x) =>
656
+ onChange({ left: `${formatValue(x)}px` })
657
+ }
658
+ />
659
+ <VisualScrubInput
660
+ label={t("styleInspector.y")}
661
+ icon={null}
662
+ labelClassName="w-8 justify-center"
663
+ value={snapshot.y}
664
+ unit="px"
665
+ onChange={(y) => onChange({ top: `${formatValue(y)}px` })}
666
+ />
667
+ </div>
668
+ <VisualScrubInput
669
+ label={t("styleInspector.rotation")}
670
+ icon={IconAngle}
671
+ prefix="icon"
672
+ value={snapshot.rotation}
673
+ min={-360}
674
+ max={360}
675
+ unit="°"
676
+ onChange={(rotation) =>
677
+ onChange({ transform: rotationTransform(rotation) })
678
+ }
679
+ />
680
+ </>
681
+ )}
682
+
683
+ {snapshot.isText && (
684
+ <>
685
+ <div className="grid grid-cols-2 gap-2">
686
+ <VisualScrubInput
687
+ label={t("styleInspector.opacity")}
688
+ icon={IconGridDots}
689
+ prefix="icon"
690
+ value={snapshot.opacity}
691
+ min={0}
692
+ max={100}
693
+ step={5}
694
+ unit="%"
695
+ onChange={(opacity) =>
696
+ onChange({ opacity: String(opacity / 100) })
697
+ }
698
+ />
699
+ <VisualScrubInput
700
+ label={t("styleInspector.cornerRadius")}
701
+ icon={IconBorderRadius}
702
+ prefix="icon"
703
+ value={snapshot.borderRadius}
704
+ min={0}
705
+ max={96}
706
+ unit="px"
707
+ onChange={(radius) =>
708
+ onChange({ borderRadius: `${formatValue(radius)}px` })
709
+ }
710
+ />
711
+ </div>
712
+ <VisualScrubInput
713
+ label={t("styleInspector.strokeWeight")}
714
+ icon={IconBorderStyle}
715
+ prefix="icon"
716
+ value={snapshot.borderWidth}
717
+ min={0}
718
+ max={16}
719
+ unit="px"
720
+ onChange={(width) =>
721
+ onChange({ borderWidth: `${formatValue(width)}px` })
722
+ }
723
+ />
724
+ <VisualControlRow label={t("styleInspector.strokeColor")}>
725
+ <VisualColorPicker
726
+ label={t("styleInspector.strokeColor")}
727
+ value={snapshot.borderColor}
728
+ documentColors={documentColors}
729
+ variant="filled"
730
+ className="rounded-sm"
731
+ contentProps={inlineEditSurfaceProps}
732
+ onChange={(value) => onChange({ borderColor: value })}
733
+ />
734
+ </VisualControlRow>
735
+ <VisualScrubInput
736
+ label={t("styleInspector.line")}
737
+ icon={IconArrowAutofitHeight}
738
+ prefix="icon"
739
+ value={snapshot.lineHeight}
740
+ min={0.8}
741
+ max={3}
742
+ step={0.05}
743
+ onChange={(lineHeight) =>
744
+ onChange({ lineHeight: formatValue(lineHeight) })
745
+ }
746
+ />
747
+ <VisualControlRow label={t("styleInspector.fill")}>
748
+ <VisualColorPicker
749
+ label={t("styleInspector.fill")}
750
+ value={snapshot.backgroundColor}
751
+ documentColors={documentColors}
752
+ allowTransparent
753
+ variant="filled"
754
+ className="rounded-sm"
755
+ contentProps={inlineEditSurfaceProps}
756
+ onChange={(value) => onChange({ backgroundColor: value })}
757
+ />
758
+ </VisualControlRow>
759
+ </>
760
+ )}
761
+
762
+ {!snapshot.isImage && (
763
+ <div className="grid grid-cols-2 gap-2">
764
+ <VisualScrubInput
765
+ label={t("styleInspector.horizontal")}
766
+ icon={IconSpacingHorizontal}
767
+ prefix="icon"
768
+ value={snapshot.paddingX}
769
+ min={0}
770
+ max={120}
771
+ step={2}
772
+ unit="px"
773
+ onChange={(padding) =>
774
+ onChange({
775
+ paddingLeft: `${formatValue(padding)}px`,
776
+ paddingRight: `${formatValue(padding)}px`,
777
+ })
778
+ }
779
+ />
780
+ <VisualScrubInput
781
+ label={t("styleInspector.vertical")}
782
+ icon={IconSpacingVertical}
783
+ prefix="icon"
784
+ value={snapshot.paddingY}
785
+ min={0}
786
+ max={120}
787
+ step={2}
788
+ unit="px"
789
+ onChange={(padding) =>
790
+ onChange({
791
+ paddingTop: `${formatValue(padding)}px`,
792
+ paddingBottom: `${formatValue(padding)}px`,
793
+ })
794
+ }
795
+ />
796
+ </div>
797
+ )}
798
+ </PopoverContent>
799
+ </Popover>
800
+ </>
801
+ )}
802
+ </div>
803
+ );
804
+ }