promptslide 0.3.16 → 0.3.18

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.
@@ -0,0 +1,1948 @@
1
+ import {
2
+ Pencil,
3
+ Check,
4
+ X,
5
+ Undo2,
6
+ Redo2,
7
+ MousePointer2,
8
+ Type,
9
+ Image as ImageIcon,
10
+ Shapes,
11
+ Bold,
12
+ Italic,
13
+ Underline,
14
+ AlignLeft,
15
+ AlignCenter,
16
+ AlignRight,
17
+ AlignStartVertical,
18
+ AlignCenterVertical,
19
+ AlignEndVertical,
20
+ AlignStartHorizontal,
21
+ AlignCenterHorizontal,
22
+ AlignEndHorizontal,
23
+ ArrowUpToLine,
24
+ ArrowDownToLine,
25
+ Copy,
26
+ Trash2,
27
+ Plus,
28
+ ChevronDown,
29
+ Layers,
30
+ MoveHorizontal,
31
+ MoveVertical,
32
+ RotateCw
33
+ } from "lucide-react"
34
+ import { useEffect, useLayoutEffect, useRef, useState } from "react"
35
+ import { createPortal } from "react-dom"
36
+
37
+ import { aligned, snap, translated, type Rect } from "./geometry"
38
+ import "./studio.css"
39
+
40
+ type Style = Record<string, string | number>
41
+ type SourceNode = {
42
+ items?: { item: number; value: string }[]
43
+ start: number
44
+ tag: string
45
+ line: number
46
+ computedText?: boolean
47
+ text: string | null
48
+ propTargets?: Record<string, number>
49
+ props: Record<string, string>
50
+ structural: boolean
51
+ container: boolean
52
+ }
53
+ type Source = { revision: string; nodes: SourceNode[] }
54
+ type Edit = {
55
+ item?: number
56
+ start: number
57
+ style?: Style
58
+ text?: string
59
+ props?: Record<string, string>
60
+ action?: "delete" | "duplicate"
61
+ copies?: number
62
+ insert?: string
63
+ richText?: { from: number; to: number; style: Style }
64
+ }
65
+ type Draft = Record<string, { revision: string; edits: Edit[] }>
66
+ type Selection = {
67
+ item?: number
68
+ path: string
69
+ start: number
70
+ revision: string
71
+ element?: HTMLElement
72
+ node?: SourceNode
73
+ textProp?: string
74
+ }
75
+ const sourceOf = (element: HTMLElement): Selection | null => {
76
+ try {
77
+ const [path, start, revision] = JSON.parse(element.dataset.psSource!)
78
+ return {
79
+ path,
80
+ start,
81
+ revision,
82
+ element,
83
+ item: element.dataset.psItem === undefined ? undefined : Number(element.dataset.psItem)
84
+ }
85
+ } catch {
86
+ return null
87
+ }
88
+ }
89
+ const keyOf = (s: Selection) => `${s.path}:${s.start}:${s.item ?? "all"}`
90
+ const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
91
+ function Studio({
92
+ token,
93
+ close,
94
+ panel,
95
+ overlay,
96
+ surface
97
+ }: {
98
+ token: string
99
+ close: () => void
100
+ panel: HTMLElement
101
+ overlay: HTMLElement
102
+ surface: HTMLElement
103
+ }) {
104
+ const [index, setIndex] = useState(Number(surface.dataset.slideIndex || 0))
105
+ const [tab, setTab] = useState<"design" | "arrange">("design")
106
+ const [selected, setSelected] = useState<Selection[]>([])
107
+ const [sources, setSources] = useState<Record<string, Source>>({})
108
+ const [sharedAppearanceEnabled, setSharedAppearanceEnabled] = useState(false)
109
+ const [draft, setDraft] = useState<Draft>(() => {
110
+ try {
111
+ return JSON.parse(sessionStorage.getItem("promptslide:editor-draft") || "{}")
112
+ } catch {
113
+ return {}
114
+ }
115
+ })
116
+ const draftRef = useRef(draft)
117
+ draftRef.current = draft
118
+ const [past, setPast] = useState<Draft[]>([])
119
+ const [future, setFuture] = useState<Draft[]>([])
120
+ const [message, setMessage] = useState("All changes saved")
121
+ const [busy, setBusy] = useState(false)
122
+ const [scale, setScale] = useState(1)
123
+ const [version, setVersion] = useState(0)
124
+ const [, redraw] = useState(0)
125
+ const [layers, setLayers] = useState<Selection[]>([])
126
+ const [guides, setGuides] = useState<{ x?: number; y?: number }>({})
127
+ const [confirmClose, setConfirmClose] = useState(false)
128
+ const [closeError, setCloseError] = useState("")
129
+ const closeDialog = useRef<HTMLDialogElement>(null)
130
+ useEffect(() => {
131
+ if (confirmClose) closeDialog.current?.showModal()
132
+ else closeDialog.current?.close()
133
+ }, [confirmClose])
134
+ const [inlineText, setInlineText] = useState<string | null>(null)
135
+ const canvas = useRef<HTMLElement>(surface)
136
+ canvas.current = surface
137
+ const touched = useRef(new Set<HTMLElement>())
138
+ const copies = useRef<Element[]>([])
139
+ function clearCopies() {
140
+ copies.current.forEach(element => element.remove())
141
+ copies.current = []
142
+ }
143
+ useEffect(() => clearCopies, [])
144
+ const textArea = useRef<HTMLTextAreaElement>(null)
145
+ const originals = useRef(
146
+ new Map<
147
+ HTMLElement,
148
+ { style: string | null; html: string; src: string | null; hidden: boolean; source?: string }
149
+ >()
150
+ )
151
+ const current = selected[0]
152
+ const currentNode = current && sources[current.path]?.nodes.find(n => n.start === current.start)
153
+ const currentItem = currentNode?.items?.find(item => item.item === current?.item)
154
+ const currentText = current?.textProp
155
+ ? currentNode?.props[current.textProp]
156
+ : (currentItem?.value ?? currentNode?.text)
157
+ const unsupportedSelection = selected.some(
158
+ selection =>
159
+ sources[selection.path]?.nodes.find(node => node.start === selection.start)?.computedText
160
+ )
161
+
162
+ const sharedEdit =
163
+ current &&
164
+ draft[current.path]?.edits.find(e => e.start === current.start && e.item === undefined)
165
+ const itemEdit =
166
+ currentItem &&
167
+ current &&
168
+ draft[current.path]?.edits.find(e => e.start === current.start && e.item === current.item)
169
+ const formattingSource = current?.element && sourceOf(current.element)
170
+ const formattingEdit =
171
+ formattingSource &&
172
+ draft[formattingSource.path]?.edits.find(
173
+ edit => edit.start === formattingSource.start && edit.item === undefined
174
+ )
175
+ const currentEdit =
176
+ sharedEdit || itemEdit || formattingEdit
177
+ ? {
178
+ ...sharedEdit,
179
+ ...itemEdit,
180
+ text: current?.textProp
181
+ ? sharedEdit?.props?.[current.textProp]
182
+ : (itemEdit?.text ?? sharedEdit?.text),
183
+ style: formattingEdit?.style ?? sharedEdit?.style
184
+ }
185
+ : undefined
186
+ const dirty = Object.keys(draft).length > 0
187
+ async function request(data: unknown) {
188
+ const response = await fetch("/__promptslide_editor", {
189
+ method: "POST",
190
+ headers: { "Content-Type": "application/json", "X-Promptslide-Editor": token },
191
+ body: JSON.stringify(data)
192
+ })
193
+ const value = await response.json()
194
+ if (!response.ok)
195
+ throw new Error(
196
+ response.status === 409
197
+ ? "This slide changed elsewhere. Your changes are still here. Download a copy or discard them to use the latest version."
198
+ : value.error || "We couldn't save those changes. Please try again."
199
+ )
200
+ return value
201
+ }
202
+ async function inspect(selection: Selection[]) {
203
+ if (selection.some(item => item.element)) surface.focus({ preventScroll: true })
204
+ panel.querySelector(".ps-panel-body")?.scrollTo({ top: 0, behavior: "instant" })
205
+ if (selection[0] && !selection[0].element) setTab("design")
206
+ setSharedAppearanceEnabled(false)
207
+ setInlineText(null)
208
+ setSelected(selection)
209
+ try {
210
+ const paths = [...new Set(selection.map(s => s.path))]
211
+ const entries = await Promise.all(
212
+ paths
213
+ .filter(
214
+ p =>
215
+ !sources[p] ||
216
+ selection.some(item => item.path === p && item.revision !== sources[p]?.revision)
217
+ )
218
+ .map(async path => [path, await request({ action: "inspect", path })] as const)
219
+ )
220
+ const resolved = { ...sources, ...Object.fromEntries(entries) } as Record<string, Source>
221
+ setSources(resolved)
222
+ setSelected(
223
+ selection.map(item => {
224
+ const file = resolved[item.path]
225
+ const owners =
226
+ file?.nodes.flatMap(node =>
227
+ Object.entries(node.propTargets || {})
228
+ .filter(([prop, start]) => start === item.start && node.props[prop] !== undefined)
229
+ .map(([prop]) => ({ node, prop }))
230
+ ) || []
231
+ if (owners.length !== 1) return item
232
+ return {
233
+ ...item,
234
+ start: owners[0]!.node.start,
235
+ textProp: owners[0]!.prop,
236
+ node: owners[0]!.node
237
+ }
238
+ })
239
+ )
240
+ } catch (error) {
241
+ setMessage(error instanceof Error ? error.message : "Something went wrong. Please try again.")
242
+ }
243
+ }
244
+ function appearanceLocked(selection: Selection) {
245
+ if (sharedAppearanceEnabled) return false
246
+ if (selection.textProp) return true
247
+ const origin = selection.element && sourceOf(selection.element)
248
+ return Boolean(
249
+ origin &&
250
+ layers.filter(layer => layer.path === origin.path && layer.start === origin.start).length > 1
251
+ )
252
+ }
253
+ function change(
254
+ selections: Selection[],
255
+ patch: Partial<Edit> | ((s: Selection) => Partial<Edit>)
256
+ ) {
257
+ if (
258
+ !selections.length ||
259
+ selections.some(
260
+ selection =>
261
+ sources[selection.path]?.nodes.find(node => node.start === selection.start)?.computedText
262
+ )
263
+ )
264
+ return
265
+ const next = clone(draftRef.current)
266
+ for (const selection of selections) {
267
+ let p = typeof patch === "function" ? patch(selection) : patch
268
+ if (selection.textProp && p.text !== undefined)
269
+ p = { ...p, text: undefined, props: { ...p.props, [selection.textProp]: p.text } }
270
+ if ((p.style || p.action || p.insert || p.richText) && appearanceLocked(selection)) return
271
+ const s =
272
+ (p.style || p.action || p.insert) && selection.element
273
+ ? sourceOf(selection.element) || selection
274
+ : selection
275
+ const file = (next[s.path] ||= { revision: s.revision, edits: [] })
276
+ if (file.revision !== s.revision) {
277
+ setMessage(
278
+ "This slide changed elsewhere. Download your changes or discard them to use the latest version."
279
+ )
280
+ return
281
+ }
282
+ const item = p.text !== undefined ? s.item : undefined
283
+ const edit =
284
+ file.edits.find(e => e.start === s.start && e.item === item) ||
285
+ file.edits[file.edits.push({ start: s.start, item }) - 1]!
286
+ Object.assign(edit, {
287
+ ...p,
288
+ style: p.style ? { ...edit.style, ...p.style } : edit.style,
289
+ props: p.props ? { ...edit.props, ...p.props } : edit.props
290
+ })
291
+ }
292
+ const previous = clone(draftRef.current)
293
+ setPast(p => [...p.slice(-99), previous])
294
+ setFuture([])
295
+ setDraft(next)
296
+ setMessage("Unsaved changes")
297
+ }
298
+ function undo() {
299
+ if (!past.length) return
300
+ const previous = clone(draftRef.current)
301
+ setFuture(f => [previous, ...f])
302
+ setMessage(
303
+ Object.keys(past[past.length - 1]!).length ? "Unsaved changes" : "No unsaved changes"
304
+ )
305
+ setDraft(past[past.length - 1]!)
306
+ setPast(p => p.slice(0, -1))
307
+ }
308
+ function redo() {
309
+ if (!future.length) return
310
+ const previous = clone(draftRef.current)
311
+ setPast(p => [...p, previous])
312
+ setMessage(Object.keys(future[0]!).length ? "Unsaved changes" : "No unsaved changes")
313
+ setDraft(future[0]!)
314
+ setFuture(f => f.slice(1))
315
+ }
316
+ function restore() {
317
+ clearCopies()
318
+ for (const [el, original] of originals.current) {
319
+ if (!touched.current.has(el)) continue
320
+ if (original.style === null) el.removeAttribute("style")
321
+ else el.setAttribute("style", original.style)
322
+ if (!el.querySelector("[data-ps-source]") && el.innerHTML !== original.html)
323
+ el.innerHTML = original.html
324
+ if (original.src === null) el.removeAttribute("src")
325
+ else el.setAttribute("src", original.src)
326
+ el.hidden = original.hidden
327
+ }
328
+ touched.current.clear()
329
+ }
330
+ useEffect(() => {
331
+ const hot = (
332
+ import.meta as ImportMeta & {
333
+ hot?: {
334
+ on: (event: string, callback: () => void) => void
335
+ off: (event: string, callback: () => void) => void
336
+ }
337
+ }
338
+ ).hot
339
+ let timer: ReturnType<typeof setTimeout>
340
+ const refresh = () => {
341
+ clearTimeout(timer)
342
+ timer = setTimeout(() => {
343
+ setSelected([])
344
+ setInlineText(null)
345
+ setSources({})
346
+ window.dispatchEvent(new Event("promptslide:editor-refresh"))
347
+ setVersion(v => v + 1)
348
+ }, 100)
349
+ }
350
+ hot?.on("vite:afterUpdate", refresh)
351
+ return () => {
352
+ clearTimeout(timer)
353
+ hot?.off("vite:afterUpdate", refresh)
354
+ }
355
+ }, [])
356
+ useLayoutEffect(() => {
357
+ let frame = 0
358
+ const resize = () => {
359
+ cancelAnimationFrame(frame)
360
+ frame = requestAnimationFrame(() => setScale(surface.getBoundingClientRect().width / 1280))
361
+ }
362
+ resize()
363
+ const observer = new ResizeObserver(resize)
364
+ observer.observe(surface.parentElement!)
365
+ return () => {
366
+ observer.disconnect()
367
+ cancelAnimationFrame(frame)
368
+ }
369
+ }, [surface])
370
+ useEffect(() => {
371
+ let frame = 0
372
+ const update = () => {
373
+ cancelAnimationFrame(frame)
374
+ frame = requestAnimationFrame(() => {
375
+ setIndex(Number(surface.dataset.slideIndex || 0))
376
+ setSelected([])
377
+ setInlineText(null)
378
+ setVersion(v => v + 1)
379
+ })
380
+ }
381
+ const observer = new MutationObserver(update)
382
+ observer.observe(surface, {
383
+ attributes: true,
384
+ attributeFilter: ["data-slide-index"],
385
+ childList: true
386
+ })
387
+ return () => {
388
+ observer.disconnect()
389
+ cancelAnimationFrame(frame)
390
+ }
391
+ }, [surface])
392
+ useLayoutEffect(() => {
393
+ const active = surface.querySelector(`[data-slide-render-index="${index}"]`) || surface
394
+ const elements = Array.from(active.querySelectorAll<HTMLElement>("[data-ps-source]"))
395
+ const previous = originals.current
396
+ const next = new Map<
397
+ HTMLElement,
398
+ { style: string | null; html: string; src: string | null; hidden: boolean; source?: string }
399
+ >()
400
+ for (const el of elements) {
401
+ const saved = previous.get(el)
402
+ next.set(
403
+ el,
404
+ saved && saved.source === el.dataset.psSource
405
+ ? saved
406
+ : {
407
+ style: el.getAttribute("style"),
408
+ html: el.innerHTML,
409
+ src: el.getAttribute("src"),
410
+ hidden: el.hidden,
411
+ source: el.dataset.psSource
412
+ }
413
+ )
414
+ }
415
+ originals.current = next
416
+ setLayers(elements.map(sourceOf).filter((s): s is Selection => !!s))
417
+ }, [surface, index, version])
418
+ useLayoutEffect(() => {
419
+ clearCopies()
420
+ for (const [el, original] of originals.current) {
421
+ if (!el.isConnected) continue
422
+ if (touched.current.has(el)) {
423
+ if (original.style === null) el.removeAttribute("style")
424
+ else el.setAttribute("style", original.style)
425
+ if (!el.querySelector("[data-ps-source]") && el.innerHTML !== original.html)
426
+ el.innerHTML = original.html
427
+ if (original.src === null) el.removeAttribute("src")
428
+ else el.setAttribute("src", original.src)
429
+ el.hidden = original.hidden
430
+ }
431
+ const s = sourceOf(el)
432
+ if (!s) continue
433
+ if (draft[s.path] && draft[s.path]!.revision !== s.revision) continue
434
+ const shared = draft[s.path]?.edits.find(e => e.start === s.start && e.item === undefined)
435
+ const item =
436
+ s.item === undefined
437
+ ? undefined
438
+ : draft[s.path]?.edits.find(e => e.start === s.start && e.item === s.item)
439
+ const edit = shared || item ? { ...shared, ...item, style: shared?.style } : undefined
440
+ if (!edit) continue
441
+ touched.current.add(el)
442
+ if (edit.style) Object.assign(el.style, edit.style)
443
+ if (edit.text !== undefined) el.textContent = edit.text
444
+ if (edit.richText) {
445
+ const value = el.textContent || "",
446
+ { from, to, style } = edit.richText
447
+ const span = document.createElement("span")
448
+ span.textContent = value.slice(from, to)
449
+ Object.assign(span.style, style)
450
+ el.replaceChildren(
451
+ document.createTextNode(value.slice(0, from)),
452
+ span,
453
+ document.createTextNode(value.slice(to))
454
+ )
455
+ }
456
+ if (edit.props?.src) el.setAttribute("src", edit.props.src)
457
+ if (edit.action === "delete") el.hidden = true
458
+ }
459
+ for (const [el] of originals.current) {
460
+ const source = sourceOf(el)
461
+ if (!source || !el.isConnected) continue
462
+ const file = draft[source.path]
463
+ const edit =
464
+ file?.revision === source.revision
465
+ ? file.edits.find(
466
+ candidate => candidate.start === source.start && candidate.action === "duplicate"
467
+ )
468
+ : undefined
469
+ if (!edit) continue
470
+ let previous: Element = el
471
+ for (let i = 0; i < (edit.copies ?? 1); i++) {
472
+ const copy = el.cloneNode(true) as HTMLElement
473
+ for (const node of [copy, ...copy.querySelectorAll("[data-ps-source]")]) {
474
+ node.removeAttribute("data-ps-source")
475
+ node.removeAttribute("data-ps-item")
476
+ node.removeAttribute("id")
477
+ }
478
+ copy.style.pointerEvents = "none"
479
+ previous.after(copy)
480
+ copies.current.push(copy)
481
+ previous = copy
482
+ }
483
+ }
484
+ for (const [path, file] of Object.entries(draft)) {
485
+ const source = sources[path]
486
+ if (!source || source.revision !== file.revision) continue
487
+ for (const edit of file.edits) {
488
+ const node = source.nodes.find(candidate => candidate.start === edit.start)
489
+ for (const [prop, value] of Object.entries(edit.props || {})) {
490
+ const target = node?.propTargets?.[prop]
491
+ if (target === undefined) continue
492
+ const owners = source.nodes.filter(candidate =>
493
+ Object.values(candidate.propTargets || {}).includes(target)
494
+ )
495
+ if (owners.length !== 1) continue
496
+ for (const [element] of originals.current) {
497
+ const origin = sourceOf(element)
498
+ if (origin?.path === path && origin.start === target) {
499
+ element.textContent = value
500
+ touched.current.add(element)
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
506
+ redraw(value => value + 1)
507
+ }, [draft, index, version, surface, sources])
508
+ useEffect(() => {
509
+ try {
510
+ sessionStorage.setItem("promptslide:editor-draft", JSON.stringify(draft))
511
+ } catch {
512
+ /* Storage can be disabled. */
513
+ }
514
+ }, [draft])
515
+ useEffect(() => {
516
+ const before = (event: BeforeUnloadEvent) => {
517
+ if (Object.keys(draftRef.current).length) {
518
+ event.preventDefault()
519
+ event.returnValue = ""
520
+ }
521
+ }
522
+ window.addEventListener("beforeunload", before)
523
+ return () => window.removeEventListener("beforeunload", before)
524
+ }, [])
525
+ useEffect(() => {
526
+ // Prevent the presentation's global keyboard handler from advancing behind the editor.
527
+ const handler = (event: KeyboardEvent) => {
528
+ event.stopImmediatePropagation()
529
+ if (confirmClose) return
530
+ if (busy) return
531
+ const input = (event.target as HTMLElement).closest("input,textarea,select,[contenteditable]")
532
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
533
+ event.preventDefault()
534
+ void save()
535
+ return
536
+ }
537
+ if (input) return
538
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
539
+ event.preventDefault()
540
+ if (event.shiftKey) redo()
541
+ else undo()
542
+ return
543
+ }
544
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "y") {
545
+ event.preventDefault()
546
+ redo()
547
+ return
548
+ }
549
+ if (event.key === "Escape") {
550
+ setSelected([])
551
+ setInlineText(null)
552
+ return
553
+ }
554
+ if (event.key.startsWith("Arrow") && selected.length) {
555
+ event.preventDefault()
556
+ const amount = event.shiftKey ? 10 : 1
557
+ move(
558
+ selected,
559
+ event.key === "ArrowRight" ? amount : event.key === "ArrowLeft" ? -amount : 0,
560
+ event.key === "ArrowDown" ? amount : event.key === "ArrowUp" ? -amount : 0
561
+ )
562
+ }
563
+ if (event.key === "Delete" || event.key === "Backspace") {
564
+ if (!selected.length) return
565
+ event.preventDefault()
566
+ const deletable = selected.filter(selection => {
567
+ const origin = selection.element && sourceOf(selection.element)
568
+ return (
569
+ origin &&
570
+ !appearanceLocked(selection) &&
571
+ sources[origin.path]?.nodes.find(node => node.start === origin.start)?.structural
572
+ )
573
+ })
574
+ change(deletable, { action: "delete" })
575
+ }
576
+ }
577
+ window.addEventListener("keydown", handler, true)
578
+ return () => window.removeEventListener("keydown", handler, true)
579
+ })
580
+ function rect(el: HTMLElement): Rect {
581
+ const r = el.getBoundingClientRect(),
582
+ base = canvas.current!.getBoundingClientRect()
583
+ return {
584
+ x: (r.left - base.left) / scale,
585
+ y: (r.top - base.top) / scale,
586
+ width: r.width / scale,
587
+ height: r.height / scale
588
+ }
589
+ }
590
+ function translation(s: Selection) {
591
+ const style = draftRef.current[s.path]?.edits.find(e => e.start === s.start)?.style
592
+ return String(style?.translate ?? (s.element ? getComputedStyle(s.element).translate : "none"))
593
+ }
594
+ function move(items: Selection[], dx: number, dy: number) {
595
+ change(items, s => ({ style: { translate: translated(translation(s), dx, dy) } }))
596
+ }
597
+ function gesture(
598
+ event: React.PointerEvent,
599
+ kind: "move" | "resize" | "rotate",
600
+ target?: Selection
601
+ ) {
602
+ if (event.button !== 0 || busy || unsupportedSelection || (shared && !sharedAppearanceEnabled))
603
+ return
604
+ const items = target ? [target] : selected
605
+ if (!items.length || !items.every(s => s.element)) return
606
+ surface.focus({ preventScroll: true })
607
+ event.preventDefault()
608
+ event.stopPropagation()
609
+ const startX = event.clientX,
610
+ startY = event.clientY
611
+ const initial = items.map(s => ({
612
+ s,
613
+ rect: rect(s.element!),
614
+ translation: translation(s),
615
+ rotate: parseFloat(s.element!.style.rotate) || 0,
616
+ css: s.element!.getAttribute("style")
617
+ }))
618
+ const bounds = initial[0]!.rect
619
+ let patches: Partial<Edit>[] = []
620
+ const others = layers
621
+ .filter(l => l.element && !items.some(s => s.element === l.element))
622
+ .map(l => rect(l.element!))
623
+ const update = (e: PointerEvent) => {
624
+ let dx = (e.clientX - startX) / scale,
625
+ dy = (e.clientY - startY) / scale
626
+ if (kind === "move" && !e.altKey) {
627
+ const snapped = snap({ ...bounds, x: bounds.x + dx, y: bounds.y + dy }, others, 6 / scale)
628
+ dx = snapped.x - bounds.x
629
+ dy = snapped.y - bounds.y
630
+ setGuides({ x: snapped.guideX, y: snapped.guideY })
631
+ }
632
+ patches = initial.map(({ s, rect: r, translation: initialTranslation, rotate }) => {
633
+ const style: Style =
634
+ kind === "move"
635
+ ? { translate: translated(initialTranslation, dx, dy) }
636
+ : kind === "rotate"
637
+ ? { rotate: `${rotate + dx / 2}deg` }
638
+ : {
639
+ width: `${Math.max(8, r.width + dx)}px`,
640
+ height: `${Math.max(8, r.height + dy)}px`,
641
+ boxSizing: "border-box",
642
+ flexShrink: 0
643
+ }
644
+ Object.assign(s.element!.style, style)
645
+ return { style }
646
+ })
647
+ redraw(value => value + 1)
648
+ }
649
+ const finish = (e: PointerEvent) => {
650
+ window.removeEventListener("pointermove", update)
651
+ window.removeEventListener("pointerup", finish)
652
+ window.removeEventListener("pointercancel", finish)
653
+ setGuides({})
654
+ if (e.type === "pointercancel") {
655
+ for (const { s, css } of initial) {
656
+ if (css === null) s.element!.removeAttribute("style")
657
+ else s.element!.setAttribute("style", css)
658
+ }
659
+ return
660
+ }
661
+ if (patches.length) change(items, s => patches[items.indexOf(s)]!)
662
+ }
663
+ window.addEventListener("pointermove", update)
664
+ window.addEventListener("pointerup", finish)
665
+ window.addEventListener("pointercancel", finish)
666
+ }
667
+ async function save() {
668
+ if (busy || !Object.keys(draftRef.current).length) return false
669
+ setBusy(true)
670
+ try {
671
+ await request({
672
+ action: "save",
673
+ files: Object.entries(draftRef.current).map(([path, file]) => ({ path, ...file }))
674
+ })
675
+ setDraft({})
676
+ setPast([])
677
+ setFuture([])
678
+ setSources({})
679
+ setSelected([])
680
+ setInlineText(null)
681
+ setMessage("All changes saved")
682
+ window.dispatchEvent(new Event("promptslide:editor-refresh"))
683
+ setVersion(v => v + 1)
684
+ return true
685
+ } catch (error) {
686
+ setMessage(error instanceof Error ? error.message : "Something went wrong. Please try again.")
687
+ setCloseError(error instanceof Error ? error.message : "Could not save. Please try again.")
688
+ return false
689
+ } finally {
690
+ setBusy(false)
691
+ }
692
+ }
693
+ function discard() {
694
+ restore()
695
+ setDraft({})
696
+ setPast([])
697
+ setFuture([])
698
+ setSelected([])
699
+ setSources({})
700
+ touched.current.clear()
701
+ window.dispatchEvent(new Event("promptslide:editor-refresh"))
702
+ setVersion(v => v + 1)
703
+ setMessage("Changes discarded")
704
+ }
705
+ function exportDraft() {
706
+ const url = URL.createObjectURL(
707
+ new Blob([JSON.stringify(draft, null, 2)], { type: "application/json" })
708
+ )
709
+ const a = document.createElement("a")
710
+ a.href = url
711
+ a.download = "promptslide-editor-draft.json"
712
+ a.click()
713
+ URL.revokeObjectURL(url)
714
+ }
715
+ function align(axis: "x" | "y", mode: "start" | "center" | "end" | "distribute") {
716
+ const items = selected.filter(s => s.element)
717
+ if (items.length < 2) return
718
+ const rects = items.map(s => rect(s.element!)),
719
+ positions = aligned(rects, axis, mode)
720
+ change(items, s => {
721
+ const i = items.indexOf(s),
722
+ delta = positions[i]! - rects[i]![axis]
723
+ return {
724
+ style: {
725
+ translate: translated(translation(s), axis === "x" ? delta : 0, axis === "y" ? delta : 0)
726
+ }
727
+ }
728
+ })
729
+ }
730
+ function beginInline() {
731
+ if (current?.element && currentText !== null && currentText !== undefined)
732
+ setInlineText(currentEdit?.text ?? currentText)
733
+ else setMessage("Select a text box to edit it.")
734
+ }
735
+ function drillInto(event: { clientX: number; clientY: number }, item: Selection) {
736
+ const descendant = document
737
+ .elementsFromPoint(event.clientX, event.clientY)
738
+ .map(element => element.closest<HTMLElement>("[data-ps-source]"))
739
+ .find(element => element && element !== item.element && item.element?.contains(element))
740
+ const next = descendant && sourceOf(descendant)
741
+ if (next) void inspect([next])
742
+ else beginInline()
743
+ }
744
+ useEffect(() => {
745
+ const clearOutside = (event: PointerEvent) => {
746
+ const target = event.target
747
+ if (
748
+ confirmClose ||
749
+ busy ||
750
+ !(target instanceof Node) ||
751
+ surface.contains(target) ||
752
+ overlay.contains(target) ||
753
+ panel.contains(target) ||
754
+ (target instanceof Element && target.closest("[data-ps-viewer-toolbar]"))
755
+ )
756
+ return
757
+ if (inlineText !== null && current && inlineText !== (currentEdit?.text ?? currentText))
758
+ change([current], { text: inlineText })
759
+ setInlineText(null)
760
+ setSelected([])
761
+ setGuides({})
762
+ }
763
+ document.addEventListener("pointerdown", clearOutside, true)
764
+ return () => document.removeEventListener("pointerdown", clearOutside, true)
765
+ })
766
+ useEffect(() => {
767
+ const click = (event: MouseEvent) => {
768
+ if (busy) return
769
+ event.preventDefault()
770
+ event.stopPropagation()
771
+ const element = (event.target as HTMLElement).closest<HTMLElement>("[data-ps-source]")
772
+ if (!element) {
773
+ setSelected([])
774
+ return
775
+ }
776
+ const item = sourceOf(element)
777
+ if (!item) return
778
+ void inspect(
779
+ event.shiftKey
780
+ ? selected.some(s => s.element === element)
781
+ ? selected.filter(s => s.element !== element)
782
+ : [...selected, item]
783
+ : [item]
784
+ )
785
+ }
786
+ const doubleClick = (event: MouseEvent) => {
787
+ event.preventDefault()
788
+ event.stopPropagation()
789
+ beginInline()
790
+ }
791
+ surface.addEventListener("click", click, true)
792
+ surface.addEventListener("dblclick", doubleClick, true)
793
+ return () => {
794
+ surface.removeEventListener("click", click, true)
795
+ surface.removeEventListener("dblclick", doubleClick, true)
796
+ }
797
+ })
798
+ function closeAfterResolution() {
799
+ try {
800
+ sessionStorage.removeItem("promptslide:editor-draft")
801
+ } catch {
802
+ /* Storage may be unavailable. */
803
+ }
804
+ close()
805
+ }
806
+ function finishEditing() {
807
+ if (dirty) {
808
+ setCloseError("")
809
+ setConfirmClose(true)
810
+ return
811
+ }
812
+ restore()
813
+ close()
814
+ }
815
+ useEffect(() => {
816
+ window.addEventListener("promptslide:editor-done", finishEditing)
817
+ return () => window.removeEventListener("promptslide:editor-done", finishEditing)
818
+ })
819
+ const renderedText = currentEdit?.text ?? currentText ?? ""
820
+ const computed = current?.element ? getComputedStyle(current.element) : undefined
821
+ const css = (name: string, fallback = "") =>
822
+ String(
823
+ currentEdit?.style?.[name] ??
824
+ (computed as unknown as Record<string, string> | undefined)?.[name] ??
825
+ fallback
826
+ )
827
+ const number = (name: string, fallback = 0) => parseFloat(css(name)) || fallback
828
+ const put = (style: Style) => {
829
+ if (!shared || sharedAppearanceEnabled) change(selected, { style })
830
+ }
831
+ const selectionKind = current?.element ? elementKind(current.element) : "Text"
832
+ const isText =
833
+ selectionKind === "Text" ||
834
+ selectionKind === "Heading" ||
835
+ (currentText !== null && currentText !== undefined)
836
+ const isImage = current?.element?.tagName === "IMG"
837
+ const box = current?.element?.isConnected ? rect(current.element) : undefined
838
+ const sharedCount = formattingSource
839
+ ? layers.filter(
840
+ item => item.path === formattingSource.path && item.start === formattingSource.start
841
+ ).length
842
+ : 0
843
+ const componentAppearance = Boolean(current?.textProp)
844
+ const shared = componentAppearance || sharedCount > 1
845
+ const editingLocked = unsupportedSelection || selected.some(appearanceLocked)
846
+ const renderedNode =
847
+ formattingSource &&
848
+ sources[formattingSource.path]?.nodes.find(node => node.start === formattingSource.start)
849
+ const structureAllowed = Boolean(renderedNode?.structural) && !editingLocked
850
+ function formatText(format: "bold" | "italic" | "underline") {
851
+ const from = textArea.current?.selectionStart || 0,
852
+ to = textArea.current?.selectionEnd || 0
853
+ const style: Style =
854
+ format === "bold"
855
+ ? { fontWeight: Number(css("fontWeight", "400")) >= 600 ? 400 : 700 }
856
+ : format === "italic"
857
+ ? { fontStyle: css("fontStyle") === "italic" ? "normal" : "italic" }
858
+ : {
859
+ textDecoration: css("textDecorationLine").includes("underline") ? "none" : "underline"
860
+ }
861
+ if (
862
+ to > from &&
863
+ !currentItem &&
864
+ currentEdit?.text === undefined &&
865
+ current &&
866
+ !current.textProp
867
+ )
868
+ change([current], { richText: { from, to, style } })
869
+ else put(style)
870
+ }
871
+ function selectRelative(direction: "parent" | "child") {
872
+ const element =
873
+ direction === "parent"
874
+ ? current?.element?.parentElement?.closest<HTMLElement>("[data-ps-source]")
875
+ : current?.element?.querySelector<HTMLElement>("[data-ps-source]")
876
+ const next = element && sourceOf(element)
877
+ if (next) void inspect([next])
878
+ }
879
+ const selectedName = selected.length > 1 ? `${selected.length} elements` : selectionKind
880
+ return (
881
+ <>
882
+ {confirmClose &&
883
+ createPortal(
884
+ <dialog
885
+ ref={closeDialog}
886
+ className="ps-close-dialog"
887
+ aria-labelledby="ps-close-title"
888
+ onCancel={event => {
889
+ event.preventDefault()
890
+ if (!busy) setConfirmClose(false)
891
+ }}
892
+ >
893
+ <h2 id="ps-close-title">Save your changes?</h2>
894
+ <p>
895
+ You have unsaved changes to this deck. Save them before leaving the editor, or discard
896
+ them.
897
+ </p>
898
+ {closeError && (
899
+ <p role="alert" className="ps-close-error">
900
+ {closeError}
901
+ </p>
902
+ )}
903
+ <div className="ps-close-actions">
904
+ <button disabled={busy} onClick={() => setConfirmClose(false)}>
905
+ Keep editing
906
+ </button>
907
+ <button
908
+ disabled={busy}
909
+ onClick={() => {
910
+ discard()
911
+ closeAfterResolution()
912
+ }}
913
+ >
914
+ Discard changes
915
+ </button>
916
+ <button
917
+ className="ps-close-save"
918
+ disabled={busy}
919
+ onClick={async () => {
920
+ setCloseError("")
921
+ if (await save()) closeAfterResolution()
922
+ }}
923
+ >
924
+ <Check size={14} />
925
+ {busy ? "Saving…" : "Save changes"}
926
+ </button>
927
+ </div>
928
+ </dialog>,
929
+ document.body
930
+ )}
931
+ {createPortal(
932
+ <aside className="ps-panel" aria-label="Format panel">
933
+ <div className="ps-panel-heading">
934
+ <div>
935
+ <span className="ps-overline">SLIDE EDITOR</span>
936
+ <h2>{current ? selectedName : "Make it yours"}</h2>
937
+ </div>
938
+ <IconButton label="Done editing" disabled={busy} onClick={finishEditing}>
939
+ <X size={18} />
940
+ </IconButton>
941
+ </div>
942
+ <div className="ps-panel-tools">
943
+ <div className="ps-tabs" role="tablist" aria-label="Editing tools">
944
+ <button role="tab" aria-selected={tab === "design"} onClick={() => setTab("design")}>
945
+ Design
946
+ </button>
947
+ <button
948
+ role="tab"
949
+ disabled={
950
+ unsupportedSelection ||
951
+ (shared && !sharedAppearanceEnabled) ||
952
+ (!!current && !current.element)
953
+ }
954
+ aria-selected={tab === "arrange"}
955
+ onClick={() => setTab("arrange")}
956
+ >
957
+ Arrange
958
+ </button>
959
+ </div>
960
+ <IconButton label="Undo" disabled={!past.length || busy} onClick={undo}>
961
+ <Undo2 size={16} />
962
+ </IconButton>
963
+ <IconButton label="Redo" disabled={!future.length || busy} onClick={redo}>
964
+ <Redo2 size={16} />
965
+ </IconButton>
966
+ </div>
967
+ {unsupportedSelection && (
968
+ <section className="ps-selection-notice" role="status">
969
+ <h3>Not directly editable</h3>
970
+ <p>This element can’t be edited here yet. Select another element to make changes.</p>
971
+ <button className="ps-text-button" onClick={() => selectRelative("parent")}>
972
+ Select containing group
973
+ </button>
974
+ </section>
975
+ )}
976
+ <div className="ps-panel-body" inert={busy}>
977
+ {!current ? (
978
+ <div className="ps-empty">
979
+ <div className="ps-empty-art" aria-hidden="true">
980
+ <div />
981
+ <div />
982
+ <div />
983
+ <MousePointer2 size={25} />
984
+ </div>
985
+ <h3>
986
+ Select something
987
+ <br />
988
+ on your slide
989
+ </h3>
990
+ <p>
991
+ Click text, an image, or a shape
992
+ <br />
993
+ to make it your own.
994
+ </p>
995
+ <span className="ps-shortcut">
996
+ Hold <kbd>Shift</kbd> to select several
997
+ </span>
998
+ </div>
999
+ ) : unsupportedSelection ? null : (
1000
+ <>
1001
+ {shared && (
1002
+ <section className="ps-shared" aria-label="Shared component">
1003
+ <div className="ps-shared-heading">
1004
+ <span className="ps-shared-icon">
1005
+ <Layers size={16} />
1006
+ </span>
1007
+ <h3>Shared component</h3>
1008
+ <span className="ps-shared-badge">
1009
+ {sharedAppearanceEnabled ? "Editing" : "Locked"}
1010
+ </span>
1011
+ </div>
1012
+ <p>
1013
+ {componentAppearance || currentItem
1014
+ ? "Edit the text for this item. Design changes apply wherever this component is used."
1015
+ : "Design changes apply to every use of this element, including other slides."}
1016
+ </p>
1017
+ {!sharedAppearanceEnabled && (
1018
+ <button
1019
+ className="ps-shared-action"
1020
+ aria-label="Edit shared component"
1021
+ onClick={() => {
1022
+ setSharedAppearanceEnabled(true)
1023
+ setTab("design")
1024
+ }}
1025
+ >
1026
+ <Pencil size={14} />
1027
+ Edit shared component
1028
+ </button>
1029
+ )}
1030
+ </section>
1031
+ )}
1032
+ {!current.element && (
1033
+ <p className="ps-note">
1034
+ Only the content fields below can be edited here. Select an element on the slide
1035
+ to change its appearance.
1036
+ </p>
1037
+ )}
1038
+ {tab === "design" ? (
1039
+ <>
1040
+ {currentText !== null && currentText !== undefined && (
1041
+ <section className="ps-section">
1042
+ <div className="ps-section-heading">
1043
+ <h3>Text</h3>
1044
+ <span>Double-click to edit on slide</span>
1045
+ </div>
1046
+ <textarea
1047
+ className="ps-text-input"
1048
+ aria-label="Text"
1049
+ ref={textArea}
1050
+ value={renderedText}
1051
+ onChange={event => change([current], { text: event.target.value })}
1052
+ />
1053
+ </section>
1054
+ )}
1055
+ {currentNode &&
1056
+ Object.entries(currentNode.props)
1057
+ .filter(
1058
+ ([name]) =>
1059
+ name !== current.textProp &&
1060
+ ["title", "subtitle", "eyebrow", "src", "alt"].includes(name)
1061
+ )
1062
+ .map(([name, value]) => (
1063
+ <label className="ps-field" key={name}>
1064
+ <span>
1065
+ {
1066
+ (
1067
+ {
1068
+ title: "Title",
1069
+ subtitle: "Subtitle",
1070
+ eyebrow: "Label",
1071
+ src: "Image address",
1072
+ alt: "Image description"
1073
+ } as Record<string, string>
1074
+ )[name]
1075
+ }
1076
+ </span>
1077
+ <input
1078
+ value={currentEdit?.props?.[name] ?? value}
1079
+ onChange={event =>
1080
+ change([current], { props: { [name]: event.target.value } })
1081
+ }
1082
+ />
1083
+ </label>
1084
+ ))}
1085
+ <fieldset
1086
+ disabled={shared && !sharedAppearanceEnabled}
1087
+ style={{ border: 0, padding: 0, margin: 0, minWidth: 0 }}
1088
+ >
1089
+ {isText && current.element && (
1090
+ <section className="ps-section">
1091
+ <h3>Font</h3>
1092
+ <div className="ps-font-row">
1093
+ <label className="ps-field">
1094
+ <span className="ps-sr-only">Font</span>
1095
+ <select
1096
+ aria-label="Font"
1097
+ value={css("fontFamily").split(",")[0]!.replace(/"/g, "")}
1098
+ onChange={event => put({ fontFamily: event.target.value })}
1099
+ >
1100
+ {Array.from(
1101
+ new Set([
1102
+ css("fontFamily").split(",")[0]!.replace(/"/g, ""),
1103
+ "Inter",
1104
+ "Arial",
1105
+ "Helvetica",
1106
+ "Georgia",
1107
+ "Times New Roman",
1108
+ "Courier New"
1109
+ ])
1110
+ ).map(font => (
1111
+ <option key={font}>{font}</option>
1112
+ ))}
1113
+ </select>
1114
+ </label>
1115
+ <NumberField
1116
+ label="Font size"
1117
+ value={number("fontSize", 16)}
1118
+ min={1}
1119
+ onChange={value => put({ fontSize: `${value}px` })}
1120
+ />
1121
+ </div>
1122
+ <div className="ps-format-row">
1123
+ <div className="ps-segments">
1124
+ <IconButton
1125
+ label="Bold"
1126
+ pressed={Number(css("fontWeight", "400")) >= 600}
1127
+ onClick={() => formatText("bold")}
1128
+ >
1129
+ <Bold size={16} />
1130
+ </IconButton>
1131
+ <IconButton
1132
+ label="Italic"
1133
+ pressed={css("fontStyle") === "italic"}
1134
+ onClick={() => formatText("italic")}
1135
+ >
1136
+ <Italic size={16} />
1137
+ </IconButton>
1138
+ <IconButton
1139
+ label="Underline"
1140
+ pressed={css("textDecorationLine").includes("underline")}
1141
+ onClick={() => formatText("underline")}
1142
+ >
1143
+ <Underline size={16} />
1144
+ </IconButton>
1145
+ </div>
1146
+ <div className="ps-segments">
1147
+ {[
1148
+ { value: "left", label: "Align text left", icon: AlignLeft },
1149
+ { value: "center", label: "Center text", icon: AlignCenter },
1150
+ { value: "right", label: "Align text right", icon: AlignRight }
1151
+ ].map(({ value, label, icon: Icon }) => (
1152
+ <IconButton
1153
+ key={value}
1154
+ label={label}
1155
+ pressed={css("textAlign") === value}
1156
+ onClick={() => put({ textAlign: value })}
1157
+ >
1158
+ <Icon size={16} />
1159
+ </IconButton>
1160
+ ))}
1161
+ </div>
1162
+ </div>
1163
+ <ColorField
1164
+ label="Text color"
1165
+ value={computed?.color || "#ffffff"}
1166
+ onChange={color => put({ color })}
1167
+ />
1168
+ <div className="ps-palette">
1169
+ <span>Deck colors</span>
1170
+ {[
1171
+ { name: "Accent", value: "var(--primary)" },
1172
+ { name: "Text", value: "var(--foreground)" },
1173
+ { name: "Muted", value: "var(--muted-foreground)" },
1174
+ { name: "White", value: "#ffffff" },
1175
+ { name: "Dark", value: "#171717" }
1176
+ ].map(color => (
1177
+ <button
1178
+ key={color.name}
1179
+ className="ps-swatch"
1180
+ aria-label={`${color.name} text color`}
1181
+ title={color.name}
1182
+ style={{ background: color.value }}
1183
+ onClick={() => put({ color: color.value })}
1184
+ />
1185
+ ))}
1186
+ </div>
1187
+ <details className="ps-details">
1188
+ <summary>
1189
+ Text spacing
1190
+ <ChevronDown size={14} />
1191
+ </summary>
1192
+ <div className="ps-two-col">
1193
+ <NumberField
1194
+ label="Line spacing"
1195
+ value={
1196
+ number("lineHeight", number("fontSize", 16) * 1.5) /
1197
+ number("fontSize", 16)
1198
+ }
1199
+ min={0.5}
1200
+ step={0.1}
1201
+ onChange={value => put({ lineHeight: value })}
1202
+ />
1203
+ <NumberField
1204
+ label="Letter spacing"
1205
+ value={number("letterSpacing")}
1206
+ step={0.1}
1207
+ onChange={value => put({ letterSpacing: `${value}px` })}
1208
+ />
1209
+ </div>
1210
+ </details>
1211
+ </section>
1212
+ )}
1213
+ {current.element && (
1214
+ <section className="ps-section">
1215
+ <h3>Appearance</h3>
1216
+ {selectionKind === "Icon" && (
1217
+ <>
1218
+ <ColorField
1219
+ label="Icon color"
1220
+ value={css("color", "#ffffff")}
1221
+ onChange={color => put({ color })}
1222
+ />
1223
+ <NumberField
1224
+ label="Line thickness"
1225
+ value={number("strokeWidth", 2)}
1226
+ min={0.5}
1227
+ step={0.5}
1228
+ onChange={strokeWidth => put({ strokeWidth })}
1229
+ />
1230
+ </>
1231
+ )}
1232
+ <ColorField
1233
+ label="Fill"
1234
+ value={computed?.backgroundColor || "transparent"}
1235
+ onChange={backgroundColor => put({ backgroundColor })}
1236
+ clear={() => put({ backgroundColor: "transparent" })}
1237
+ />
1238
+ <div className="ps-two-col">
1239
+ <NumberField
1240
+ label="Corner rounding"
1241
+ value={number("borderRadius")}
1242
+ min={0}
1243
+ onChange={value => put({ borderRadius: `${value}px` })}
1244
+ />
1245
+ <NumberField
1246
+ label="Border width"
1247
+ value={number("borderTopWidth")}
1248
+ min={0}
1249
+ onChange={value =>
1250
+ put({ borderWidth: `${value}px`, borderStyle: "solid" })
1251
+ }
1252
+ />
1253
+ </div>
1254
+ {number("borderTopWidth") > 0 && (
1255
+ <ColorField
1256
+ label="Border color"
1257
+ value={css("borderTopColor", "#ffffff")}
1258
+ onChange={borderColor => put({ borderColor })}
1259
+ />
1260
+ )}
1261
+ <label className="ps-range">
1262
+ <span>
1263
+ Opacity
1264
+ <strong>{Math.round(Number(css("opacity", "1")) * 100)}%</strong>
1265
+ </span>
1266
+ <input
1267
+ aria-label="Opacity"
1268
+ type="range"
1269
+ min={0}
1270
+ max={100}
1271
+ value={Math.round(Number(css("opacity", "1")) * 100)}
1272
+ onChange={event => put({ opacity: Number(event.target.value) / 100 })}
1273
+ />
1274
+ </label>
1275
+ </section>
1276
+ )}
1277
+ {isImage && (
1278
+ <section className="ps-section">
1279
+ <h3>Image</h3>
1280
+ <label className="ps-field">
1281
+ <span>Fit</span>
1282
+ <select
1283
+ aria-label="Image fit"
1284
+ value={css("objectFit", "fill")}
1285
+ onChange={event => put({ objectFit: event.target.value })}
1286
+ >
1287
+ <option value="cover">Fill the frame</option>
1288
+ <option value="contain">Fit entire image</option>
1289
+ <option value="fill">Stretch to fit</option>
1290
+ </select>
1291
+ </label>
1292
+ <label className="ps-field">
1293
+ <span>Focus</span>
1294
+ <select
1295
+ aria-label="Image focus"
1296
+ value={css("objectPosition", "50% 50%")}
1297
+ onChange={event => put({ objectPosition: event.target.value })}
1298
+ >
1299
+ {[
1300
+ ["Center", "50% 50%"],
1301
+ ["Top", "50% 0%"],
1302
+ ["Bottom", "50% 100%"],
1303
+ ["Left", "0% 50%"],
1304
+ ["Right", "100% 50%"]
1305
+ ].map(([name, value]) => (
1306
+ <option value={value} key={name}>
1307
+ {name}
1308
+ </option>
1309
+ ))}
1310
+ </select>
1311
+ </label>
1312
+ <label className="ps-range">
1313
+ <span>
1314
+ Crop edges
1315
+ <strong>
1316
+ {parseFloat(css("clipPath").match(/inset\(([^%]+)/)?.[1] || "0")}%
1317
+ </strong>
1318
+ </span>
1319
+ <input
1320
+ aria-label="Crop edges"
1321
+ type="range"
1322
+ min={0}
1323
+ max={45}
1324
+ value={parseFloat(
1325
+ css("clipPath").match(/inset\(([^%]+)/)?.[1] || "0"
1326
+ )}
1327
+ onChange={event => put({ clipPath: `inset(${event.target.value}%)` })}
1328
+ />
1329
+ </label>
1330
+ </section>
1331
+ )}
1332
+ {current.element && (
1333
+ <details className="ps-details ps-section">
1334
+ <summary>
1335
+ Spacing
1336
+ <ChevronDown size={14} />
1337
+ </summary>
1338
+ <div className="ps-two-col">
1339
+ {["Top", "Right", "Bottom", "Left"].map(side => (
1340
+ <NumberField
1341
+ key={side}
1342
+ label={`Inside ${side.toLowerCase()}`}
1343
+ value={number(`padding${side}`)}
1344
+ min={0}
1345
+ onChange={value => put({ [`padding${side}`]: `${value}px` })}
1346
+ />
1347
+ ))}
1348
+ </div>
1349
+ {["flex", "grid"].includes(css("display")) && (
1350
+ <NumberField
1351
+ label="Space between items"
1352
+ value={number("gap")}
1353
+ min={0}
1354
+ onChange={value => put({ gap: `${value}px` })}
1355
+ />
1356
+ )}
1357
+ </details>
1358
+ )}
1359
+ {!current.element && (
1360
+ <p className="ps-note">Save to update this linked content on your slide.</p>
1361
+ )}
1362
+ </fieldset>
1363
+ </>
1364
+ ) : (
1365
+ <>
1366
+ {box && (
1367
+ <section className="ps-section">
1368
+ <h3>Position & size</h3>
1369
+ <div className="ps-two-col">
1370
+ <NumberField
1371
+ label="Horizontal position"
1372
+ shortLabel="X"
1373
+ value={box.x}
1374
+ onChange={value => move(selected, value - box.x, 0)}
1375
+ />
1376
+ <NumberField
1377
+ label="Vertical position"
1378
+ shortLabel="Y"
1379
+ value={box.y}
1380
+ onChange={value => move(selected, 0, value - box.y)}
1381
+ />
1382
+ <NumberField
1383
+ label="Width"
1384
+ value={box.width}
1385
+ min={1}
1386
+ onChange={value =>
1387
+ put({ width: `${value}px`, boxSizing: "border-box", flexShrink: 0 })
1388
+ }
1389
+ />
1390
+ <NumberField
1391
+ label="Height"
1392
+ value={box.height}
1393
+ min={1}
1394
+ onChange={value =>
1395
+ put({ height: `${value}px`, boxSizing: "border-box", flexShrink: 0 })
1396
+ }
1397
+ />
1398
+ </div>
1399
+ <NumberField
1400
+ label="Rotation"
1401
+ value={number("rotate")}
1402
+ suffix="°"
1403
+ onChange={value => put({ rotate: `${value}deg` })}
1404
+ />
1405
+ </section>
1406
+ )}
1407
+ <section className="ps-section">
1408
+ <div className="ps-section-heading">
1409
+ <h3>Align elements</h3>
1410
+ </div>
1411
+ <div className="ps-align-buttons">
1412
+ {(
1413
+ [
1414
+ {
1415
+ axis: "x",
1416
+ mode: "start",
1417
+ name: "Align left",
1418
+ icon: AlignStartVertical
1419
+ },
1420
+ {
1421
+ axis: "x",
1422
+ mode: "center",
1423
+ name: "Align horizontal centers",
1424
+ icon: AlignCenterVertical
1425
+ },
1426
+ { axis: "x", mode: "end", name: "Align right", icon: AlignEndVertical },
1427
+ {
1428
+ axis: "y",
1429
+ mode: "start",
1430
+ name: "Align top",
1431
+ icon: AlignStartHorizontal
1432
+ },
1433
+ {
1434
+ axis: "y",
1435
+ mode: "center",
1436
+ name: "Align vertical centers",
1437
+ icon: AlignCenterHorizontal
1438
+ },
1439
+ {
1440
+ axis: "y",
1441
+ mode: "end",
1442
+ name: "Align bottom",
1443
+ icon: AlignEndHorizontal
1444
+ }
1445
+ ] as const
1446
+ ).map(({ axis, mode, name, icon: Icon }) => (
1447
+ <IconButton
1448
+ key={name}
1449
+ label={name}
1450
+ disabled={selected.length < 2}
1451
+ onClick={() => align(axis, mode)}
1452
+ >
1453
+ <Icon size={19} />
1454
+ </IconButton>
1455
+ ))}
1456
+ </div>
1457
+ <div className="ps-two-col">
1458
+ <button
1459
+ className="ps-soft-button"
1460
+ disabled={selected.length < 3}
1461
+ onClick={() => align("x", "distribute")}
1462
+ >
1463
+ <MoveHorizontal size={16} />
1464
+ Space across
1465
+ </button>
1466
+ <button
1467
+ className="ps-soft-button"
1468
+ disabled={selected.length < 3}
1469
+ onClick={() => align("y", "distribute")}
1470
+ >
1471
+ <MoveVertical size={16} />
1472
+ Space down
1473
+ </button>
1474
+ </div>
1475
+ {selected.length < 2 && (
1476
+ <p className="ps-note">Shift-click another element to align them.</p>
1477
+ )}
1478
+ </section>
1479
+ <section className="ps-section">
1480
+ <h3>Layer order</h3>
1481
+ <div className="ps-two-col">
1482
+ <button
1483
+ className="ps-soft-button"
1484
+ disabled={!current.element}
1485
+ onClick={() =>
1486
+ put({
1487
+ position: css("position") === "static" ? "relative" : css("position"),
1488
+ zIndex: number("zIndex") + 1
1489
+ })
1490
+ }
1491
+ >
1492
+ <ArrowUpToLine size={16} />
1493
+ Bring forward
1494
+ </button>
1495
+ <button
1496
+ className="ps-soft-button"
1497
+ disabled={!current.element}
1498
+ onClick={() =>
1499
+ put({
1500
+ position: css("position") === "static" ? "relative" : css("position"),
1501
+ zIndex: number("zIndex") - 1
1502
+ })
1503
+ }
1504
+ >
1505
+ <ArrowDownToLine size={16} />
1506
+ Send backward
1507
+ </button>
1508
+ </div>
1509
+ </section>
1510
+ </>
1511
+ )}
1512
+ {currentEdit?.action === "duplicate" && (
1513
+ <p className="ps-note">Copy added. Save to edit copies separately.</p>
1514
+ )}
1515
+ <section className="ps-section ps-object-actions">
1516
+ <button
1517
+ className="ps-soft-button"
1518
+ disabled={!structureAllowed}
1519
+ onClick={() =>
1520
+ change([current], {
1521
+ action: "duplicate",
1522
+ copies: (currentEdit?.copies ?? 0) + 1
1523
+ })
1524
+ }
1525
+ >
1526
+ <Copy size={15} />
1527
+ Duplicate
1528
+ </button>
1529
+ <button
1530
+ className="ps-soft-button ps-danger"
1531
+ disabled={!structureAllowed}
1532
+ onClick={() => change([current], { action: "delete" })}
1533
+ >
1534
+ <Trash2 size={15} />
1535
+ Delete
1536
+ </button>
1537
+ {current.element && (
1538
+ <button className="ps-text-button" onClick={() => selectRelative("parent")}>
1539
+ Select group
1540
+ </button>
1541
+ )}
1542
+ </section>
1543
+ {renderedNode?.container && (
1544
+ <details className="ps-details ps-section">
1545
+ <summary>
1546
+ <span>Add to this group</span>
1547
+ <Plus size={14} />
1548
+ </summary>
1549
+ <div className="ps-insert-options">
1550
+ {[
1551
+ { kind: "text", name: "Text", icon: Type },
1552
+ { kind: "shape", name: "Shape", icon: Shapes },
1553
+ { kind: "image", name: "Image", icon: ImageIcon }
1554
+ ].map(({ kind: insert, name, icon: Icon }) => (
1555
+ <button
1556
+ key={insert}
1557
+ disabled={editingLocked}
1558
+ onClick={() => change([current], { insert })}
1559
+ >
1560
+ <Icon size={20} />
1561
+ <span>{name}</span>
1562
+ </button>
1563
+ ))}
1564
+ </div>
1565
+ <p className="ps-note">New elements appear after you save.</p>
1566
+ </details>
1567
+ )}
1568
+ </>
1569
+ )}
1570
+ <details className="ps-details ps-section">
1571
+ <summary>
1572
+ <span>Slide elements</span>
1573
+ <Layers size={14} />
1574
+ </summary>
1575
+ <div className="ps-layer-list">
1576
+ {layers
1577
+ .filter(item => item.element?.getBoundingClientRect().height)
1578
+ .map((item, i) => {
1579
+ const element = item.element!,
1580
+ kind = elementKind(element),
1581
+ Icon =
1582
+ kind === "Image"
1583
+ ? ImageIcon
1584
+ : kind === "Group"
1585
+ ? Layers
1586
+ : kind === "Shape" || kind === "Icon"
1587
+ ? Shapes
1588
+ : Type
1589
+ return (
1590
+ <button
1591
+ key={`${keyOf(item)}:${i}`}
1592
+ aria-pressed={selected.some(s => s.element === element)}
1593
+ onClick={event =>
1594
+ void inspect(
1595
+ event.shiftKey
1596
+ ? selected.some(s => s.element === element)
1597
+ ? selected.filter(s => s.element !== element)
1598
+ : [...selected, item]
1599
+ : [item]
1600
+ )
1601
+ }
1602
+ >
1603
+ <Icon size={14} />
1604
+ <span>
1605
+ {kind === "Group" || kind === "Icon"
1606
+ ? kind
1607
+ : kind === "Image"
1608
+ ? element.getAttribute("alt") || "Image"
1609
+ : element.textContent?.trim().slice(0, 48) || "Shape"}
1610
+ </span>
1611
+ </button>
1612
+ )
1613
+ })}
1614
+ </div>
1615
+ </details>
1616
+ </div>
1617
+ <footer className="ps-panel-footer">
1618
+ <div className="ps-save-status" role="status">
1619
+ <span className={dirty ? "ps-status-dot" : "ps-status-dot saved"} />
1620
+ <span>{busy ? "Saving…" : dirty ? "Unsaved changes" : "All changes saved"}</span>
1621
+ {!dirty && <Check size={13} />}
1622
+ </div>
1623
+ {![
1624
+ "All changes saved",
1625
+ "Unsaved changes",
1626
+ "No unsaved changes",
1627
+ "Changes discarded"
1628
+ ].includes(message) ? (
1629
+ <p className="ps-error">
1630
+ {message}
1631
+ <button onClick={exportDraft}>Download your changes</button>
1632
+ </p>
1633
+ ) : null}
1634
+ <div className="ps-save-actions">
1635
+ <button className="ps-text-button" disabled={!dirty || busy} onClick={discard}>
1636
+ Discard
1637
+ </button>
1638
+ <button className="ps-save" disabled={!dirty || busy} onClick={() => void save()}>
1639
+ <Check size={14} />
1640
+ <span>{busy ? "Saving…" : "Save changes"}</span>
1641
+ </button>
1642
+ </div>
1643
+ </footer>
1644
+ </aside>,
1645
+ panel
1646
+ )}
1647
+ {createPortal(
1648
+ <div className="ps-edit-overlay">
1649
+ {selected.map((item, i) => {
1650
+ if (!item.element?.isConnected) return null
1651
+ if (inlineText !== null && item.element === current?.element) return null
1652
+ const r = rect(item.element)
1653
+ return (
1654
+ <div
1655
+ key={`${keyOf(item)}:${i}`}
1656
+ className={`ps-selection${editingLocked ? " ps-selection-locked" : ""}`}
1657
+ style={{
1658
+ left: r.x * scale,
1659
+ top: r.y * scale,
1660
+ width: r.width * scale,
1661
+ height: r.height * scale
1662
+ }}
1663
+ onDoubleClick={event => drillInto(event, item)}
1664
+ onPointerDown={event => gesture(event, "move")}
1665
+ >
1666
+ {!unsupportedSelection && (
1667
+ <button
1668
+ aria-label="Resize element"
1669
+ disabled={editingLocked}
1670
+ className="ps-resize"
1671
+ onPointerDown={event => gesture(event, "resize", item)}
1672
+ />
1673
+ )}
1674
+ {!unsupportedSelection && (
1675
+ <button
1676
+ aria-label="Rotate element"
1677
+ disabled={editingLocked}
1678
+ className="ps-rotate"
1679
+ onPointerDown={event => gesture(event, "rotate", item)}
1680
+ >
1681
+ <RotateCw size={10} />
1682
+ </button>
1683
+ )}
1684
+ </div>
1685
+ )
1686
+ })}
1687
+ {inlineText !== null &&
1688
+ current?.element &&
1689
+ (() => {
1690
+ const r = rect(current.element),
1691
+ style = getComputedStyle(current.element),
1692
+ fontSize = Math.max(12, parseFloat(style.fontSize) * scale),
1693
+ lineHeight = parseFloat(style.lineHeight) * scale || fontSize * 1.3
1694
+ return (
1695
+ <div
1696
+ className="ps-inline-frame"
1697
+ style={{
1698
+ left: r.x * scale - 8,
1699
+ top: r.y * scale - 8,
1700
+ width: Math.ceil(Math.max(140, r.width * scale)) + 20
1701
+ }}
1702
+ >
1703
+ <div className={`ps-inline-hint${r.y * scale < 36 ? " below" : ""}`}>
1704
+ <Pencil size={12} aria-hidden="true" />
1705
+ <span>Editing text</span>
1706
+ <span className="ps-inline-hint-detail">Click outside to apply</span>
1707
+ </div>
1708
+ <textarea
1709
+ ref={element => {
1710
+ if (!element) return
1711
+ element.style.height = `${Math.max(lineHeight + 4, r.height * scale + 4, element.scrollHeight)}px`
1712
+ if (document.activeElement !== element) element.focus()
1713
+ }}
1714
+ aria-label="Edit text inline"
1715
+ className="ps-inline"
1716
+ style={{
1717
+ height: Math.max(lineHeight + 4, r.height * scale + 4),
1718
+ fontFamily: style.fontFamily,
1719
+ fontSize,
1720
+ fontWeight: style.fontWeight,
1721
+ fontStyle: style.fontStyle,
1722
+ letterSpacing: parseFloat(style.letterSpacing) * scale || 0,
1723
+ lineHeight: `${lineHeight}px`,
1724
+ textAlign: style.textAlign as React.CSSProperties["textAlign"],
1725
+ color: style.color
1726
+ }}
1727
+ value={inlineText}
1728
+ onChange={event => setInlineText(event.target.value)}
1729
+ onBlur={() => {
1730
+ if (inlineText !== renderedText) change([current], { text: inlineText })
1731
+ setInlineText(null)
1732
+ }}
1733
+ />
1734
+ </div>
1735
+ )
1736
+ })()}
1737
+ {guides.x !== undefined && (
1738
+ <div className="ps-guide vertical" style={{ left: guides.x * scale }} />
1739
+ )}
1740
+ {guides.y !== undefined && (
1741
+ <div className="ps-guide horizontal" style={{ top: guides.y * scale }} />
1742
+ )}
1743
+ </div>,
1744
+ overlay
1745
+ )}
1746
+ </>
1747
+ )
1748
+ }
1749
+
1750
+ function IconButton({
1751
+ label,
1752
+ children,
1753
+ disabled,
1754
+ pressed,
1755
+ onClick
1756
+ }: {
1757
+ label: string
1758
+ children: React.ReactNode
1759
+ disabled?: boolean
1760
+ pressed?: boolean
1761
+ onClick: () => void
1762
+ }) {
1763
+ return (
1764
+ <button
1765
+ className="ps-icon-button"
1766
+ type="button"
1767
+ aria-label={label}
1768
+ title={label}
1769
+ aria-pressed={pressed}
1770
+ disabled={disabled}
1771
+ onClick={onClick}
1772
+ >
1773
+ {children}
1774
+ </button>
1775
+ )
1776
+ }
1777
+ function NumberField({
1778
+ label,
1779
+ shortLabel,
1780
+ value,
1781
+ min,
1782
+ step = 1,
1783
+ suffix,
1784
+ onChange
1785
+ }: {
1786
+ label: string
1787
+ shortLabel?: string
1788
+ value: number
1789
+ min?: number
1790
+ step?: number
1791
+ suffix?: string
1792
+ onChange: (value: number) => void
1793
+ }) {
1794
+ return (
1795
+ <label className="ps-field ps-number-field">
1796
+ <span>{shortLabel || label}</span>
1797
+ <div>
1798
+ <input
1799
+ aria-label={label}
1800
+ type="number"
1801
+ min={min}
1802
+ step={step}
1803
+ value={Number.isFinite(value) ? Math.round(value * 100) / 100 : 0}
1804
+ onChange={event => {
1805
+ if (event.target.value !== "" && Number.isFinite(event.target.valueAsNumber))
1806
+ onChange(Math.max(min ?? -Infinity, event.target.valueAsNumber))
1807
+ }}
1808
+ />
1809
+ {suffix && <span>{suffix}</span>}
1810
+ </div>
1811
+ </label>
1812
+ )
1813
+ }
1814
+ function colorHex(value: string) {
1815
+ if (typeof document === "undefined") return "#ffffff"
1816
+ const context = document.createElement("canvas").getContext("2d")
1817
+ if (!context) return "#ffffff"
1818
+ context.fillStyle = value
1819
+ context.fillRect(0, 0, 1, 1)
1820
+ const pixels = context.getImageData(0, 0, 1, 1).data
1821
+ return (
1822
+ "#" +
1823
+ Array.from(pixels.slice(0, 3))
1824
+ .map(channel => channel.toString(16).padStart(2, "0"))
1825
+ .join("")
1826
+ )
1827
+ }
1828
+ function ColorField({
1829
+ label,
1830
+ value,
1831
+ onChange,
1832
+ clear
1833
+ }: {
1834
+ label: string
1835
+ value: string
1836
+ onChange: (value: string) => void
1837
+ clear?: () => void
1838
+ }) {
1839
+ const transparent = value === "transparent" || value === "rgba(0, 0, 0, 0)"
1840
+ return (
1841
+ <div className="ps-color-field">
1842
+ <label>
1843
+ <span>{label}</span>
1844
+ <span className="ps-color-value">
1845
+ <span
1846
+ className={`ps-color-preview ${transparent ? "empty" : ""}`}
1847
+ style={{ background: transparent ? undefined : value }}
1848
+ />
1849
+ <span>{transparent ? "None" : colorHex(value).toUpperCase()}</span>
1850
+ <input
1851
+ type="color"
1852
+ aria-label={label}
1853
+ value={colorHex(value)}
1854
+ onChange={event => onChange(event.target.value)}
1855
+ />
1856
+ </span>
1857
+ </label>
1858
+ {clear && (
1859
+ <IconButton label="Remove fill" onClick={clear}>
1860
+ <X size={13} />
1861
+ </IconButton>
1862
+ )}
1863
+ </div>
1864
+ )
1865
+ }
1866
+ function elementKind(element: HTMLElement) {
1867
+ if (element.tagName.toLowerCase() === "svg") return "Icon"
1868
+ if (element.tagName === "IMG") return "Image"
1869
+ if (/^H[1-6]$/.test(element.tagName)) return "Heading"
1870
+ if (/^(P|SPAN|LABEL|LI|BLOCKQUOTE|STRONG|EM|B|I)$/.test(element.tagName)) return "Text"
1871
+ if (element.querySelector("[data-ps-source]")) return "Group"
1872
+ return element.textContent?.trim() ? "Text" : "Shape"
1873
+ }
1874
+ // Export only the component here: Fast Refresh keeps its draft and history intact.
1875
+ export function EditorLauncher({
1876
+ token,
1877
+ initiallyOpen = false
1878
+ }: {
1879
+ token: string
1880
+ initiallyOpen?: boolean
1881
+ }) {
1882
+ const [open, setOpen] = useState(initiallyOpen)
1883
+ const [mounted, setMounted] = useState(initiallyOpen)
1884
+ useEffect(() => {
1885
+ if (open) {
1886
+ setMounted(true)
1887
+ return
1888
+ }
1889
+ const timeout = window.setTimeout(
1890
+ () => setMounted(false),
1891
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 200
1892
+ )
1893
+ return () => window.clearTimeout(timeout)
1894
+ }, [open])
1895
+ const [slots, setSlots] = useState<{
1896
+ toolbar: HTMLElement
1897
+ panel: HTMLElement
1898
+ overlay: HTMLElement
1899
+ surface: HTMLElement
1900
+ } | null>(null)
1901
+ useEffect(() => {
1902
+ const find = () => {
1903
+ const toolbar = document.querySelector<HTMLElement>("[data-ps-editor-toolbar]"),
1904
+ panel = document.querySelector<HTMLElement>("[data-ps-editor-panel]"),
1905
+ overlay = document.querySelector<HTMLElement>("[data-ps-editor-overlay]"),
1906
+ surface = document.querySelector<HTMLElement>("[data-ps-editor-canvas]")
1907
+ if (toolbar && panel && overlay && surface)
1908
+ setSlots(previous =>
1909
+ previous?.toolbar === toolbar &&
1910
+ previous.panel === panel &&
1911
+ previous.overlay === overlay &&
1912
+ previous.surface === surface
1913
+ ? previous
1914
+ : { toolbar, panel, overlay, surface }
1915
+ )
1916
+ }
1917
+ find()
1918
+ const observer = new MutationObserver(find)
1919
+ observer.observe(document.getElementById("root")!, { childList: true, subtree: true })
1920
+ return () => observer.disconnect()
1921
+ }, [])
1922
+ const toggle = (value: boolean) => {
1923
+ setOpen(value)
1924
+ window.dispatchEvent(new CustomEvent("promptslide:editor-state", { detail: { open: value } }))
1925
+ }
1926
+ if (!slots) return null
1927
+ return (
1928
+ <>
1929
+ {createPortal(
1930
+ <button
1931
+ className={`ps-edit-action ${open ? "active" : ""}`}
1932
+ aria-label="Edit slide"
1933
+ title="Edit slide"
1934
+ aria-pressed={open}
1935
+ onClick={() => {
1936
+ if (!open) toggle(true)
1937
+ else window.dispatchEvent(new Event("promptslide:editor-done"))
1938
+ }}
1939
+ >
1940
+ <Pencil size={15} />
1941
+ {!open && <span>Edit</span>}
1942
+ </button>,
1943
+ slots.toolbar
1944
+ )}
1945
+ {mounted && <Studio token={token} close={() => toggle(false)} {...slots} />}
1946
+ </>
1947
+ )
1948
+ }