@hyperframes/studio 0.5.0-alpha.13 → 0.5.0-alpha.15
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/dist/assets/{hyperframes-player-vibA20NC.js → hyperframes-player-Cd8vYWxP.js} +2 -2
- package/dist/assets/index-DFLVGWTx.js +106 -0
- package/dist/assets/index-mXJ-UH9F.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +785 -377
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +970 -115
- package/src/components/editor/PropertyPanel.tsx +91 -83
- package/src/components/editor/domEditing.test.ts +161 -29
- package/src/components/editor/domEditing.ts +84 -113
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1397 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/renders/RenderQueue.tsx +10 -3
- package/src/hooks/usePersistentEditHistory.test.ts +1 -0
- package/src/hooks/usePersistentEditHistory.ts +3 -2
- package/src/player/components/CompositionThumbnail.test.ts +1 -1
- package/src/player/components/CompositionThumbnail.tsx +1 -1
- package/src/player/components/Player.tsx +54 -9
- package/src/player/hooks/useTimelinePlayer.test.ts +1 -0
- package/src/utils/clipboard.test.ts +1 -0
- package/src/utils/frameCapture.ts +3 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/dist/assets/index-JhhmFie-.js +0 -105
- package/dist/assets/index-KioPDrX6.css +0 -1
package/src/App.tsx
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
shouldHandleTimelineToggleHotkey,
|
|
62
62
|
} from "./utils/timelineDiscovery";
|
|
63
63
|
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
|
|
64
|
+
import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
|
|
64
65
|
import { Camera } from "./icons/SystemIcons";
|
|
65
66
|
import { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
66
67
|
import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
|
|
@@ -69,23 +70,38 @@ import {
|
|
|
69
70
|
importedFontFaceCss,
|
|
70
71
|
type ImportedFontAsset,
|
|
71
72
|
} from "./components/editor/fontAssets";
|
|
72
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
DomEditOverlay,
|
|
75
|
+
type DomEditGroupPathOffsetCommit,
|
|
76
|
+
} from "./components/editor/DomEditOverlay";
|
|
73
77
|
import {
|
|
74
78
|
buildDefaultDomEditTextField,
|
|
75
|
-
buildDomEditDetachPatchOperations,
|
|
76
|
-
buildDomEditMovePatchOperations,
|
|
77
|
-
buildDomEditResizePatchOperations,
|
|
78
79
|
buildDomEditStylePatchOperation,
|
|
79
80
|
buildDomEditTextPatchOperation,
|
|
80
81
|
buildElementAgentPrompt,
|
|
81
82
|
findElementForSelection,
|
|
83
|
+
getDomEditTargetKey,
|
|
82
84
|
isTextEditableSelection,
|
|
83
85
|
serializeDomEditTextFields,
|
|
84
|
-
resolveDomEditCapabilities,
|
|
85
86
|
resolveDomEditSelection,
|
|
86
87
|
type DomEditTextField,
|
|
87
88
|
type DomEditSelection,
|
|
88
89
|
} from "./components/editor/domEditing";
|
|
90
|
+
import {
|
|
91
|
+
STUDIO_MANUAL_EDITS_PATH,
|
|
92
|
+
applyStudioManualEditManifest,
|
|
93
|
+
emptyStudioManualEditManifest,
|
|
94
|
+
installStudioManualEditSeekReapply,
|
|
95
|
+
isStudioManualEditManifestPath,
|
|
96
|
+
parseStudioManualEditManifest,
|
|
97
|
+
readStudioFileChangePath,
|
|
98
|
+
removeStudioManualEditsForSelection,
|
|
99
|
+
serializeStudioManualEditManifest,
|
|
100
|
+
type StudioManualEditManifest,
|
|
101
|
+
upsertStudioBoxSizeEdit,
|
|
102
|
+
upsertStudioPathOffsetEdit,
|
|
103
|
+
upsertStudioRotationEdit,
|
|
104
|
+
} from "./components/editor/manualEdits";
|
|
89
105
|
import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
|
|
90
106
|
|
|
91
107
|
interface EditingFile {
|
|
@@ -226,10 +242,7 @@ function normalizeDomEditStyleValue(property: string, value: string): string {
|
|
|
226
242
|
const trimmed = value.trim();
|
|
227
243
|
if (!trimmed) return trimmed;
|
|
228
244
|
|
|
229
|
-
if (
|
|
230
|
-
["left", "top", "width", "height", "border-radius", "font-size"].includes(property) &&
|
|
231
|
-
/^-?\d+(\.\d+)?$/.test(trimmed)
|
|
232
|
-
) {
|
|
245
|
+
if (["border-radius", "font-size"].includes(property) && /^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
233
246
|
return `${trimmed}px`;
|
|
234
247
|
}
|
|
235
248
|
|
|
@@ -240,27 +253,6 @@ function isImageBackgroundValue(value: string): boolean {
|
|
|
240
253
|
return /^url\(/i.test(value.trim());
|
|
241
254
|
}
|
|
242
255
|
|
|
243
|
-
function shouldDetachOppositeEdges(selection: DomEditSelection): boolean {
|
|
244
|
-
return Boolean(
|
|
245
|
-
selection.inlineStyles.inset || selection.inlineStyles.right || selection.inlineStyles.bottom,
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function buildOppositeEdgePatchOperations(
|
|
250
|
-
selection: DomEditSelection,
|
|
251
|
-
dimension: "width" | "height" | "both",
|
|
252
|
-
): PatchOperation[] {
|
|
253
|
-
if (!shouldDetachOppositeEdges(selection)) return [];
|
|
254
|
-
const operations: PatchOperation[] = [];
|
|
255
|
-
if (dimension === "width" || dimension === "both") {
|
|
256
|
-
operations.push({ type: "inline-style", property: "right", value: "auto" });
|
|
257
|
-
}
|
|
258
|
-
if (dimension === "height" || dimension === "both") {
|
|
259
|
-
operations.push({ type: "inline-style", property: "bottom", value: "auto" });
|
|
260
|
-
}
|
|
261
|
-
return operations;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
256
|
function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
265
257
|
if (!target || typeof target !== "object") return null;
|
|
266
258
|
const maybeNode = target as {
|
|
@@ -289,14 +281,6 @@ function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
|
289
281
|
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
290
282
|
}
|
|
291
283
|
|
|
292
|
-
function getDomEditCoalesceKey(
|
|
293
|
-
selection: Pick<DomEditSelection, "id" | "selector" | "sourceFile">,
|
|
294
|
-
action: "move" | "resize",
|
|
295
|
-
): string {
|
|
296
|
-
const target = selection.id || selection.selector || "selection";
|
|
297
|
-
return `${action}:${selection.sourceFile || "index.html"}:${target}`;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
284
|
function findMatchingTimelineElementId(
|
|
301
285
|
selection: Pick<
|
|
302
286
|
DomEditSelection,
|
|
@@ -304,8 +288,14 @@ function findMatchingTimelineElementId(
|
|
|
304
288
|
>,
|
|
305
289
|
elements: TimelineElement[],
|
|
306
290
|
): string | null {
|
|
291
|
+
const selectionSourceFile = selection.sourceFile || "index.html";
|
|
307
292
|
for (const element of elements) {
|
|
308
|
-
|
|
293
|
+
const elementSourceFile = element.sourceFile || "index.html";
|
|
294
|
+
if (
|
|
295
|
+
selection.id &&
|
|
296
|
+
element.domId === selection.id &&
|
|
297
|
+
elementSourceFile === selectionSourceFile
|
|
298
|
+
) {
|
|
309
299
|
return element.key ?? element.id;
|
|
310
300
|
}
|
|
311
301
|
if (
|
|
@@ -328,112 +318,8 @@ function findMatchingTimelineElementId(
|
|
|
328
318
|
return null;
|
|
329
319
|
}
|
|
330
320
|
|
|
331
|
-
function
|
|
332
|
-
|
|
333
|
-
timelineElements: TimelineElement[],
|
|
334
|
-
compIdToSrc: Map<string, string>,
|
|
335
|
-
fileTree: string[],
|
|
336
|
-
): { host: HTMLElement; compositionSrc: string } | null {
|
|
337
|
-
const rootCompositionId =
|
|
338
|
-
target.ownerDocument
|
|
339
|
-
.querySelector("[data-composition-id]")
|
|
340
|
-
?.getAttribute("data-composition-id") ?? null;
|
|
341
|
-
|
|
342
|
-
let nestedCurrent: HTMLElement | null = target;
|
|
343
|
-
while (nestedCurrent) {
|
|
344
|
-
const nestedCompId = nestedCurrent.getAttribute("data-composition-id");
|
|
345
|
-
if (nestedCompId && nestedCompId !== rootCompositionId) {
|
|
346
|
-
const hostCandidate = nestedCurrent.parentElement?.closest(".clip");
|
|
347
|
-
if (hostCandidate instanceof HTMLElement) {
|
|
348
|
-
const hostCompId = hostCandidate.getAttribute("data-composition-id");
|
|
349
|
-
const compositionSrc =
|
|
350
|
-
hostCandidate.getAttribute("data-composition-src") ??
|
|
351
|
-
hostCandidate.getAttribute("data-composition-file") ??
|
|
352
|
-
(hostCompId ? compIdToSrc.get(hostCompId) : undefined) ??
|
|
353
|
-
compIdToSrc.get(nestedCompId) ??
|
|
354
|
-
fileTree.find((path) => path.endsWith(`${nestedCompId}.html`)) ??
|
|
355
|
-
undefined;
|
|
356
|
-
if (compositionSrc) {
|
|
357
|
-
return { host: hostCandidate, compositionSrc };
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
nestedCurrent = nestedCurrent.parentElement;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
let current: HTMLElement | null = target;
|
|
365
|
-
while (current) {
|
|
366
|
-
const compId = current.getAttribute("data-composition-id");
|
|
367
|
-
const directSrc =
|
|
368
|
-
current.getAttribute("data-composition-src") ??
|
|
369
|
-
current.getAttribute("data-composition-file") ??
|
|
370
|
-
undefined;
|
|
371
|
-
const timelineMatch =
|
|
372
|
-
timelineElements.find(
|
|
373
|
-
(element) =>
|
|
374
|
-
Boolean(element.compositionSrc) &&
|
|
375
|
-
(element.domId === current?.id ||
|
|
376
|
-
(current?.id && element.id === current.id) ||
|
|
377
|
-
(compId && element.id === compId)),
|
|
378
|
-
) ?? null;
|
|
379
|
-
const compositionSrc =
|
|
380
|
-
directSrc ??
|
|
381
|
-
timelineMatch?.compositionSrc ??
|
|
382
|
-
(compId ? compIdToSrc.get(compId) : undefined) ??
|
|
383
|
-
(compId ? fileTree.find((path) => path.endsWith(`${compId}.html`)) : undefined);
|
|
384
|
-
if (compositionSrc) {
|
|
385
|
-
return { host: current, compositionSrc };
|
|
386
|
-
}
|
|
387
|
-
current = current.parentElement;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
return null;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function isMoveStyleProperty(property: string): boolean {
|
|
394
|
-
return property === "left" || property === "top";
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function isResizeStyleProperty(property: string): boolean {
|
|
398
|
-
return property === "width" || property === "height";
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function getDomDetachCoordinateRoot(element: HTMLElement): HTMLElement {
|
|
402
|
-
const offsetParent = element.offsetParent;
|
|
403
|
-
if (offsetParent instanceof HTMLElement) return offsetParent;
|
|
404
|
-
|
|
405
|
-
let current = element.parentElement;
|
|
406
|
-
while (current) {
|
|
407
|
-
if (current.hasAttribute("data-composition-id")) return current;
|
|
408
|
-
current = current.parentElement;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return element.ownerDocument.body;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function measureDomDetachRect(element: HTMLElement): {
|
|
415
|
-
left: number;
|
|
416
|
-
top: number;
|
|
417
|
-
width: number;
|
|
418
|
-
height: number;
|
|
419
|
-
} {
|
|
420
|
-
const root = getDomDetachCoordinateRoot(element);
|
|
421
|
-
const rect = element.getBoundingClientRect();
|
|
422
|
-
const rootRect = root.getBoundingClientRect();
|
|
423
|
-
|
|
424
|
-
return {
|
|
425
|
-
left: rect.left - rootRect.left + root.scrollLeft,
|
|
426
|
-
top: rect.top - rootRect.top + root.scrollTop,
|
|
427
|
-
width: rect.width,
|
|
428
|
-
height: rect.height,
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function getDomSelectionClickKey(
|
|
433
|
-
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex">,
|
|
434
|
-
): string {
|
|
435
|
-
if (selection.id) return `id:${selection.id}`;
|
|
436
|
-
return `${selection.selector ?? "unknown"}:${selection.selectorIndex ?? 0}`;
|
|
321
|
+
function isManualGeometryStyleProperty(property: string): boolean {
|
|
322
|
+
return property === "left" || property === "top" || property === "width" || property === "height";
|
|
437
323
|
}
|
|
438
324
|
|
|
439
325
|
function getPreviewTargetFromPointer(
|
|
@@ -467,6 +353,108 @@ function getPreviewTargetFromPointer(
|
|
|
467
353
|
return getEventTargetElement(doc.elementFromPoint(localX, localY));
|
|
468
354
|
}
|
|
469
355
|
|
|
356
|
+
function domEditSelectionsTargetSame(
|
|
357
|
+
a: DomEditSelection | null,
|
|
358
|
+
b: DomEditSelection | null,
|
|
359
|
+
): boolean {
|
|
360
|
+
if (a === b) return true;
|
|
361
|
+
if (!a || !b) return false;
|
|
362
|
+
return getDomEditTargetKey(a) === getDomEditTargetKey(b);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function domEditSelectionInGroup(
|
|
366
|
+
group: DomEditSelection[],
|
|
367
|
+
selection: DomEditSelection | null,
|
|
368
|
+
): boolean {
|
|
369
|
+
if (!selection) return false;
|
|
370
|
+
return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function toggleDomEditGroupSelection(
|
|
374
|
+
group: DomEditSelection[],
|
|
375
|
+
selection: DomEditSelection,
|
|
376
|
+
): DomEditSelection[] {
|
|
377
|
+
if (domEditSelectionInGroup(group, selection)) {
|
|
378
|
+
return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
|
|
379
|
+
}
|
|
380
|
+
return [...group, selection];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function replaceDomEditGroupSelection(
|
|
384
|
+
group: DomEditSelection[],
|
|
385
|
+
selection: DomEditSelection,
|
|
386
|
+
): DomEditSelection[] {
|
|
387
|
+
let replaced = false;
|
|
388
|
+
const nextGroup = group.map((entry) => {
|
|
389
|
+
if (!domEditSelectionsTargetSame(entry, selection)) return entry;
|
|
390
|
+
replaced = true;
|
|
391
|
+
return selection;
|
|
392
|
+
});
|
|
393
|
+
return replaced ? nextGroup : [...group, selection];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function seedDomEditGroupWithSelection(
|
|
397
|
+
group: DomEditSelection[],
|
|
398
|
+
selection: DomEditSelection | null,
|
|
399
|
+
): DomEditSelection[] {
|
|
400
|
+
if (!selection || domEditSelectionInGroup(group, selection)) return group;
|
|
401
|
+
return [selection, ...group];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function objectLike(value: unknown): object | null {
|
|
405
|
+
return value && (typeof value === "object" || typeof value === "function") ? value : null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function callPlaybackMethod(target: object | null, key: string): void {
|
|
409
|
+
const method = target ? Reflect.get(target, key) : null;
|
|
410
|
+
if (typeof method !== "function") return;
|
|
411
|
+
try {
|
|
412
|
+
method.call(target);
|
|
413
|
+
} catch {
|
|
414
|
+
// Best-effort playback freeze; drag should still work if playback control is unavailable.
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function readPlaybackTime(target: object | null, key: string): number | null {
|
|
419
|
+
const method = target ? Reflect.get(target, key) : null;
|
|
420
|
+
if (typeof method !== "function") return null;
|
|
421
|
+
try {
|
|
422
|
+
const value = method.call(target);
|
|
423
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
424
|
+
} catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
|
|
430
|
+
const win = iframe?.contentWindow;
|
|
431
|
+
if (!win) return null;
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
let pausedTime: number | null = null;
|
|
435
|
+
const player = objectLike(Reflect.get(win, "__player"));
|
|
436
|
+
pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
|
|
437
|
+
callPlaybackMethod(player, "pause");
|
|
438
|
+
|
|
439
|
+
const timeline = objectLike(Reflect.get(win, "__timeline"));
|
|
440
|
+
pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
|
|
441
|
+
callPlaybackMethod(timeline, "pause");
|
|
442
|
+
|
|
443
|
+
const timelines = objectLike(Reflect.get(win, "__timelines"));
|
|
444
|
+
if (timelines) {
|
|
445
|
+
for (const value of Object.values(timelines)) {
|
|
446
|
+
const timelineRecord = objectLike(value);
|
|
447
|
+
pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
|
|
448
|
+
callPlaybackMethod(timelineRecord, "pause");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return pausedTime;
|
|
453
|
+
} catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
470
458
|
// ── Ask Agent Modal ──
|
|
471
459
|
|
|
472
460
|
function AskAgentModal({
|
|
@@ -611,9 +599,9 @@ export function StudioApp() {
|
|
|
611
599
|
const [resolving, setResolving] = useState(true);
|
|
612
600
|
|
|
613
601
|
useMountEffect(() => {
|
|
614
|
-
const
|
|
615
|
-
if (
|
|
616
|
-
setProjectId(
|
|
602
|
+
const hashProjectId = parseProjectIdFromHash(window.location.hash);
|
|
603
|
+
if (hashProjectId) {
|
|
604
|
+
setProjectId(hashProjectId);
|
|
617
605
|
setResolving(false);
|
|
618
606
|
return;
|
|
619
607
|
}
|
|
@@ -624,7 +612,7 @@ export function StudioApp() {
|
|
|
624
612
|
const first = (data.projects ?? [])[0];
|
|
625
613
|
if (first) {
|
|
626
614
|
setProjectId(first.id);
|
|
627
|
-
window.location.hash =
|
|
615
|
+
window.location.hash = buildProjectHash(first.id);
|
|
628
616
|
}
|
|
629
617
|
})
|
|
630
618
|
.catch(() => {})
|
|
@@ -648,6 +636,8 @@ export function StudioApp() {
|
|
|
648
636
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
649
637
|
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
650
638
|
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
639
|
+
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
|
640
|
+
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
|
651
641
|
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
|
652
642
|
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
653
643
|
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
|
@@ -784,6 +774,7 @@ export function StudioApp() {
|
|
|
784
774
|
const lastBlockedDomMoveToastAtRef = useRef(0);
|
|
785
775
|
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
786
776
|
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
777
|
+
const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
|
|
787
778
|
const panelDragRef = useRef<{
|
|
788
779
|
side: "left" | "right";
|
|
789
780
|
startX: number;
|
|
@@ -1102,9 +1093,37 @@ export function StudioApp() {
|
|
|
1102
1093
|
const consoleErrorsRef = useRef<LintFinding[]>([]);
|
|
1103
1094
|
const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1104
1095
|
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
|
1105
|
-
const
|
|
1096
|
+
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
|
|
1097
|
+
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
|
|
1106
1098
|
const domEditSaveTimestampRef = useRef(0);
|
|
1107
1099
|
const domTextCommitVersionRef = useRef(0);
|
|
1100
|
+
const domEditSaveQueueRef = useRef(Promise.resolve());
|
|
1101
|
+
const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
|
|
1102
|
+
emptyStudioManualEditManifest(),
|
|
1103
|
+
);
|
|
1104
|
+
const studioManualEditRevisionRef = useRef(0);
|
|
1105
|
+
const applyStudioManualEditsToPreviewRef = useRef<
|
|
1106
|
+
(
|
|
1107
|
+
iframe?: HTMLIFrameElement | null,
|
|
1108
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
1109
|
+
) => Promise<void>
|
|
1110
|
+
>(async () => {});
|
|
1111
|
+
const studioManualEditProjectRef = useRef<string | null>(projectId);
|
|
1112
|
+
const activeCompPathRef = useRef(activeCompPath);
|
|
1113
|
+
activeCompPathRef.current = activeCompPath;
|
|
1114
|
+
|
|
1115
|
+
const queueDomEditSave = useCallback((save: () => Promise<void>) => {
|
|
1116
|
+
const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
|
|
1117
|
+
domEditSaveQueueRef.current = queuedSave.then(
|
|
1118
|
+
() => undefined,
|
|
1119
|
+
() => undefined,
|
|
1120
|
+
);
|
|
1121
|
+
return queuedSave;
|
|
1122
|
+
}, []);
|
|
1123
|
+
|
|
1124
|
+
const waitForPendingDomEditSaves = useCallback(async () => {
|
|
1125
|
+
await domEditSaveQueueRef.current.catch(() => undefined);
|
|
1126
|
+
}, []);
|
|
1108
1127
|
|
|
1109
1128
|
// Listen for external file changes (user editing HTML outside the editor).
|
|
1110
1129
|
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
|
@@ -1112,8 +1131,18 @@ export function StudioApp() {
|
|
|
1112
1131
|
// those changes are already applied to the iframe DOM and a full reload
|
|
1113
1132
|
// would flash the preview.
|
|
1114
1133
|
useMountEffect(() => {
|
|
1115
|
-
const handler = () => {
|
|
1116
|
-
|
|
1134
|
+
const handler = (payload?: unknown) => {
|
|
1135
|
+
const changedPath = readStudioFileChangePath(payload);
|
|
1136
|
+
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
|
1137
|
+
if (isStudioManualEditManifestPath(changedPath)) {
|
|
1138
|
+
if (!recentDomEditSave) {
|
|
1139
|
+
void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
|
|
1140
|
+
forceFromDisk: true,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (recentDomEditSave) return;
|
|
1117
1146
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1118
1147
|
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
|
|
1119
1148
|
};
|
|
@@ -1128,6 +1157,17 @@ export function StudioApp() {
|
|
|
1128
1157
|
});
|
|
1129
1158
|
projectIdRef.current = projectId;
|
|
1130
1159
|
domEditSelectionRef.current = domEditSelection;
|
|
1160
|
+
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
|
1161
|
+
domEditHoverSelectionRef.current = domEditHoverSelection;
|
|
1162
|
+
|
|
1163
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1164
|
+
useEffect(() => {
|
|
1165
|
+
const previousProjectId = studioManualEditProjectRef.current;
|
|
1166
|
+
studioManualEditProjectRef.current = projectId;
|
|
1167
|
+
if (!previousProjectId || previousProjectId === projectId) return;
|
|
1168
|
+
studioManualEditManifestRef.current = emptyStudioManualEditManifest();
|
|
1169
|
+
studioManualEditRevisionRef.current += 1;
|
|
1170
|
+
}, [projectId]);
|
|
1131
1171
|
|
|
1132
1172
|
// Load file tree when projectId changes.
|
|
1133
1173
|
// Note: This is one of the few places where useEffect with deps is acceptable —
|
|
@@ -1199,6 +1239,16 @@ export function StudioApp() {
|
|
|
1199
1239
|
}
|
|
1200
1240
|
}, []);
|
|
1201
1241
|
|
|
1242
|
+
const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
1243
|
+
const pid = projectIdRef.current;
|
|
1244
|
+
if (!pid) throw new Error("No active project");
|
|
1245
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
1246
|
+
if (response.status === 404) return "";
|
|
1247
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
1248
|
+
const data = (await response.json()) as { content?: string };
|
|
1249
|
+
return typeof data.content === "string" ? data.content : "";
|
|
1250
|
+
}, []);
|
|
1251
|
+
|
|
1202
1252
|
const handleContentChange = useCallback(
|
|
1203
1253
|
(content: string) => {
|
|
1204
1254
|
const pid = projectIdRef.current;
|
|
@@ -1419,6 +1469,7 @@ export function StudioApp() {
|
|
|
1419
1469
|
|
|
1420
1470
|
const currentTime = usePlayerStore.getState().currentTime;
|
|
1421
1471
|
setCaptureFrameTime(currentTime);
|
|
1472
|
+
await waitForPendingDomEditSaves();
|
|
1422
1473
|
const href = buildFrameCaptureUrl({
|
|
1423
1474
|
projectId,
|
|
1424
1475
|
compositionPath: activeCompPath,
|
|
@@ -1445,7 +1496,7 @@ export function StudioApp() {
|
|
|
1445
1496
|
showToast(message);
|
|
1446
1497
|
}
|
|
1447
1498
|
},
|
|
1448
|
-
[activeCompPath, projectId, showToast],
|
|
1499
|
+
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
|
1449
1500
|
);
|
|
1450
1501
|
|
|
1451
1502
|
const handleTimelineElementDelete = useCallback(
|
|
@@ -1572,10 +1623,8 @@ export function StudioApp() {
|
|
|
1572
1623
|
if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
|
|
1573
1624
|
lastBlockedDomMoveToastAtRef.current = now;
|
|
1574
1625
|
showToast(
|
|
1575
|
-
selection.capabilities.
|
|
1576
|
-
|
|
1577
|
-
: (selection.capabilities.reasonIfDisabled ??
|
|
1578
|
-
"This element can’t be moved directly from the preview."),
|
|
1626
|
+
selection.capabilities.reasonIfDisabled ??
|
|
1627
|
+
"This element can’t be adjusted directly from the preview.",
|
|
1579
1628
|
"info",
|
|
1580
1629
|
);
|
|
1581
1630
|
},
|
|
@@ -1583,16 +1632,57 @@ export function StudioApp() {
|
|
|
1583
1632
|
);
|
|
1584
1633
|
|
|
1585
1634
|
const applyDomSelection = useCallback(
|
|
1586
|
-
(
|
|
1587
|
-
|
|
1635
|
+
(
|
|
1636
|
+
selection: DomEditSelection | null,
|
|
1637
|
+
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
1638
|
+
) => {
|
|
1588
1639
|
setAgentPromptTagSnippet(undefined);
|
|
1589
1640
|
setCopiedAgentPrompt(false);
|
|
1590
|
-
if (selection) {
|
|
1641
|
+
if (!selection) {
|
|
1642
|
+
domEditSelectionRef.current = null;
|
|
1643
|
+
domEditGroupSelectionsRef.current = [];
|
|
1644
|
+
setDomEditSelection(null);
|
|
1645
|
+
setDomEditGroupSelections([]);
|
|
1646
|
+
setSelectedTimelineElementId(null);
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const isAdditiveSelection = Boolean(options?.additive);
|
|
1651
|
+
const currentSelection = domEditSelectionRef.current;
|
|
1652
|
+
const previousGroup = domEditGroupSelectionsRef.current;
|
|
1653
|
+
const currentGroup = isAdditiveSelection
|
|
1654
|
+
? seedDomEditGroupWithSelection(previousGroup, currentSelection)
|
|
1655
|
+
: previousGroup;
|
|
1656
|
+
const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
|
|
1657
|
+
const nextGroup = options?.preserveGroup
|
|
1658
|
+
? replaceDomEditGroupSelection(currentGroup, selection)
|
|
1659
|
+
: isAdditiveSelection
|
|
1660
|
+
? toggleDomEditGroupSelection(currentGroup, selection)
|
|
1661
|
+
: [selection];
|
|
1662
|
+
const nextSelection = options?.preserveGroup
|
|
1663
|
+
? selection
|
|
1664
|
+
: isAdditiveSelection && wasInGroup
|
|
1665
|
+
? domEditSelectionsTargetSame(currentSelection, selection)
|
|
1666
|
+
? (nextGroup[0] ?? null)
|
|
1667
|
+
: domEditSelectionInGroup(nextGroup, currentSelection)
|
|
1668
|
+
? currentSelection
|
|
1669
|
+
: (nextGroup[0] ?? null)
|
|
1670
|
+
: selection;
|
|
1671
|
+
|
|
1672
|
+
domEditSelectionRef.current = nextSelection;
|
|
1673
|
+
domEditGroupSelectionsRef.current = nextGroup;
|
|
1674
|
+
setDomEditSelection(nextSelection);
|
|
1675
|
+
setDomEditGroupSelections(nextGroup);
|
|
1676
|
+
|
|
1677
|
+
if (nextSelection) {
|
|
1591
1678
|
if (options?.revealPanel !== false) {
|
|
1592
1679
|
setRightCollapsed(false);
|
|
1593
1680
|
setRightPanelTab("design");
|
|
1594
1681
|
}
|
|
1595
|
-
const nextSelectedTimelineId = findMatchingTimelineElementId(
|
|
1682
|
+
const nextSelectedTimelineId = findMatchingTimelineElementId(
|
|
1683
|
+
nextSelection,
|
|
1684
|
+
timelineElements,
|
|
1685
|
+
);
|
|
1596
1686
|
setSelectedTimelineElementId(nextSelectedTimelineId);
|
|
1597
1687
|
return;
|
|
1598
1688
|
}
|
|
@@ -1606,10 +1696,194 @@ export function StudioApp() {
|
|
|
1606
1696
|
applyDomSelection(null, { revealPanel: false });
|
|
1607
1697
|
}, [applyDomSelection]);
|
|
1608
1698
|
|
|
1699
|
+
const readHistoryProjectFile = useCallback(
|
|
1700
|
+
async (path: string): Promise<string> => {
|
|
1701
|
+
return path === STUDIO_MANUAL_EDITS_PATH
|
|
1702
|
+
? readOptionalProjectFile(path)
|
|
1703
|
+
: readProjectFile(path);
|
|
1704
|
+
},
|
|
1705
|
+
[readOptionalProjectFile, readProjectFile],
|
|
1706
|
+
);
|
|
1707
|
+
|
|
1708
|
+
const writeHistoryProjectFile = useCallback(
|
|
1709
|
+
async (path: string, content: string): Promise<void> => {
|
|
1710
|
+
await writeProjectFile(path, content);
|
|
1711
|
+
if (path === STUDIO_MANUAL_EDITS_PATH) {
|
|
1712
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1713
|
+
}
|
|
1714
|
+
},
|
|
1715
|
+
[writeProjectFile],
|
|
1716
|
+
);
|
|
1717
|
+
|
|
1718
|
+
const applyCurrentStudioManualEditsToPreview = useCallback(
|
|
1719
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
1720
|
+
if (!iframe) return;
|
|
1721
|
+
let doc: Document | null = null;
|
|
1722
|
+
try {
|
|
1723
|
+
doc = iframe.contentDocument;
|
|
1724
|
+
} catch {
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
if (!doc) return;
|
|
1728
|
+
const previewDoc = doc;
|
|
1729
|
+
|
|
1730
|
+
const applyManifest = () => {
|
|
1731
|
+
applyStudioManualEditManifest(
|
|
1732
|
+
previewDoc,
|
|
1733
|
+
studioManualEditManifestRef.current,
|
|
1734
|
+
activeCompPathRef.current,
|
|
1735
|
+
);
|
|
1736
|
+
};
|
|
1737
|
+
const applyAndInstallSeekHooks = () => {
|
|
1738
|
+
applyManifest();
|
|
1739
|
+
if (iframe.contentWindow) {
|
|
1740
|
+
installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1743
|
+
|
|
1744
|
+
const win = iframe.contentWindow;
|
|
1745
|
+
applyAndInstallSeekHooks();
|
|
1746
|
+
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
|
1747
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 80);
|
|
1748
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 250);
|
|
1749
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 500);
|
|
1750
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
|
|
1751
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
|
|
1752
|
+
},
|
|
1753
|
+
[],
|
|
1754
|
+
);
|
|
1755
|
+
|
|
1756
|
+
const applyStudioManualEditsToPreview = useCallback(
|
|
1757
|
+
async (
|
|
1758
|
+
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
1759
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
1760
|
+
) => {
|
|
1761
|
+
const readRevision = studioManualEditRevisionRef.current;
|
|
1762
|
+
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
|
1763
|
+
if (!readFromDiskFirst) {
|
|
1764
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
1765
|
+
}
|
|
1766
|
+
let content: string;
|
|
1767
|
+
try {
|
|
1768
|
+
content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
1769
|
+
} catch (error) {
|
|
1770
|
+
const message =
|
|
1771
|
+
error instanceof Error ? error.message : "Failed to read manual edit manifest";
|
|
1772
|
+
showToast(message);
|
|
1773
|
+
if (readFromDiskFirst) {
|
|
1774
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
1775
|
+
}
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
|
|
1779
|
+
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
|
|
1780
|
+
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
|
|
1781
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (readFromDiskFirst) {
|
|
1785
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
1786
|
+
}
|
|
1787
|
+
},
|
|
1788
|
+
[applyCurrentStudioManualEditsToPreview, readOptionalProjectFile, showToast],
|
|
1789
|
+
);
|
|
1790
|
+
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
|
|
1791
|
+
|
|
1792
|
+
const applyStudioManualEditsToPreviewAfterRefresh = useCallback(
|
|
1793
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
|
|
1794
|
+
applyStudioManualEditsToPreview(iframe, { readFromDiskFirst: true }),
|
|
1795
|
+
[applyStudioManualEditsToPreview],
|
|
1796
|
+
);
|
|
1797
|
+
|
|
1798
|
+
const commitStudioManualEditManifestOptimistically = useCallback(
|
|
1799
|
+
(
|
|
1800
|
+
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
|
1801
|
+
options: { label: string; coalesceKey: string },
|
|
1802
|
+
) => {
|
|
1803
|
+
const previousManifest = studioManualEditManifestRef.current;
|
|
1804
|
+
const nextManifest = updateManifest(previousManifest);
|
|
1805
|
+
const previousContent = serializeStudioManualEditManifest(previousManifest);
|
|
1806
|
+
const nextContent = serializeStudioManualEditManifest(nextManifest);
|
|
1807
|
+
if (nextContent === previousContent) {
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const revision = studioManualEditRevisionRef.current + 1;
|
|
1812
|
+
studioManualEditRevisionRef.current = revision;
|
|
1813
|
+
studioManualEditManifestRef.current = nextManifest;
|
|
1814
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
1815
|
+
|
|
1816
|
+
const save = async () => {
|
|
1817
|
+
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
1818
|
+
const diskManifest = parseStudioManualEditManifest(originalContent);
|
|
1819
|
+
const nextDiskManifest = updateManifest(diskManifest);
|
|
1820
|
+
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
|
|
1821
|
+
if (nextDiskContent === originalContent) {
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
const pid = projectIdRef.current;
|
|
1826
|
+
if (!pid) throw new Error("No active project");
|
|
1827
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1828
|
+
await saveProjectFilesWithHistory({
|
|
1829
|
+
projectId: pid,
|
|
1830
|
+
label: options.label,
|
|
1831
|
+
kind: "manual",
|
|
1832
|
+
coalesceKey: options.coalesceKey,
|
|
1833
|
+
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
|
|
1834
|
+
readFile: async () => originalContent,
|
|
1835
|
+
writeFile: writeProjectFile,
|
|
1836
|
+
recordEdit: editHistory.recordEdit,
|
|
1837
|
+
});
|
|
1838
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1839
|
+
|
|
1840
|
+
if (studioManualEditRevisionRef.current === revision) {
|
|
1841
|
+
studioManualEditManifestRef.current = nextDiskManifest;
|
|
1842
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
1843
|
+
}
|
|
1844
|
+
};
|
|
1845
|
+
|
|
1846
|
+
void queueDomEditSave(save).catch((error) => {
|
|
1847
|
+
if (studioManualEditRevisionRef.current === revision) {
|
|
1848
|
+
studioManualEditRevisionRef.current += 1;
|
|
1849
|
+
studioManualEditManifestRef.current = previousManifest;
|
|
1850
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
1851
|
+
}
|
|
1852
|
+
const message = error instanceof Error ? error.message : "Failed to save manual edit";
|
|
1853
|
+
showToast(message);
|
|
1854
|
+
});
|
|
1855
|
+
},
|
|
1856
|
+
[
|
|
1857
|
+
applyCurrentStudioManualEditsToPreview,
|
|
1858
|
+
editHistory.recordEdit,
|
|
1859
|
+
queueDomEditSave,
|
|
1860
|
+
readOptionalProjectFile,
|
|
1861
|
+
showToast,
|
|
1862
|
+
writeProjectFile,
|
|
1863
|
+
],
|
|
1864
|
+
);
|
|
1865
|
+
|
|
1866
|
+
const syncHistoryPreviewAfterApply = useCallback(
|
|
1867
|
+
async (paths: string[] | undefined) => {
|
|
1868
|
+
const changedPaths = paths ?? [];
|
|
1869
|
+
const manualManifestOnly =
|
|
1870
|
+
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
|
|
1871
|
+
|
|
1872
|
+
if (manualManifestOnly) {
|
|
1873
|
+
await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
setRefreshKey((key) => key + 1);
|
|
1878
|
+
},
|
|
1879
|
+
[applyStudioManualEditsToPreview],
|
|
1880
|
+
);
|
|
1881
|
+
|
|
1609
1882
|
const handleUndo = useCallback(async () => {
|
|
1883
|
+
await waitForPendingDomEditSaves();
|
|
1610
1884
|
const result = await editHistory.undo({
|
|
1611
|
-
readFile:
|
|
1612
|
-
writeFile:
|
|
1885
|
+
readFile: readHistoryProjectFile,
|
|
1886
|
+
writeFile: writeHistoryProjectFile,
|
|
1613
1887
|
});
|
|
1614
1888
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
1615
1889
|
showToast("File changed outside Studio. Undo history was not applied.", "info");
|
|
@@ -1617,15 +1891,24 @@ export function StudioApp() {
|
|
|
1617
1891
|
}
|
|
1618
1892
|
if (result.ok && result.label) {
|
|
1619
1893
|
clearDomSelection();
|
|
1620
|
-
|
|
1894
|
+
await syncHistoryPreviewAfterApply(result.paths);
|
|
1621
1895
|
showToast(`Undid ${result.label}`, "info");
|
|
1622
1896
|
}
|
|
1623
|
-
}, [
|
|
1897
|
+
}, [
|
|
1898
|
+
clearDomSelection,
|
|
1899
|
+
editHistory,
|
|
1900
|
+
readHistoryProjectFile,
|
|
1901
|
+
showToast,
|
|
1902
|
+
syncHistoryPreviewAfterApply,
|
|
1903
|
+
waitForPendingDomEditSaves,
|
|
1904
|
+
writeHistoryProjectFile,
|
|
1905
|
+
]);
|
|
1624
1906
|
|
|
1625
1907
|
const handleRedo = useCallback(async () => {
|
|
1908
|
+
await waitForPendingDomEditSaves();
|
|
1626
1909
|
const result = await editHistory.redo({
|
|
1627
|
-
readFile:
|
|
1628
|
-
writeFile:
|
|
1910
|
+
readFile: readHistoryProjectFile,
|
|
1911
|
+
writeFile: writeHistoryProjectFile,
|
|
1629
1912
|
});
|
|
1630
1913
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
1631
1914
|
showToast("File changed outside Studio. Redo history was not applied.", "info");
|
|
@@ -1633,78 +1916,107 @@ export function StudioApp() {
|
|
|
1633
1916
|
}
|
|
1634
1917
|
if (result.ok && result.label) {
|
|
1635
1918
|
clearDomSelection();
|
|
1636
|
-
|
|
1919
|
+
await syncHistoryPreviewAfterApply(result.paths);
|
|
1637
1920
|
showToast(`Redid ${result.label}`, "info");
|
|
1638
1921
|
}
|
|
1639
|
-
}, [
|
|
1922
|
+
}, [
|
|
1923
|
+
clearDomSelection,
|
|
1924
|
+
editHistory,
|
|
1925
|
+
readHistoryProjectFile,
|
|
1926
|
+
showToast,
|
|
1927
|
+
syncHistoryPreviewAfterApply,
|
|
1928
|
+
waitForPendingDomEditSaves,
|
|
1929
|
+
writeHistoryProjectFile,
|
|
1930
|
+
]);
|
|
1640
1931
|
|
|
1641
1932
|
const handleUndoRef = useRef(handleUndo);
|
|
1642
1933
|
const handleRedoRef = useRef(handleRedo);
|
|
1643
1934
|
handleUndoRef.current = handleUndo;
|
|
1644
1935
|
handleRedoRef.current = handleRedo;
|
|
1645
1936
|
|
|
1937
|
+
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
|
|
1938
|
+
if (!(event.metaKey || event.ctrlKey)) return;
|
|
1939
|
+
if (shouldIgnoreHistoryShortcut(event.target)) return;
|
|
1940
|
+
const key = event.key.toLowerCase();
|
|
1941
|
+
if (key === "z" && !event.shiftKey) {
|
|
1942
|
+
event.preventDefault();
|
|
1943
|
+
void handleUndoRef.current();
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
|
1947
|
+
event.preventDefault();
|
|
1948
|
+
void handleRedoRef.current();
|
|
1949
|
+
}
|
|
1950
|
+
}, []);
|
|
1951
|
+
|
|
1646
1952
|
// eslint-disable-next-line no-restricted-syntax
|
|
1647
1953
|
useEffect(() => {
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1954
|
+
window.addEventListener("keydown", handleHistoryHotkey, true);
|
|
1955
|
+
return () => window.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
1956
|
+
}, [handleHistoryHotkey]);
|
|
1957
|
+
|
|
1958
|
+
const syncPreviewHistoryHotkey = useCallback(
|
|
1959
|
+
(iframe: HTMLIFrameElement | null) => {
|
|
1960
|
+
previewHistoryHotkeyCleanupRef.current?.();
|
|
1961
|
+
previewHistoryHotkeyCleanupRef.current = null;
|
|
1962
|
+
|
|
1963
|
+
const win = iframe?.contentWindow ?? null;
|
|
1964
|
+
let doc: Document | null = null;
|
|
1965
|
+
try {
|
|
1966
|
+
doc = iframe?.contentDocument ?? null;
|
|
1967
|
+
} catch {
|
|
1968
|
+
doc = null;
|
|
1660
1969
|
}
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1970
|
+
if (!win && !doc) return;
|
|
1971
|
+
|
|
1972
|
+
win?.addEventListener("keydown", handleHistoryHotkey, true);
|
|
1973
|
+
doc?.addEventListener("keydown", handleHistoryHotkey, true);
|
|
1974
|
+
previewHistoryHotkeyCleanupRef.current = () => {
|
|
1975
|
+
win?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
1976
|
+
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
1977
|
+
};
|
|
1978
|
+
},
|
|
1979
|
+
[handleHistoryHotkey],
|
|
1980
|
+
);
|
|
1981
|
+
|
|
1982
|
+
useEffect(
|
|
1983
|
+
() => () => {
|
|
1984
|
+
previewHistoryHotkeyCleanupRef.current?.();
|
|
1985
|
+
previewHistoryHotkeyCleanupRef.current = null;
|
|
1986
|
+
},
|
|
1987
|
+
[],
|
|
1988
|
+
);
|
|
1665
1989
|
|
|
1666
1990
|
const buildDomSelectionFromTarget = useCallback(
|
|
1667
1991
|
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
|
1668
|
-
if (isMasterView) {
|
|
1669
|
-
const mappedHost = findMappedCompositionHost(
|
|
1670
|
-
target,
|
|
1671
|
-
timelineElements,
|
|
1672
|
-
compIdToSrc,
|
|
1673
|
-
fileTree,
|
|
1674
|
-
);
|
|
1675
|
-
if (mappedHost) {
|
|
1676
|
-
const hostSelection = resolveDomEditSelection(mappedHost.host, {
|
|
1677
|
-
activeCompositionPath: activeCompPath,
|
|
1678
|
-
isMasterView,
|
|
1679
|
-
preferClipAncestor: options?.preferClipAncestor,
|
|
1680
|
-
});
|
|
1681
|
-
if (!hostSelection) return null;
|
|
1682
|
-
return {
|
|
1683
|
-
...hostSelection,
|
|
1684
|
-
compositionSrc: mappedHost.compositionSrc,
|
|
1685
|
-
isCompositionHost: true,
|
|
1686
|
-
capabilities: resolveDomEditCapabilities({
|
|
1687
|
-
selector: hostSelection.selector,
|
|
1688
|
-
tagName: hostSelection.tagName,
|
|
1689
|
-
className: hostSelection.element.className,
|
|
1690
|
-
inlineStyles: hostSelection.inlineStyles,
|
|
1691
|
-
computedStyles: hostSelection.computedStyles,
|
|
1692
|
-
isCompositionHost: true,
|
|
1693
|
-
isMasterView: true,
|
|
1694
|
-
}),
|
|
1695
|
-
} satisfies DomEditSelection;
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
1992
|
return resolveDomEditSelection(target, {
|
|
1700
1993
|
activeCompositionPath: activeCompPath,
|
|
1701
1994
|
isMasterView,
|
|
1702
1995
|
preferClipAncestor: options?.preferClipAncestor,
|
|
1703
1996
|
});
|
|
1704
1997
|
},
|
|
1705
|
-
[activeCompPath,
|
|
1998
|
+
[activeCompPath, isMasterView],
|
|
1706
1999
|
);
|
|
1707
2000
|
|
|
2001
|
+
const resolveDomSelectionFromPreviewPoint = useCallback(
|
|
2002
|
+
(clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
|
|
2003
|
+
const iframe = previewIframeRef.current;
|
|
2004
|
+
if (!iframe || captionEditMode) return null;
|
|
2005
|
+
const target = getPreviewTargetFromPointer(iframe, clientX, clientY);
|
|
2006
|
+
if (!target) return null;
|
|
2007
|
+
return buildDomSelectionFromTarget(target, {
|
|
2008
|
+
preferClipAncestor: options?.preferClipAncestor,
|
|
2009
|
+
});
|
|
2010
|
+
},
|
|
2011
|
+
[buildDomSelectionFromTarget, captionEditMode],
|
|
2012
|
+
);
|
|
2013
|
+
|
|
2014
|
+
const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
|
|
2015
|
+
if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
|
|
2016
|
+
domEditHoverSelectionRef.current = selection;
|
|
2017
|
+
setDomEditHoverSelection(selection);
|
|
2018
|
+
}, []);
|
|
2019
|
+
|
|
1708
2020
|
const preloadAgentPromptSnippet = useCallback(
|
|
1709
2021
|
async (selection: DomEditSelection) => {
|
|
1710
2022
|
const pid = projectIdRef.current;
|
|
@@ -1817,110 +2129,183 @@ export function StudioApp() {
|
|
|
1817
2129
|
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
1818
2130
|
);
|
|
1819
2131
|
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2132
|
+
const refreshDomEditSelectionFromPreview = useCallback(
|
|
2133
|
+
(selection: DomEditSelection) => {
|
|
2134
|
+
const iframe = previewIframeRef.current;
|
|
2135
|
+
let doc: Document | null = null;
|
|
2136
|
+
try {
|
|
2137
|
+
doc = iframe?.contentDocument ?? null;
|
|
2138
|
+
} catch {
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
if (!doc) return;
|
|
2142
|
+
|
|
2143
|
+
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
2144
|
+
if (!element) return;
|
|
2145
|
+
|
|
2146
|
+
const nextSelection = buildDomSelectionFromTarget(element);
|
|
2147
|
+
if (nextSelection) {
|
|
2148
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
2149
|
+
}
|
|
2150
|
+
},
|
|
2151
|
+
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget],
|
|
2152
|
+
);
|
|
2153
|
+
|
|
2154
|
+
const refreshDomEditGroupSelectionsFromPreview = useCallback(
|
|
2155
|
+
(selections: DomEditSelection[]) => {
|
|
2156
|
+
const iframe = previewIframeRef.current;
|
|
2157
|
+
let doc: Document | null = null;
|
|
2158
|
+
try {
|
|
2159
|
+
doc = iframe?.contentDocument ?? null;
|
|
2160
|
+
} catch {
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
if (!doc) return;
|
|
2164
|
+
|
|
2165
|
+
const nextGroup: DomEditSelection[] = [];
|
|
2166
|
+
for (const selection of selections) {
|
|
2167
|
+
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
2168
|
+
if (!element) continue;
|
|
2169
|
+
const nextSelection = buildDomSelectionFromTarget(element);
|
|
2170
|
+
if (nextSelection) nextGroup.push(nextSelection);
|
|
2171
|
+
}
|
|
2172
|
+
if (nextGroup.length === 0) return;
|
|
2173
|
+
|
|
2174
|
+
const currentSelection = domEditSelectionRef.current;
|
|
2175
|
+
const nextSelection =
|
|
2176
|
+
nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
|
|
2177
|
+
nextGroup[0] ??
|
|
2178
|
+
null;
|
|
2179
|
+
|
|
2180
|
+
setAgentPromptTagSnippet(undefined);
|
|
2181
|
+
setCopiedAgentPrompt(false);
|
|
2182
|
+
domEditSelectionRef.current = nextSelection;
|
|
2183
|
+
domEditGroupSelectionsRef.current = nextGroup;
|
|
2184
|
+
setDomEditSelection(nextSelection);
|
|
2185
|
+
setDomEditGroupSelections(nextGroup);
|
|
2186
|
+
|
|
2187
|
+
if (nextSelection) {
|
|
2188
|
+
setSelectedTimelineElementId(
|
|
2189
|
+
findMatchingTimelineElementId(nextSelection, timelineElements),
|
|
2190
|
+
);
|
|
2191
|
+
} else {
|
|
2192
|
+
setSelectedTimelineElementId(null);
|
|
2193
|
+
}
|
|
2194
|
+
},
|
|
2195
|
+
[activeCompPath, buildDomSelectionFromTarget, setSelectedTimelineElementId, timelineElements],
|
|
2196
|
+
);
|
|
2197
|
+
|
|
2198
|
+
const handleDomManualDragStart = useCallback(() => {
|
|
2199
|
+
const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
|
|
2200
|
+
const playerStore = usePlayerStore.getState();
|
|
2201
|
+
playerStore.setIsPlaying(false);
|
|
2202
|
+
if (pausedTime != null) {
|
|
2203
|
+
playerStore.setCurrentTime(pausedTime);
|
|
2204
|
+
liveTime.notify(pausedTime);
|
|
2205
|
+
}
|
|
2206
|
+
}, []);
|
|
2207
|
+
|
|
2208
|
+
const handleDomPathOffsetCommit = useCallback(
|
|
2209
|
+
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
2210
|
+
commitStudioManualEditManifestOptimistically(
|
|
2211
|
+
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
|
|
1828
2212
|
{
|
|
1829
|
-
skipRefresh: true,
|
|
1830
2213
|
label: "Move layer",
|
|
1831
|
-
coalesceKey:
|
|
2214
|
+
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
|
1832
2215
|
},
|
|
1833
2216
|
);
|
|
2217
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
1834
2218
|
},
|
|
1835
|
-
[
|
|
2219
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
1836
2220
|
);
|
|
1837
2221
|
|
|
1838
|
-
const
|
|
1839
|
-
|
|
1840
|
-
if (
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2222
|
+
const handleDomGroupPathOffsetCommit = useCallback(
|
|
2223
|
+
(updates: DomEditGroupPathOffsetCommit[]) => {
|
|
2224
|
+
if (updates.length === 0) return;
|
|
2225
|
+
const coalesceKey = updates
|
|
2226
|
+
.map((update) => getDomEditTargetKey(update.selection))
|
|
2227
|
+
.sort()
|
|
2228
|
+
.join(":");
|
|
2229
|
+
commitStudioManualEditManifestOptimistically(
|
|
2230
|
+
(manifest) =>
|
|
2231
|
+
updates.reduce(
|
|
2232
|
+
(nextManifest, update) =>
|
|
2233
|
+
upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
|
|
2234
|
+
manifest,
|
|
2235
|
+
),
|
|
1850
2236
|
{
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
coalesceKey: getDomEditCoalesceKey(selection, "resize"),
|
|
2237
|
+
label: `Move ${updates.length} layers`,
|
|
2238
|
+
coalesceKey: `group-path-offset:${coalesceKey}`,
|
|
1854
2239
|
},
|
|
1855
2240
|
);
|
|
2241
|
+
refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
|
|
1856
2242
|
},
|
|
1857
|
-
[
|
|
2243
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditGroupSelectionsFromPreview],
|
|
1858
2244
|
);
|
|
1859
2245
|
|
|
1860
|
-
const
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
const rect = measureDomDetachRect(element);
|
|
1874
|
-
const operations = buildDomEditDetachPatchOperations(rect);
|
|
1875
|
-
|
|
1876
|
-
for (const operation of operations) {
|
|
1877
|
-
element.style.setProperty(operation.property, operation.value);
|
|
1878
|
-
}
|
|
2246
|
+
const handleDomBoxSizeCommit = useCallback(
|
|
2247
|
+
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
2248
|
+
commitStudioManualEditManifestOptimistically(
|
|
2249
|
+
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
|
|
2250
|
+
{
|
|
2251
|
+
label: "Resize layer box",
|
|
2252
|
+
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
|
2253
|
+
},
|
|
2254
|
+
);
|
|
2255
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2256
|
+
},
|
|
2257
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2258
|
+
);
|
|
1879
2259
|
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
2260
|
+
const handleDomRotationCommit = useCallback(
|
|
2261
|
+
(selection: DomEditSelection, next: { angle: number }) => {
|
|
2262
|
+
commitStudioManualEditManifestOptimistically(
|
|
2263
|
+
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
|
|
2264
|
+
{
|
|
2265
|
+
label: "Rotate layer",
|
|
2266
|
+
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
|
2267
|
+
},
|
|
2268
|
+
);
|
|
2269
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2270
|
+
},
|
|
2271
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2272
|
+
);
|
|
1884
2273
|
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2274
|
+
const handleDomManualEditsReset = useCallback(
|
|
2275
|
+
(selection: DomEditSelection) => {
|
|
2276
|
+
commitStudioManualEditManifestOptimistically(
|
|
2277
|
+
(manifest) => removeStudioManualEditsForSelection(manifest, selection),
|
|
2278
|
+
{
|
|
2279
|
+
label: "Reset layer edits",
|
|
2280
|
+
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
|
2281
|
+
},
|
|
2282
|
+
);
|
|
2283
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2284
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2285
|
+
},
|
|
2286
|
+
[
|
|
2287
|
+
applyCurrentStudioManualEditsToPreview,
|
|
2288
|
+
commitStudioManualEditManifestOptimistically,
|
|
2289
|
+
refreshDomEditSelectionFromPreview,
|
|
2290
|
+
],
|
|
2291
|
+
);
|
|
1900
2292
|
|
|
1901
2293
|
const handleDomStyleCommit = useCallback(
|
|
1902
2294
|
async (property: string, value: string) => {
|
|
1903
2295
|
if (!domEditSelection) return;
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
if (isMoveStyle && !domEditSelection.capabilities.canMove) return;
|
|
1907
|
-
if (isResizeStyle && !domEditSelection.capabilities.canResize) return;
|
|
1908
|
-
if (!isMoveStyle && !isResizeStyle && !domEditSelection.capabilities.canEditStyles) return;
|
|
2296
|
+
if (isManualGeometryStyleProperty(property)) return;
|
|
2297
|
+
if (!domEditSelection.capabilities.canEditStyles) return;
|
|
1909
2298
|
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
1910
2299
|
const iframe = previewIframeRef.current;
|
|
1911
2300
|
const doc = iframe?.contentDocument;
|
|
1912
2301
|
if (doc) {
|
|
1913
|
-
const el = findElementForSelection(doc, domEditSelection,
|
|
2302
|
+
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
1914
2303
|
if (el) {
|
|
1915
2304
|
el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
|
|
1916
2305
|
if (property === "font-family") {
|
|
1917
2306
|
injectPreviewGoogleFont(doc, value);
|
|
1918
2307
|
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
1919
2308
|
}
|
|
1920
|
-
if (shouldDetachOppositeEdges(domEditSelection)) {
|
|
1921
|
-
if (property === "width") el.style.right = "auto";
|
|
1922
|
-
if (property === "height") el.style.bottom = "auto";
|
|
1923
|
-
}
|
|
1924
2309
|
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
1925
2310
|
el.style.setProperty("background-position", "center");
|
|
1926
2311
|
el.style.setProperty("background-repeat", "no-repeat");
|
|
@@ -1931,11 +2316,7 @@ export function StudioApp() {
|
|
|
1931
2316
|
const operations: PatchOperation[] = [
|
|
1932
2317
|
buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
|
|
1933
2318
|
];
|
|
1934
|
-
if (property === "
|
|
1935
|
-
operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "width"));
|
|
1936
|
-
} else if (property === "height") {
|
|
1937
|
-
operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "height"));
|
|
1938
|
-
} else if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
2319
|
+
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
1939
2320
|
operations.push(
|
|
1940
2321
|
buildDomEditStylePatchOperation("background-position", "center"),
|
|
1941
2322
|
buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
|
|
@@ -1950,7 +2331,7 @@ export function StudioApp() {
|
|
|
1950
2331
|
: undefined,
|
|
1951
2332
|
});
|
|
1952
2333
|
},
|
|
1953
|
-
[domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
|
|
2334
|
+
[activeCompPath, domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
|
|
1954
2335
|
);
|
|
1955
2336
|
|
|
1956
2337
|
const handleDomTextCommit = useCallback(
|
|
@@ -1972,7 +2353,7 @@ export function StudioApp() {
|
|
|
1972
2353
|
const iframe = previewIframeRef.current;
|
|
1973
2354
|
const doc = iframe?.contentDocument;
|
|
1974
2355
|
if (doc) {
|
|
1975
|
-
const el = findElementForSelection(doc, domEditSelection,
|
|
2356
|
+
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
1976
2357
|
if (el) {
|
|
1977
2358
|
if (
|
|
1978
2359
|
nextTextFields.length > 1 ||
|
|
@@ -1996,20 +2377,22 @@ export function StudioApp() {
|
|
|
1996
2377
|
if (domTextCommitVersionRef.current !== commitVersion) return;
|
|
1997
2378
|
|
|
1998
2379
|
if (doc) {
|
|
1999
|
-
const refreshed = findElementForSelection(
|
|
2000
|
-
doc,
|
|
2001
|
-
domEditSelection,
|
|
2002
|
-
domEditSelection.sourceFile,
|
|
2003
|
-
);
|
|
2380
|
+
const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
2004
2381
|
if (refreshed) {
|
|
2005
2382
|
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
2006
2383
|
if (nextSelection) {
|
|
2007
|
-
applyDomSelection(nextSelection, { revealPanel: false });
|
|
2384
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
2008
2385
|
}
|
|
2009
2386
|
}
|
|
2010
2387
|
}
|
|
2011
2388
|
},
|
|
2012
|
-
[
|
|
2389
|
+
[
|
|
2390
|
+
activeCompPath,
|
|
2391
|
+
applyDomSelection,
|
|
2392
|
+
buildDomSelectionFromTarget,
|
|
2393
|
+
domEditSelection,
|
|
2394
|
+
persistDomEditOperations,
|
|
2395
|
+
],
|
|
2013
2396
|
);
|
|
2014
2397
|
|
|
2015
2398
|
const commitDomTextFields = useCallback(
|
|
@@ -2026,7 +2409,7 @@ export function StudioApp() {
|
|
|
2026
2409
|
const iframe = previewIframeRef.current;
|
|
2027
2410
|
const doc = iframe?.contentDocument;
|
|
2028
2411
|
if (doc) {
|
|
2029
|
-
const el = findElementForSelection(doc, selection,
|
|
2412
|
+
const el = findElementForSelection(doc, selection, activeCompPath);
|
|
2030
2413
|
if (el) {
|
|
2031
2414
|
if (
|
|
2032
2415
|
nextTextFields.length > 1 ||
|
|
@@ -2049,16 +2432,16 @@ export function StudioApp() {
|
|
|
2049
2432
|
});
|
|
2050
2433
|
|
|
2051
2434
|
if (doc) {
|
|
2052
|
-
const refreshed = findElementForSelection(doc, selection,
|
|
2435
|
+
const refreshed = findElementForSelection(doc, selection, activeCompPath);
|
|
2053
2436
|
if (refreshed) {
|
|
2054
2437
|
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
2055
2438
|
if (nextSelection) {
|
|
2056
|
-
applyDomSelection(nextSelection, { revealPanel: false });
|
|
2439
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
2057
2440
|
}
|
|
2058
2441
|
}
|
|
2059
2442
|
}
|
|
2060
2443
|
},
|
|
2061
|
-
[applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
|
|
2444
|
+
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
|
|
2062
2445
|
);
|
|
2063
2446
|
|
|
2064
2447
|
const handleDomTextFieldStyleCommit = useCallback(
|
|
@@ -2184,84 +2567,93 @@ export function StudioApp() {
|
|
|
2184
2567
|
previewIframeRef.current = iframe;
|
|
2185
2568
|
setPreviewIframe(iframe);
|
|
2186
2569
|
syncPreviewTimelineHotkey(iframe);
|
|
2570
|
+
syncPreviewHistoryHotkey(iframe);
|
|
2187
2571
|
consoleErrorsRef.current = [];
|
|
2188
2572
|
setConsoleErrors(null);
|
|
2189
2573
|
},
|
|
2190
|
-
[syncPreviewTimelineHotkey],
|
|
2574
|
+
[syncPreviewHistoryHotkey, syncPreviewTimelineHotkey],
|
|
2191
2575
|
);
|
|
2192
2576
|
|
|
2193
2577
|
const handlePreviewCanvasMouseDown = useCallback(
|
|
2194
2578
|
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
|
|
2198
|
-
if (!target) {
|
|
2199
|
-
lastPreviewClickRef.current = null;
|
|
2200
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2201
|
-
return;
|
|
2202
|
-
}
|
|
2203
|
-
e.preventDefault();
|
|
2204
|
-
e.stopPropagation();
|
|
2205
|
-
const nextSelection = buildDomSelectionFromTarget(target, {
|
|
2579
|
+
if (captionEditMode) return;
|
|
2580
|
+
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
2206
2581
|
preferClipAncestor: options?.preferClipAncestor ?? true,
|
|
2207
2582
|
});
|
|
2208
2583
|
if (!nextSelection) {
|
|
2209
|
-
|
|
2210
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2584
|
+
if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
|
|
2211
2585
|
return;
|
|
2212
2586
|
}
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
const now = Date.now();
|
|
2217
|
-
if (last && last.key === key && now - last.at < 350) {
|
|
2218
|
-
lastPreviewClickRef.current = null;
|
|
2219
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2220
|
-
setActiveCompPath(nextSelection.compositionSrc);
|
|
2221
|
-
return;
|
|
2222
|
-
}
|
|
2223
|
-
lastPreviewClickRef.current = { key, at: now };
|
|
2224
|
-
} else {
|
|
2225
|
-
lastPreviewClickRef.current = null;
|
|
2226
|
-
}
|
|
2227
|
-
applyDomSelection(nextSelection);
|
|
2587
|
+
e.preventDefault();
|
|
2588
|
+
e.stopPropagation();
|
|
2589
|
+
applyDomSelection(nextSelection, { additive: e.shiftKey });
|
|
2228
2590
|
},
|
|
2229
|
-
[applyDomSelection,
|
|
2591
|
+
[applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint],
|
|
2230
2592
|
);
|
|
2231
2593
|
|
|
2232
|
-
const
|
|
2233
|
-
(e: React.
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
if (!target) return;
|
|
2238
|
-
const nextSelection = buildDomSelectionFromTarget(target, {
|
|
2239
|
-
preferClipAncestor: false,
|
|
2240
|
-
});
|
|
2241
|
-
if (!nextSelection?.isCompositionHost || !isMasterView || !nextSelection.compositionSrc) {
|
|
2242
|
-
return;
|
|
2594
|
+
const handlePreviewCanvasPointerMove = useCallback(
|
|
2595
|
+
(e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
2596
|
+
if (captionEditMode) {
|
|
2597
|
+
updateDomEditHoverSelection(null);
|
|
2598
|
+
return null;
|
|
2243
2599
|
}
|
|
2244
|
-
|
|
2245
|
-
e.
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2600
|
+
|
|
2601
|
+
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
2602
|
+
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
2603
|
+
});
|
|
2604
|
+
updateDomEditHoverSelection(nextSelection);
|
|
2605
|
+
return nextSelection;
|
|
2249
2606
|
},
|
|
2250
|
-
[
|
|
2607
|
+
[captionEditMode, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection],
|
|
2251
2608
|
);
|
|
2252
2609
|
|
|
2253
|
-
const
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2610
|
+
const handlePreviewCanvasPointerLeave = useCallback(() => {
|
|
2611
|
+
updateDomEditHoverSelection(null);
|
|
2612
|
+
}, [updateDomEditHoverSelection]);
|
|
2613
|
+
|
|
2614
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
2615
|
+
useEffect(() => {
|
|
2616
|
+
if (captionEditMode) updateDomEditHoverSelection(null);
|
|
2617
|
+
}, [captionEditMode, updateDomEditHoverSelection]);
|
|
2618
|
+
|
|
2619
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
2620
|
+
useEffect(() => {
|
|
2621
|
+
updateDomEditHoverSelection(null);
|
|
2622
|
+
}, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
|
|
2623
|
+
|
|
2624
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
2625
|
+
useEffect(() => {
|
|
2626
|
+
if (!domEditHoverSelection) return;
|
|
2627
|
+
const hoverMatchesSelection = domEditSelectionsTargetSame(
|
|
2628
|
+
domEditHoverSelection,
|
|
2629
|
+
domEditSelection,
|
|
2630
|
+
);
|
|
2631
|
+
const hoverMatchesGroup = domEditSelectionInGroup(
|
|
2632
|
+
domEditGroupSelections,
|
|
2633
|
+
domEditHoverSelection,
|
|
2634
|
+
);
|
|
2635
|
+
if (!hoverMatchesSelection && !hoverMatchesGroup) return;
|
|
2636
|
+
updateDomEditHoverSelection(null);
|
|
2637
|
+
}, [
|
|
2638
|
+
domEditGroupSelections,
|
|
2639
|
+
domEditHoverSelection,
|
|
2640
|
+
domEditSelection,
|
|
2641
|
+
updateDomEditHoverSelection,
|
|
2642
|
+
]);
|
|
2259
2643
|
|
|
2260
2644
|
// eslint-disable-next-line no-restricted-syntax
|
|
2261
2645
|
useEffect(() => {
|
|
2262
|
-
if (!
|
|
2646
|
+
if (!domEditHoverSelection) return;
|
|
2647
|
+
if (domEditHoverSelection.element.isConnected) return;
|
|
2648
|
+
updateDomEditHoverSelection(null);
|
|
2649
|
+
}, [domEditHoverSelection, updateDomEditHoverSelection]);
|
|
2650
|
+
|
|
2651
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
2652
|
+
useEffect(() => {
|
|
2653
|
+
if (!previewIframe) return;
|
|
2263
2654
|
|
|
2264
2655
|
const syncSelectionFromDocument = () => {
|
|
2656
|
+
if (captionEditMode) return;
|
|
2265
2657
|
const currentSelection = domEditSelectionRef.current;
|
|
2266
2658
|
if (!currentSelection) return;
|
|
2267
2659
|
let doc: Document | null = null;
|
|
@@ -2280,7 +2672,7 @@ export function StudioApp() {
|
|
|
2280
2672
|
|
|
2281
2673
|
const nextSelection = buildDomSelectionFromTarget(nextElement);
|
|
2282
2674
|
if (nextSelection) {
|
|
2283
|
-
applyDomSelection(nextSelection, { revealPanel: false });
|
|
2675
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
2284
2676
|
}
|
|
2285
2677
|
};
|
|
2286
2678
|
|
|
@@ -2315,12 +2707,16 @@ export function StudioApp() {
|
|
|
2315
2707
|
};
|
|
2316
2708
|
|
|
2317
2709
|
attachErrorCapture();
|
|
2710
|
+
syncPreviewHistoryHotkey(previewIframe);
|
|
2711
|
+
void applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
|
|
2318
2712
|
syncSelectionFromDocument();
|
|
2319
2713
|
|
|
2320
2714
|
const handleLoad = () => {
|
|
2321
2715
|
consoleErrorsRef.current = [];
|
|
2322
2716
|
setConsoleErrors(null);
|
|
2323
2717
|
attachErrorCapture();
|
|
2718
|
+
syncPreviewHistoryHotkey(previewIframe);
|
|
2719
|
+
void applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
|
|
2324
2720
|
syncSelectionFromDocument();
|
|
2325
2721
|
};
|
|
2326
2722
|
|
|
@@ -2331,9 +2727,11 @@ export function StudioApp() {
|
|
|
2331
2727
|
}, [
|
|
2332
2728
|
activeCompPath,
|
|
2333
2729
|
applyDomSelection,
|
|
2730
|
+
applyStudioManualEditsToPreviewAfterRefresh,
|
|
2334
2731
|
buildDomSelectionFromTarget,
|
|
2335
2732
|
captionEditMode,
|
|
2336
2733
|
previewIframe,
|
|
2734
|
+
syncPreviewHistoryHotkey,
|
|
2337
2735
|
]);
|
|
2338
2736
|
|
|
2339
2737
|
// eslint-disable-next-line no-restricted-syntax
|
|
@@ -3016,16 +3414,25 @@ export function StudioApp() {
|
|
|
3016
3414
|
) : (
|
|
3017
3415
|
<DomEditOverlay
|
|
3018
3416
|
iframeRef={previewIframeRef}
|
|
3417
|
+
activeCompositionPath={activeCompPath}
|
|
3418
|
+
hoverSelection={captionEditMode ? null : domEditHoverSelection}
|
|
3019
3419
|
selection={
|
|
3020
3420
|
!rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
|
|
3021
3421
|
}
|
|
3022
|
-
|
|
3422
|
+
groupSelections={
|
|
3423
|
+
!rightCollapsed && rightPanelTab === "design" ? domEditGroupSelections : []
|
|
3424
|
+
}
|
|
3425
|
+
allowCanvasMovement
|
|
3023
3426
|
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
3024
|
-
|
|
3025
|
-
|
|
3427
|
+
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
|
3428
|
+
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
|
3429
|
+
onSelectionChange={applyDomSelection}
|
|
3026
3430
|
onBlockedMove={handleBlockedDomMove}
|
|
3027
|
-
|
|
3028
|
-
|
|
3431
|
+
onManualDragStart={handleDomManualDragStart}
|
|
3432
|
+
onPathOffsetCommit={handleDomPathOffsetCommit}
|
|
3433
|
+
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
3434
|
+
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
3435
|
+
onRotationCommit={handleDomRotationCommit}
|
|
3029
3436
|
/>
|
|
3030
3437
|
)
|
|
3031
3438
|
}
|
|
@@ -3100,21 +3507,21 @@ export function StudioApp() {
|
|
|
3100
3507
|
<PropertyPanel
|
|
3101
3508
|
projectId={projectId}
|
|
3102
3509
|
assets={assets}
|
|
3103
|
-
element={domEditSelection}
|
|
3510
|
+
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
3104
3511
|
copiedAgentPrompt={copiedAgentPrompt}
|
|
3105
3512
|
onClearSelection={clearDomSelection}
|
|
3106
3513
|
onSetStyle={handleDomStyleCommit}
|
|
3514
|
+
onSetManualOffset={handleDomPathOffsetCommit}
|
|
3515
|
+
onSetManualSize={handleDomBoxSizeCommit}
|
|
3107
3516
|
onSetText={handleDomTextCommit}
|
|
3108
3517
|
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
|
3109
3518
|
onAddTextField={handleDomAddTextField}
|
|
3110
3519
|
onRemoveTextField={handleDomRemoveTextField}
|
|
3111
|
-
|
|
3520
|
+
onResetManualEdits={handleDomManualEditsReset}
|
|
3112
3521
|
onAskAgent={handleAskAgent}
|
|
3113
|
-
onCopyAgentInstruction={handleAgentModalSubmit}
|
|
3114
3522
|
onImportAssets={handleImportFiles}
|
|
3115
3523
|
fontAssets={fontAssets}
|
|
3116
3524
|
onImportFonts={handleImportFonts}
|
|
3117
|
-
allowLayoutDetach={false}
|
|
3118
3525
|
/>
|
|
3119
3526
|
) : (
|
|
3120
3527
|
<RenderQueue
|
|
@@ -3122,9 +3529,10 @@ export function StudioApp() {
|
|
|
3122
3529
|
projectId={projectId}
|
|
3123
3530
|
onDelete={renderQueue.deleteRender}
|
|
3124
3531
|
onClearCompleted={renderQueue.clearCompleted}
|
|
3125
|
-
onStartRender={(format, quality) =>
|
|
3126
|
-
|
|
3127
|
-
|
|
3532
|
+
onStartRender={async (format, quality) => {
|
|
3533
|
+
await waitForPendingDomEditSaves();
|
|
3534
|
+
await renderQueue.startRender(30, quality, format);
|
|
3535
|
+
}}
|
|
3128
3536
|
isRendering={renderQueue.isRendering}
|
|
3129
3537
|
/>
|
|
3130
3538
|
)}
|