promptslide 0.3.17 → 0.3.19

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