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.
- package/CHANGELOG.md +14 -0
- package/README.md +76 -0
- package/dist/index.d.ts +29 -4
- package/dist/index.js +1427 -359
- package/dist/index.js.map +1 -1
- package/package.json +31 -31
- package/src/commands/build.mjs +4 -1
- package/src/commands/clone.mjs +5 -2
- package/src/commands/create.mjs +45 -14
- package/src/commands/pull.mjs +6 -3
- package/src/commands/studio.mjs +4 -3
- package/src/core/annotations/annotation-panel.tsx +18 -12
- package/src/core/index.ts +11 -5
- package/src/core/inspector.tsx +155 -0
- package/src/core/loop/config.d.mts +13 -0
- package/src/core/loop/config.mjs +65 -0
- package/src/core/loop/context.tsx +20 -0
- package/src/core/loop/loop-settings.tsx +292 -0
- package/src/core/loop/use-loop-playback.ts +115 -0
- package/src/core/slide-deck.tsx +459 -125
- package/src/core/slide-renderer.tsx +5 -4
- package/src/core/toolbar-action.tsx +38 -0
- package/src/core/types.ts +14 -0
- package/src/core/use-slide-navigation.ts +27 -0
- package/src/core/view-menu.tsx +130 -0
- package/src/editor/README.md +45 -0
- package/src/editor/geometry.ts +76 -0
- package/src/editor/mount.ts +24 -0
- package/src/editor/plugin.mjs +189 -0
- package/src/editor/source.mjs +363 -0
- package/src/editor/studio.css +978 -0
- package/src/editor/studio.tsx +1964 -0
- package/src/utils/deck-config.mjs +6 -0
- package/src/utils/loop-config.mjs +70 -0
- package/src/vite/config.mjs +23 -2
- package/src/vite/plugin.mjs +21 -4
- package/templates/default/src/deck-config.ts +2 -2
|
@@ -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
|
+
}
|