@bycrux/editor 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/carousel/AddElementMenu.tsx +5 -5
- package/src/carousel/CarouselEditor.tsx +47 -36
- package/src/carousel/SlidePropertyPanel.tsx +117 -78
- package/src/text/FontPicker.tsx +2 -2
- package/src/text/TextFormattingToolbar.tsx +96 -95
- package/src/ui/index.ts +1 -1
- package/src/ui/input.tsx +6 -0
package/package.json
CHANGED
|
@@ -122,14 +122,14 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
|
|
|
122
122
|
|
|
123
123
|
return (
|
|
124
124
|
<div className="flex flex-col gap-2">
|
|
125
|
-
<div className="
|
|
125
|
+
<div className="grid grid-cols-2 gap-2">
|
|
126
126
|
{adapter.generateImage && (
|
|
127
127
|
<Button
|
|
128
128
|
size="sm"
|
|
129
129
|
variant="outline"
|
|
130
130
|
disabled={disabled}
|
|
131
131
|
onClick={() => { setShowPrompt(p => !p); setGenError(null) }}
|
|
132
|
-
className="text-xs"
|
|
132
|
+
className="text-xs w-full justify-center whitespace-nowrap"
|
|
133
133
|
>
|
|
134
134
|
+ AI Image
|
|
135
135
|
</Button>
|
|
@@ -139,7 +139,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
|
|
|
139
139
|
variant="outline"
|
|
140
140
|
disabled={disabled}
|
|
141
141
|
onClick={() => fileInputRef.current?.click()}
|
|
142
|
-
className="text-xs"
|
|
142
|
+
className="text-xs w-full justify-center whitespace-nowrap"
|
|
143
143
|
>
|
|
144
144
|
+ Upload Image
|
|
145
145
|
</Button>
|
|
@@ -156,7 +156,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
|
|
|
156
156
|
variant="outline"
|
|
157
157
|
disabled={disabled || addingText}
|
|
158
158
|
onClick={handleAddText}
|
|
159
|
-
className="text-xs"
|
|
159
|
+
className="text-xs w-full justify-center whitespace-nowrap"
|
|
160
160
|
>
|
|
161
161
|
{addingText ? 'Adding…' : '+ Text'}
|
|
162
162
|
</Button>
|
|
@@ -166,7 +166,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
|
|
|
166
166
|
variant="outline"
|
|
167
167
|
disabled={disabled}
|
|
168
168
|
onClick={() => setShowOverlayPicker(true)}
|
|
169
|
-
className="text-xs"
|
|
169
|
+
className="text-xs w-full justify-center whitespace-nowrap"
|
|
170
170
|
>
|
|
171
171
|
+ Overlay
|
|
172
172
|
</Button>
|
|
@@ -70,7 +70,7 @@ function SlideGrid({
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
return (
|
|
73
|
-
<div className="w-56 flex-shrink-0 flex flex-col border-r border-[var(--editor-border)] bg-[var(--editor-bg)] overflow-y-auto">
|
|
73
|
+
<div className="w-56 flex-shrink-0 flex flex-col border-r border-[var(--editor-border)] bg-[var(--editor-bg)] overflow-y-auto min-h-0 h-full">
|
|
74
74
|
<div className="px-3 py-2 border-b border-[var(--editor-border)]">
|
|
75
75
|
<span className="text-xs font-semibold text-[var(--editor-text)]/60 uppercase tracking-wider">Slides</span>
|
|
76
76
|
</div>
|
|
@@ -365,10 +365,11 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
365
365
|
|
|
366
366
|
return (
|
|
367
367
|
<div ref={containerRef} className="flex flex-col h-full overflow-y-auto bg-[var(--editor-bg)]">
|
|
368
|
-
{/* TOP: slide rail | canvas | editing panel (right).
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
368
|
+
{/* TOP: slide rail | canvas | editing panel (right). Fixed viewport-relative
|
|
369
|
+
height with min-h-0 so each of the three columns establishes its own
|
|
370
|
+
independent scroll context; the project-media region flows beneath and
|
|
371
|
+
the whole editor scrolls vertically. */}
|
|
372
|
+
<div className="flex flex-shrink-0 h-[78vh] min-h-0 overflow-hidden">
|
|
372
373
|
<SlideGrid
|
|
373
374
|
project={project}
|
|
374
375
|
slides={slides}
|
|
@@ -382,40 +383,49 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
382
383
|
compileOverlay={(t) => adapter.compileOverlay(t)}
|
|
383
384
|
/>
|
|
384
385
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
|
|
392
|
-
: 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
|
|
393
|
-
}`}
|
|
394
|
-
title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
|
|
395
|
-
>
|
|
396
|
-
{refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
|
|
397
|
-
<span className="text-xs font-medium">Refresh</span>
|
|
398
|
-
</button>
|
|
399
|
-
|
|
400
|
-
<div className="absolute top-3 right-3 z-30 flex items-center gap-2">
|
|
401
|
-
{slots?.toolbarActions}
|
|
386
|
+
{/* CANVAS COLUMN: a pinned toolbar row on top, then the independently
|
|
387
|
+
scrolling slide-rendering area below it. */}
|
|
388
|
+
<div className="relative flex-1 flex flex-col min-h-0 overflow-hidden">
|
|
389
|
+
{/* TOOLBAR ROW: Refresh on the left; host toolbar actions + Render on the
|
|
390
|
+
right. Pinned (shrink-0) above the scrolling canvas area. */}
|
|
391
|
+
<div className="flex-shrink-0 flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--editor-border)]">
|
|
402
392
|
<button
|
|
403
|
-
onClick={
|
|
404
|
-
disabled={
|
|
405
|
-
className=
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
: 'Render all slides as PNGs'
|
|
412
|
-
}
|
|
393
|
+
onClick={handleRefresh}
|
|
394
|
+
disabled={refreshing}
|
|
395
|
+
className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
|
|
396
|
+
refreshState === 'err'
|
|
397
|
+
? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
|
|
398
|
+
: 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
|
|
399
|
+
}`}
|
|
400
|
+
title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
|
|
413
401
|
>
|
|
414
|
-
<
|
|
415
|
-
<span className="text-xs font-medium">
|
|
402
|
+
{refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
|
|
403
|
+
<span className="text-xs font-medium">Refresh</span>
|
|
416
404
|
</button>
|
|
405
|
+
|
|
406
|
+
<div className="flex items-center gap-2">
|
|
407
|
+
{slots?.toolbarActions}
|
|
408
|
+
<button
|
|
409
|
+
onClick={handleRender}
|
|
410
|
+
disabled={rendering || project.status === 'pending' || slides.length === 0}
|
|
411
|
+
className="flex items-center gap-2 px-3 py-2 rounded-md border border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
412
|
+
title={
|
|
413
|
+
project.status === 'pending'
|
|
414
|
+
? 'Wait for the agent to finish before rendering'
|
|
415
|
+
: slides.length === 0
|
|
416
|
+
? 'Add slides before rendering'
|
|
417
|
+
: 'Render all slides as PNGs'
|
|
418
|
+
}
|
|
419
|
+
>
|
|
420
|
+
<Download size={18} />
|
|
421
|
+
<span className="text-xs font-medium">{rendering ? 'Starting…' : 'Render'}</span>
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
417
424
|
</div>
|
|
418
425
|
|
|
426
|
+
{/* SCROLL AREA: the slide viewport. ResizeObserver lives here so
|
|
427
|
+
canvasScale measures the slide-rendering area, not the toolbar. */}
|
|
428
|
+
<div ref={canvasContainerRef} className="relative flex-1 flex flex-col items-center justify-center gap-4 overflow-y-auto min-h-0 p-4">
|
|
419
429
|
{project.status === 'pending' ? (
|
|
420
430
|
<div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
|
|
421
431
|
{slots?.pendingStatus ?? (
|
|
@@ -493,10 +503,11 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
493
503
|
</div>
|
|
494
504
|
)}
|
|
495
505
|
</div>
|
|
506
|
+
</div>
|
|
496
507
|
|
|
497
508
|
{/* RIGHT: the slide editor (add-element toolbar + property panel),
|
|
498
|
-
beside the canvas with its own vertical scroll. */}
|
|
499
|
-
<div className="w-[24rem] flex-shrink-0 border-l border-[var(--editor-border)] flex flex-col overflow-y-auto bg-[var(--editor-bg)]">
|
|
509
|
+
beside the canvas with its own independent vertical scroll. */}
|
|
510
|
+
<div className="w-[24rem] flex-shrink-0 border-l border-[var(--editor-border)] flex flex-col overflow-y-auto min-h-0 h-full bg-[var(--editor-bg)]">
|
|
500
511
|
{selectedSlide && project.status !== 'pending' && (
|
|
501
512
|
<div className="px-4 py-2 border-b border-[var(--editor-border)]">
|
|
502
513
|
<AddElementMenu
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
GlobalOverlayProp,
|
|
10
10
|
EditorAdapter,
|
|
11
11
|
} from '../types'
|
|
12
|
-
import { Button, cn } from '../ui'
|
|
12
|
+
import { Button, cn, inspectorInputClass } from '../ui'
|
|
13
13
|
import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
|
|
14
14
|
|
|
15
15
|
function parseNumber(v: string): number | null {
|
|
@@ -17,6 +17,13 @@ function parseNumber(v: string): number | null {
|
|
|
17
17
|
return Number.isFinite(n) ? n : null
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// Shared small muted label that sits just above an inspector control.
|
|
21
|
+
const fieldLabelClass = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]/55'
|
|
22
|
+
|
|
23
|
+
// Section header: SLIDE / OVERLAY / IMAGE.
|
|
24
|
+
const sectionHeaderClass =
|
|
25
|
+
'text-xs font-semibold uppercase tracking-wider text-[var(--editor-text)]/70'
|
|
26
|
+
|
|
20
27
|
interface Props {
|
|
21
28
|
project: Project
|
|
22
29
|
slide?: Slide
|
|
@@ -64,7 +71,7 @@ function HideToggle({
|
|
|
64
71
|
title={isHidden ? 'Show in editor' : 'Hide from editor'}
|
|
65
72
|
aria-label={isHidden ? 'Show in editor' : 'Hide from editor'}
|
|
66
73
|
aria-pressed={isHidden}
|
|
67
|
-
className="text-[var(--editor-text)]/60 hover:text-[var(--editor-text)]
|
|
74
|
+
className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]/60 hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
|
|
68
75
|
>
|
|
69
76
|
{isHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
70
77
|
</button>
|
|
@@ -78,8 +85,8 @@ function numInput(
|
|
|
78
85
|
opts?: { min?: number; max?: number; step?: number }
|
|
79
86
|
) {
|
|
80
87
|
return (
|
|
81
|
-
<label className="flex flex-col gap-
|
|
82
|
-
<span className=
|
|
88
|
+
<label className="flex flex-col gap-1">
|
|
89
|
+
<span className={fieldLabelClass}>{label}</span>
|
|
83
90
|
<input
|
|
84
91
|
type="number"
|
|
85
92
|
value={value}
|
|
@@ -90,7 +97,7 @@ function numInput(
|
|
|
90
97
|
const parsed = parseNumber(e.target.value)
|
|
91
98
|
if (parsed !== null) onChange(parsed)
|
|
92
99
|
}}
|
|
93
|
-
className=
|
|
100
|
+
className={cn(inspectorInputClass, 'text-right')}
|
|
94
101
|
/>
|
|
95
102
|
</label>
|
|
96
103
|
)
|
|
@@ -109,36 +116,35 @@ function PropEditor({
|
|
|
109
116
|
|
|
110
117
|
if (type === 'bool') {
|
|
111
118
|
return (
|
|
112
|
-
<label className="flex items-center gap-2
|
|
119
|
+
<label className="flex cursor-pointer items-center gap-2" title={description}>
|
|
113
120
|
<input
|
|
114
121
|
type="checkbox"
|
|
115
122
|
checked={Boolean(value)}
|
|
116
123
|
onChange={e => onChange(e.target.checked)}
|
|
117
|
-
className="accent-[var(--editor-accent)]"
|
|
124
|
+
className="h-4 w-4 accent-[var(--editor-accent)]"
|
|
118
125
|
/>
|
|
119
|
-
<span className="text-
|
|
126
|
+
<span className="text-sm text-[var(--editor-text)]">{name}</span>
|
|
120
127
|
</label>
|
|
121
128
|
)
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
if (type === 'color') {
|
|
125
132
|
return (
|
|
126
|
-
<
|
|
127
|
-
<span className=
|
|
128
|
-
<
|
|
129
|
-
type="color"
|
|
133
|
+
<div className="flex flex-col gap-1" title={description}>
|
|
134
|
+
<span className={fieldLabelClass}>{name}</span>
|
|
135
|
+
<SwatchInput
|
|
130
136
|
value={String(value ?? '#000000')}
|
|
131
|
-
onChange={
|
|
132
|
-
|
|
137
|
+
onChange={v => onChange(v)}
|
|
138
|
+
ariaLabel={name}
|
|
133
139
|
/>
|
|
134
|
-
</
|
|
140
|
+
</div>
|
|
135
141
|
)
|
|
136
142
|
}
|
|
137
143
|
|
|
138
144
|
if (type === 'int' || type === 'float') {
|
|
139
145
|
return (
|
|
140
|
-
<label className="flex flex-col gap-
|
|
141
|
-
<span className=
|
|
146
|
+
<label className="flex flex-col gap-1" title={description}>
|
|
147
|
+
<span className={fieldLabelClass}>{name}</span>
|
|
142
148
|
<input
|
|
143
149
|
type="number"
|
|
144
150
|
value={Number(value ?? 0)}
|
|
@@ -147,7 +153,7 @@ function PropEditor({
|
|
|
147
153
|
const parsed = parseNumber(e.target.value)
|
|
148
154
|
if (parsed !== null) onChange(parsed)
|
|
149
155
|
}}
|
|
150
|
-
className=
|
|
156
|
+
className={cn(inspectorInputClass, 'text-right')}
|
|
151
157
|
/>
|
|
152
158
|
</label>
|
|
153
159
|
)
|
|
@@ -155,18 +161,52 @@ function PropEditor({
|
|
|
155
161
|
|
|
156
162
|
// string fallback
|
|
157
163
|
return (
|
|
158
|
-
<label className="flex flex-col gap-
|
|
159
|
-
<span className=
|
|
164
|
+
<label className="flex flex-col gap-1" title={description}>
|
|
165
|
+
<span className={fieldLabelClass}>{name}</span>
|
|
160
166
|
<input
|
|
161
167
|
type="text"
|
|
162
168
|
value={String(value ?? '')}
|
|
163
169
|
onChange={e => onChange(e.target.value)}
|
|
164
|
-
className=
|
|
170
|
+
className={inspectorInputClass}
|
|
165
171
|
/>
|
|
166
172
|
</label>
|
|
167
173
|
)
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
// Color control that actually reads as one: a filled rounded swatch (the live
|
|
177
|
+
// value) sitting next to the hex string, the whole row clickable to open the
|
|
178
|
+
// native color picker. The transparent <input type="color"> overlays the
|
|
179
|
+
// swatch so the OS picker anchors there.
|
|
180
|
+
function SwatchInput({
|
|
181
|
+
value,
|
|
182
|
+
onChange,
|
|
183
|
+
ariaLabel,
|
|
184
|
+
}: {
|
|
185
|
+
value: string
|
|
186
|
+
onChange: (v: string) => void
|
|
187
|
+
ariaLabel: string
|
|
188
|
+
}) {
|
|
189
|
+
return (
|
|
190
|
+
<div className="flex items-center gap-2.5">
|
|
191
|
+
<label className="relative h-7 w-7 shrink-0 cursor-pointer">
|
|
192
|
+
<span
|
|
193
|
+
className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
|
|
194
|
+
style={{ backgroundColor: value }}
|
|
195
|
+
aria-hidden
|
|
196
|
+
/>
|
|
197
|
+
<input
|
|
198
|
+
type="color"
|
|
199
|
+
value={value}
|
|
200
|
+
onChange={e => onChange(e.target.value)}
|
|
201
|
+
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
|
202
|
+
aria-label={ariaLabel}
|
|
203
|
+
/>
|
|
204
|
+
</label>
|
|
205
|
+
<span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
|
|
206
|
+
</div>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
170
210
|
export default function SlidePropertyPanel({
|
|
171
211
|
project,
|
|
172
212
|
slide,
|
|
@@ -222,48 +262,45 @@ export default function SlidePropertyPanel({
|
|
|
222
262
|
|
|
223
263
|
return (
|
|
224
264
|
<div className={cn('w-80 flex-shrink-0 border-l border-[var(--editor-border)] flex flex-col overflow-y-auto bg-[var(--editor-bg)]', className)}>
|
|
225
|
-
{/* Slide
|
|
226
|
-
<div className="
|
|
227
|
-
<div className=
|
|
228
|
-
<div className="flex flex-col gap-
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
Delete
|
|
254
|
-
</Button>
|
|
255
|
-
</div>
|
|
265
|
+
{/* Slide section */}
|
|
266
|
+
<div className="flex flex-col gap-4 p-4">
|
|
267
|
+
<div className={sectionHeaderClass}>Slide</div>
|
|
268
|
+
<div className="flex flex-col gap-1">
|
|
269
|
+
<span className={fieldLabelClass}>Background color</span>
|
|
270
|
+
<SwatchInput
|
|
271
|
+
value={slide.base_color || '#ffffff'}
|
|
272
|
+
onChange={v => onSlideChange({ base_color: v })}
|
|
273
|
+
ariaLabel="Background color"
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
<div className="flex gap-2">
|
|
277
|
+
<Button
|
|
278
|
+
size="sm"
|
|
279
|
+
variant="secondary"
|
|
280
|
+
className="flex-1"
|
|
281
|
+
onClick={() => onDuplicateSlide(slide.id)}
|
|
282
|
+
>
|
|
283
|
+
Duplicate
|
|
284
|
+
</Button>
|
|
285
|
+
<Button
|
|
286
|
+
size="sm"
|
|
287
|
+
variant="danger"
|
|
288
|
+
className="flex-1"
|
|
289
|
+
onClick={() => onDeleteSlide(slide.id)}
|
|
290
|
+
>
|
|
291
|
+
Delete
|
|
292
|
+
</Button>
|
|
256
293
|
</div>
|
|
257
294
|
</div>
|
|
258
295
|
|
|
259
296
|
{/* Element section */}
|
|
260
297
|
{element && (
|
|
261
|
-
<div className="
|
|
298
|
+
<div className="flex flex-col gap-4 border-t border-[var(--editor-border)] p-4">
|
|
262
299
|
<div className="flex items-center justify-between">
|
|
263
|
-
<span className=
|
|
300
|
+
<span className={sectionHeaderClass}>
|
|
264
301
|
{element.type === 'image' ? 'Image' : 'Overlay'}
|
|
265
302
|
</span>
|
|
266
|
-
<div className="flex items-center gap-
|
|
303
|
+
<div className="flex items-center gap-0.5">
|
|
267
304
|
<HideToggle
|
|
268
305
|
elementId={element.id}
|
|
269
306
|
isHidden={hiddenElementIds?.includes(element.id) ?? false}
|
|
@@ -271,14 +308,14 @@ export default function SlidePropertyPanel({
|
|
|
271
308
|
/>
|
|
272
309
|
<button
|
|
273
310
|
onClick={() => onReorderElement(slide.id, element.id, 'forward')}
|
|
274
|
-
className="
|
|
311
|
+
className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]/60 hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
|
|
275
312
|
title="Bring forward"
|
|
276
313
|
>
|
|
277
314
|
↑
|
|
278
315
|
</button>
|
|
279
316
|
<button
|
|
280
317
|
onClick={() => onReorderElement(slide.id, element.id, 'backward')}
|
|
281
|
-
className="
|
|
318
|
+
className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]/60 hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
|
|
282
319
|
title="Send backward"
|
|
283
320
|
>
|
|
284
321
|
↓
|
|
@@ -287,27 +324,29 @@ export default function SlidePropertyPanel({
|
|
|
287
324
|
</div>
|
|
288
325
|
|
|
289
326
|
{/* Transform */}
|
|
290
|
-
<div className="
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
327
|
+
<div className="flex flex-col gap-3">
|
|
328
|
+
<div className="grid grid-cols-2 gap-3">
|
|
329
|
+
{numInput('X', element.x, v => onElementChange({ x: v }))}
|
|
330
|
+
{numInput('Y', element.y, v => onElementChange({ y: v }))}
|
|
331
|
+
{numInput('W', element.w, v => onElementChange({ w: v }), { min: 1 })}
|
|
332
|
+
{numInput('H', element.h, v => onElementChange({ h: v }), { min: 1 })}
|
|
333
|
+
</div>
|
|
295
334
|
{numInput('Rotation', element.rotation ?? 0, v => onElementChange({ rotation: v }), { min: -360, max: 360 })}
|
|
296
335
|
</div>
|
|
297
336
|
|
|
298
337
|
{/* Image-specific */}
|
|
299
338
|
{element.type === 'image' && (
|
|
300
|
-
<div className="flex flex-col gap-
|
|
301
|
-
<div className="flex flex-col gap-
|
|
302
|
-
<span className=
|
|
303
|
-
<span className="text-
|
|
339
|
+
<div className="flex flex-col gap-2">
|
|
340
|
+
<div className="flex flex-col gap-1">
|
|
341
|
+
<span className={fieldLabelClass}>Source</span>
|
|
342
|
+
<span className="truncate text-sm text-[var(--editor-text)]" title={element.src}>
|
|
304
343
|
{element.src.split('/').pop() || element.src}
|
|
305
344
|
</span>
|
|
306
345
|
</div>
|
|
307
346
|
<Button
|
|
308
347
|
size="sm"
|
|
309
|
-
variant="
|
|
310
|
-
className="
|
|
348
|
+
variant="secondary"
|
|
349
|
+
className="flex items-center gap-1.5"
|
|
311
350
|
disabled={(element.rotation ?? 0) !== 0 || !onEnterCrop}
|
|
312
351
|
title={
|
|
313
352
|
(element.rotation ?? 0) !== 0
|
|
@@ -324,7 +363,7 @@ export default function SlidePropertyPanel({
|
|
|
324
363
|
|
|
325
364
|
{/* Overlay-specific */}
|
|
326
365
|
{overlayEl && (
|
|
327
|
-
<div className="flex flex-col gap-
|
|
366
|
+
<div className="flex flex-col gap-4">
|
|
328
367
|
{/* Rich-text formatting (bold/italic/case/color/align + font family
|
|
329
368
|
& size pickers) for overlays exposing the standard text contract. */}
|
|
330
369
|
{updateOverlayProp && (
|
|
@@ -335,17 +374,17 @@ export default function SlidePropertyPanel({
|
|
|
335
374
|
/>
|
|
336
375
|
)}
|
|
337
376
|
|
|
338
|
-
<div className="grid grid-cols-2 gap-
|
|
377
|
+
<div className="grid grid-cols-2 gap-3">
|
|
339
378
|
{numInput('Frame', overlayEl.frame, v => onElementChange({ frame: v }), { min: 0 })}
|
|
340
379
|
</div>
|
|
341
380
|
|
|
342
381
|
{schemasLoading && (
|
|
343
|
-
<div className="text-xs text-[var(--editor-text)]/
|
|
382
|
+
<div className="text-xs text-[var(--editor-text)]/55">Loading overlay props…</div>
|
|
344
383
|
)}
|
|
345
384
|
|
|
346
385
|
{!schemasLoading && overlaySchema && overlaySchema.props.length > 0 && (
|
|
347
|
-
<div className="flex flex-col gap-
|
|
348
|
-
<span className=
|
|
386
|
+
<div className="flex flex-col gap-3">
|
|
387
|
+
<span className={fieldLabelClass}>Props</span>
|
|
349
388
|
{overlaySchema.props.map(prop => (
|
|
350
389
|
<PropEditor
|
|
351
390
|
key={prop.name}
|
|
@@ -367,19 +406,19 @@ export default function SlidePropertyPanel({
|
|
|
367
406
|
)}
|
|
368
407
|
|
|
369
408
|
{/* Element actions */}
|
|
370
|
-
<div className="flex gap-2
|
|
409
|
+
<div className="flex gap-2">
|
|
371
410
|
<Button
|
|
372
411
|
size="sm"
|
|
373
|
-
variant="
|
|
374
|
-
className="flex-1
|
|
412
|
+
variant="secondary"
|
|
413
|
+
className="flex-1"
|
|
375
414
|
onClick={() => onDuplicateElement(slide.id, element.id)}
|
|
376
415
|
>
|
|
377
416
|
Duplicate
|
|
378
417
|
</Button>
|
|
379
418
|
<Button
|
|
380
419
|
size="sm"
|
|
381
|
-
variant="
|
|
382
|
-
className="flex-1
|
|
420
|
+
variant="danger"
|
|
421
|
+
className="flex-1"
|
|
383
422
|
onClick={() => onDeleteElement(slide.id, element.id)}
|
|
384
423
|
>
|
|
385
424
|
Delete
|
package/src/text/FontPicker.tsx
CHANGED
|
@@ -95,7 +95,7 @@ export function FontFamilyPicker({ value, onChange, disabled, className, buttonC
|
|
|
95
95
|
onClick={() => setOpen((o) => !o)}
|
|
96
96
|
className={
|
|
97
97
|
buttonClassName ??
|
|
98
|
-
'flex w-full items-center gap-1 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2
|
|
98
|
+
'flex h-8 w-full items-center gap-1 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2.5 text-sm text-[var(--editor-text)] hover:border-[var(--editor-accent)] focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)] disabled:opacity-50'
|
|
99
99
|
}
|
|
100
100
|
style={displayStyle}
|
|
101
101
|
aria-haspopup="listbox"
|
|
@@ -210,7 +210,7 @@ export function FontSizePicker({ value, onChange, disabled, min = 8, max = 9999,
|
|
|
210
210
|
}}
|
|
211
211
|
className={
|
|
212
212
|
className ??
|
|
213
|
-
'w-14 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2
|
|
213
|
+
'h-8 w-14 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2.5 text-sm text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] focus:ring-1 focus:ring-[var(--editor-accent)] disabled:opacity-50'
|
|
214
214
|
}
|
|
215
215
|
aria-label="Font size"
|
|
216
216
|
/>
|
|
@@ -96,15 +96,16 @@ export function TextFormattingToolbar({
|
|
|
96
96
|
const rawColor = readPropAsString(element, 'color')
|
|
97
97
|
const colorValue = HEX_PATTERN.test(rawColor) && rawColor.length === 7 ? rawColor : '#111111'
|
|
98
98
|
|
|
99
|
-
// Themed
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
// Themed toggle styles: consistent square buttons. Active = solid accent fill
|
|
100
|
+
// with accent-foreground text; inactive = themed surface + border that hovers
|
|
101
|
+
// toward the surface tone. One look for B / I / case / align across the bar.
|
|
102
|
+
const toolbarBtnBase = 'flex h-7 min-w-[1.75rem] items-center justify-center rounded-md border px-1.5 text-sm transition-colors focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)]'
|
|
103
|
+
const toolbarBtnActive = 'border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)]'
|
|
104
|
+
const toolbarBtnInactive = 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
|
|
105
|
+
|
|
106
|
+
// Segment visibility helpers — group toggles vs size/font vs alignment.
|
|
105
107
|
const hasLeftGroup = supported.has('fontWeight') || supported.has('fontStyle') || supported.has('textTransform') || supported.has('color')
|
|
106
108
|
const hasMidGroup = supported.has('fontSize') || supported.has('fontFamily')
|
|
107
|
-
const hasRightGroup = supported.has('textAlign')
|
|
108
109
|
|
|
109
110
|
return (
|
|
110
111
|
<div
|
|
@@ -116,88 +117,91 @@ export function TextFormattingToolbar({
|
|
|
116
117
|
<div
|
|
117
118
|
role="toolbar"
|
|
118
119
|
aria-label="Text formatting"
|
|
119
|
-
className="flex items-center gap-
|
|
120
|
+
className="flex flex-wrap items-center gap-x-3 gap-y-2 p-2"
|
|
120
121
|
>
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
{supported.has('fontSize') && (
|
|
181
|
-
<FontSizePicker
|
|
182
|
-
value={fontSizeValue}
|
|
183
|
-
onChange={handleFontSizeChange}
|
|
184
|
-
/>
|
|
185
|
-
)}
|
|
186
|
-
|
|
187
|
-
{supported.has('fontFamily') && (
|
|
188
|
-
<FontFamilyPicker
|
|
189
|
-
value={fontFamilyValue}
|
|
190
|
-
onChange={handleFontFamilyChange}
|
|
191
|
-
className="w-32"
|
|
192
|
-
/>
|
|
122
|
+
{/* Segment 1: B / I / case / color toggles */}
|
|
123
|
+
{hasLeftGroup && (
|
|
124
|
+
<div className="flex items-center gap-1">
|
|
125
|
+
{supported.has('fontWeight') && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
aria-label="Bold"
|
|
129
|
+
aria-pressed={boldActive}
|
|
130
|
+
onClick={handleBoldClick}
|
|
131
|
+
className={`${toolbarBtnBase} ${boldActive ? toolbarBtnActive : toolbarBtnInactive}`}
|
|
132
|
+
>
|
|
133
|
+
<Bold className="h-3.5 w-3.5" />
|
|
134
|
+
</button>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{supported.has('fontStyle') && (
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
aria-label="Italic"
|
|
141
|
+
aria-pressed={italicActive}
|
|
142
|
+
onClick={handleItalicClick}
|
|
143
|
+
className={`${toolbarBtnBase} ${italicActive ? toolbarBtnActive : toolbarBtnInactive}`}
|
|
144
|
+
>
|
|
145
|
+
<Italic className="h-3.5 w-3.5" />
|
|
146
|
+
</button>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{supported.has('textTransform') && (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
aria-label={`Text case (currently ${textTransformDisplay})`}
|
|
153
|
+
onClick={handleCaseClick}
|
|
154
|
+
className={`${toolbarBtnBase} font-mono text-xs ${caseActive ? toolbarBtnActive : toolbarBtnInactive}`}
|
|
155
|
+
>
|
|
156
|
+
{caseLabel(props.textTransform)}
|
|
157
|
+
</button>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{supported.has('color') && (
|
|
161
|
+
<label
|
|
162
|
+
className={`${toolbarBtnBase} ${toolbarBtnInactive} relative cursor-pointer`}
|
|
163
|
+
title="Text color"
|
|
164
|
+
>
|
|
165
|
+
<span
|
|
166
|
+
className="block h-4 w-4 rounded-sm border border-[var(--editor-border)]"
|
|
167
|
+
style={{ backgroundColor: colorValue }}
|
|
168
|
+
aria-hidden
|
|
169
|
+
/>
|
|
170
|
+
<input
|
|
171
|
+
type="color"
|
|
172
|
+
value={colorValue}
|
|
173
|
+
onChange={(e) => handleColorChange(e.target.value)}
|
|
174
|
+
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
|
175
|
+
aria-label="Text color"
|
|
176
|
+
/>
|
|
177
|
+
</label>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
193
180
|
)}
|
|
194
181
|
|
|
195
|
-
{
|
|
196
|
-
|
|
182
|
+
{/* Segment 2: size + font */}
|
|
183
|
+
{hasMidGroup && (
|
|
184
|
+
<div className="flex items-center gap-1.5">
|
|
185
|
+
{supported.has('fontSize') && (
|
|
186
|
+
<FontSizePicker
|
|
187
|
+
value={fontSizeValue}
|
|
188
|
+
onChange={handleFontSizeChange}
|
|
189
|
+
/>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{supported.has('fontFamily') && (
|
|
193
|
+
<FontFamilyPicker
|
|
194
|
+
value={fontFamilyValue}
|
|
195
|
+
onChange={handleFontFamilyChange}
|
|
196
|
+
className="w-32"
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
197
200
|
)}
|
|
198
201
|
|
|
202
|
+
{/* Segment 3: alignment */}
|
|
199
203
|
{supported.has('textAlign') && (
|
|
200
|
-
<div role="radiogroup" aria-label="Text alignment" className="flex items-center gap-
|
|
204
|
+
<div role="radiogroup" aria-label="Text alignment" className="flex items-center gap-1">
|
|
201
205
|
{(
|
|
202
206
|
[
|
|
203
207
|
{ value: 'left', Icon: AlignLeft, label: 'Align left' },
|
|
@@ -224,23 +228,20 @@ export function TextFormattingToolbar({
|
|
|
224
228
|
)}
|
|
225
229
|
|
|
226
230
|
{!hasAnyControlProp && (
|
|
227
|
-
<span className="px-
|
|
231
|
+
<span className="px-1 py-1 text-xs text-[var(--editor-text)]/55">
|
|
228
232
|
Edit via property panel
|
|
229
233
|
</span>
|
|
230
234
|
)}
|
|
231
235
|
|
|
232
236
|
{onDelete && (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
<Trash2 className="h-3.5 w-3.5" />
|
|
242
|
-
</button>
|
|
243
|
-
</>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
aria-label="Delete text overlay"
|
|
240
|
+
onClick={onDelete}
|
|
241
|
+
className={`${toolbarBtnBase} border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/60 hover:border-red-500/50 hover:bg-red-900/30 hover:text-red-400`}
|
|
242
|
+
>
|
|
243
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
244
|
+
</button>
|
|
244
245
|
)}
|
|
245
246
|
</div>
|
|
246
247
|
</div>
|
package/src/ui/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
export { cn } from './utils'
|
|
5
5
|
export { Button } from './button'
|
|
6
6
|
export type { ButtonProps } from './button'
|
|
7
|
-
export { Input } from './input'
|
|
7
|
+
export { Input, inspectorInputClass } from './input'
|
|
8
8
|
export type { InputProps } from './input'
|
|
9
9
|
export { Label } from './label'
|
|
10
10
|
export { Select } from './select'
|
package/src/ui/input.tsx
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { cn } from './utils'
|
|
2
2
|
|
|
3
|
+
// Shared inspector-input style. One consistent height/border/focus treatment
|
|
4
|
+
// for every text/number/select control in the editor chrome so the property
|
|
5
|
+
// panel reads as a single coherent surface. Reuse via `cn(inspectorInputClass, …)`.
|
|
6
|
+
export const inspectorInputClass =
|
|
7
|
+
'h-8 w-full rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2.5 py-1.5 text-sm text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] focus:ring-1 focus:ring-[var(--editor-accent)]'
|
|
8
|
+
|
|
3
9
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
|
4
10
|
|
|
5
11
|
export function Input({ className, ...props }: InputProps) {
|