fix(studio): a hand edit after a preview reload draws on the element now on screen (#4856)

* fix(studio): a hand edit after a preview reload draws on the element now on screen

* fix(studio): a preview reload waits for the gesture on screen to end

* fix(studio): a marquee after a reload selects from the preview on screen

* fix(studio): a reload requested before a canvas save loads again once the save lands
This commit is contained in:
Miguel Ángel
2026-10-01 11:32:06 -07:00
committed by GitHub
parent 44138931ce
commit 57da627e76
19 changed files with 1019 additions and 247 deletions
@@ -31,6 +31,8 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
import { CanvasContextMenu } from "./CanvasContextMenu";
import { useInlineTextEditing } from "./useInlineTextEditing";
import { usePreviewReadOnly } from "./previewReadOnlyContext";
import { useMountEffect } from "../../hooks/useMountEffect";
import { noteStudioManualEditSave as noteSave } from "./manualEditsDom";
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
import { logSelect } from "../../utils/selectDebug";
@@ -193,16 +195,34 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onTextEditingChangeRef.current?.(true);
return () => onTextEditingChangeRef.current?.(false);
}, [inlineText.editing]);
// Each canvas save is counted before it starts, so a reload requested earlier loads again.
const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
onPathOffsetCommitRef.current = onPathOffsetCommit;
onPathOffsetCommitRef.current = (sel, ...rest) => {
noteSave(sel.element);
return onPathOffsetCommit(sel, ...rest);
};
const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
onGroupPathOffsetCommitRef.current = onGroupPathOffsetCommit;
onGroupPathOffsetCommitRef.current = (updates) => {
if (updates[0]) noteSave(updates[0].selection.element);
return onGroupPathOffsetCommit(updates);
};
const onBoxSizeCommitRef = useRef(onBoxSizeCommit);
onBoxSizeCommitRef.current = onBoxSizeCommit;
onBoxSizeCommitRef.current = (sel, ...rest) => {
noteSave(sel.element);
return onBoxSizeCommit(sel, ...rest);
};
const onRotationCommitRef = useRef(onRotationCommit);
onRotationCommitRef.current = onRotationCommit;
onRotationCommitRef.current = (sel, next) => {
noteSave(sel.element);
return onRotationCommit(sel, next);
};
const onStyleCommitRef = useRef(onStyleCommit);
onStyleCommitRef.current = onStyleCommit;
onStyleCommitRef.current =
onStyleCommit &&
((property, value) => {
if (selectionRef.current) noteSave(selectionRef.current.element);
return onStyleCommit(property, value);
});
const onBlockedMoveRef = useRef(onBlockedMove);
onBlockedMoveRef.current = onBlockedMove;
const onManualDragStartRef = useRef(onManualDragStart);
@@ -282,6 +302,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
useEffect(() => {
if (readOnly) gestures.clearPointerState(selectionRef);
}, [gestures, readOnly, selectionRef]);
// A gesture that loses its pointer, the window or the overlay is cancelled, so its mark goes too.
const cancelGestureRef = useRef(() => {});
cancelGestureRef.current = () => gestures.clearPointerState(selectionRef);
useMountEffect(() => {
const cancel = () => cancelGestureRef.current();
window.addEventListener("blur", cancel);
return () => {
window.removeEventListener("blur", cancel);
cancel();
};
});
// Arrow-key nudge (1px, Shift = 10px) — commits through the same
// path-offset callbacks as a drag, one undo entry per key burst.
@@ -463,6 +494,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
onPointerUp={marquee.onPointerUp}
onPointerCancel={marquee.onPointerCancel}
onLostPointerCapture={() => cancelGestureRef.current()}
onContextMenu={hostInput ? undefined : handleContextMenu}
>
{!hostInput && hoverSelection && hoverRect && compRect.width > 0 && (
@@ -12,6 +12,7 @@ import {
import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
import {
beginStudioManualEditGesture,
endStudioManualEditGesture,
captureStudioBoxSize,
captureStudioPathOffset,
captureStudioRotation,
@@ -81,48 +82,54 @@ export function startGroupDrag(
}
members.push(result.member);
}
resetDragMoveLog();
logDrag("group-start", {
// A member whose mapping differs from its neighbours travels a different
// distance for the same pointer delta, which is the group coming apart.
members: Object.fromEntries(
members.map((member) => [
member.key,
{
map: `${member.screenToOffset.a.toFixed(3)},${member.screenToOffset.d.toFixed(3)}`,
base: `${Math.round(member.baseGsap.x)},${Math.round(member.baseGsap.y)}`,
offset: `${Math.round(member.initialOffset.x)},${Math.round(member.initialOffset.y)}`,
},
]),
),
at: readDragPositions(members),
});
try {
resetDragMoveLog();
logDrag("group-start", {
// A member whose mapping differs from its neighbours travels a different
// distance for the same pointer delta, which is the group coming apart.
members: Object.fromEntries(
members.map((member) => [
member.key,
{
map: `${member.screenToOffset.a.toFixed(3)},${member.screenToOffset.d.toFixed(3)}`,
base: `${Math.round(member.baseGsap.x)},${Math.round(member.baseGsap.y)}`,
offset: `${Math.round(member.initialOffset.x)},${Math.round(member.initialOffset.y)}`,
},
]),
),
at: readDragPositions(members),
});
const overlayEl = opts.overlayRef.current;
const iframe = opts.iframeRef.current;
const snapContext =
overlayEl && iframe
? collectSnapContext({
overlayEl,
iframe,
excludeElements: buildExcludeElements({
const overlayEl = opts.overlayRef.current;
const iframe = opts.iframeRef.current;
const snapContext =
overlayEl && iframe
? collectSnapContext({
overlayEl,
iframe,
groupSelections: items.map((i) => i.selection),
}),
})
: undefined;
excludeElements: buildExcludeElements({
iframe,
groupSelections: items.map((i) => i.selection),
}),
})
: undefined;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
opts.rafPausedRef.current = true;
opts.groupGestureRef.current = {
startX: e.clientX,
startY: e.clientY,
originItems: items,
members,
snapContext,
};
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
opts.rafPausedRef.current = true;
opts.groupGestureRef.current = {
startX: e.clientX,
startY: e.clientY,
originItems: items,
members,
snapContext,
};
} catch (error) {
opts.groupGestureRef.current = null;
restoreManualOffsetDragMembers(members);
throw error;
}
return true;
}
@@ -219,84 +226,89 @@ export function startGesture(
}
}
// Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
// move, never asks GSAP: reading a property makes it bake the CSS into its transform.
const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
const rotation = { angle: readRotationBase(sel.element, plain) };
const overlayBounds = overlayEl?.getBoundingClientRect();
const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
// A throw before the gesture is armed would leave its mark, and the mark holds every reload.
try {
// Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
// move, never asks GSAP: reading a property makes it bake the CSS into its transform.
const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
const rotation = { angle: readRotationBase(sel.element, plain) };
const overlayBounds = overlayEl?.getBoundingClientRect();
const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
const iframe = opts.iframeRef.current;
const iframe = opts.iframeRef.current;
// For a center-anchored corner resize, capture the element's rendered CENTER (the
// centroid of its four real, rotation-aware corners) now, so per-frame anchoring
// can pin that exact point instead of an axis-aligned width/height delta (which
// only holds the center still when the element grows symmetrically from an
// unrotated layout box). Present whenever an anchor member exists (all corners).
let resizeFixedCenterStart: { x: number; y: number } | undefined;
if (kind === "resize" && pathOffsetMember && overlayEl && iframe) {
const corners = elementCornerOverlayPoints(overlayEl, iframe, sel.element);
if (corners) resizeFixedCenterStart = overlayCornersCentroid(corners);
}
const snapContext =
(kind === "drag" || kind === "resize") && overlayEl && iframe
? collectSnapContext({
overlayEl,
iframe,
excludeElements: buildExcludeElements({ iframe, selection: sel }),
})
: undefined;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
opts.rafPausedRef.current = true;
opts.gestureRef.current = {
kind,
mode,
selection: sel,
startX: e.clientX,
startY: e.clientY,
centerX,
centerY,
initialPathOffset,
initialRotation: captureStudioRotation(sel.element),
initialBoxSize: captureStudioBoxSize(sel.element),
pathOffsetMember,
originLeft: rect.left,
originTop: rect.top,
originWidth: rect.width,
originHeight: rect.height,
actualWidth,
actualHeight,
actualRotation: rotation.angle,
plainRotation,
editScaleX: rect.editScaleX,
editScaleY: rect.editScaleY,
contentScaleX,
contentScaleY,
manualEditDragToken,
snapContext,
resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
resizeFixedCenterStart,
};
if (kind === "resize") {
resetResizeMoveLog();
logResize("start", {
handle: options?.resizeHandle ?? "se",
pointer: { x: e.clientX, y: e.clientY },
center: { x: centerX, y: centerY },
origin: { left: rect.left, top: rect.top, w: rect.width, h: rect.height },
actual: { w: actualWidth, h: actualHeight },
editScale: { x: rect.editScaleX, y: rect.editScaleY },
contentScale: { x: contentScaleX, y: contentScaleY },
rotation: rotation.angle,
hasOffsetMember: !!pathOffsetMember,
fixedCenterStart: resizeFixedCenterStart ?? null,
initialBoxSize: opts.gestureRef.current?.initialBoxSize ?? null,
initialInlineStyle: sel.element.getAttribute("style"),
});
// A corner resize pins the centroid of the four rotation-aware corners: an axis-aligned
// size delta only holds the centre still for a symmetric, unrotated growth.
let resizeFixedCenterStart: { x: number; y: number } | undefined;
if (kind === "resize" && pathOffsetMember && overlayEl && iframe) {
const corners = elementCornerOverlayPoints(overlayEl, iframe, sel.element);
if (corners) resizeFixedCenterStart = overlayCornersCentroid(corners);
}
const snapContext =
(kind === "drag" || kind === "resize") && overlayEl && iframe
? collectSnapContext({
overlayEl,
iframe,
excludeElements: buildExcludeElements({ iframe, selection: sel }),
})
: undefined;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
opts.rafPausedRef.current = true;
opts.gestureRef.current = {
kind,
mode,
selection: sel,
startX: e.clientX,
startY: e.clientY,
centerX,
centerY,
initialPathOffset,
initialRotation: captureStudioRotation(sel.element),
initialBoxSize: captureStudioBoxSize(sel.element),
pathOffsetMember,
originLeft: rect.left,
originTop: rect.top,
originWidth: rect.width,
originHeight: rect.height,
actualWidth,
actualHeight,
actualRotation: rotation.angle,
plainRotation,
editScaleX: rect.editScaleX,
editScaleY: rect.editScaleY,
contentScaleX,
contentScaleY,
manualEditDragToken,
snapContext,
resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
resizeFixedCenterStart,
};
if (kind === "resize") {
resetResizeMoveLog();
logResize("start", {
handle: options?.resizeHandle ?? "se",
pointer: { x: e.clientX, y: e.clientY },
center: { x: centerX, y: centerY },
origin: { left: rect.left, top: rect.top, w: rect.width, h: rect.height },
actual: { w: actualWidth, h: actualHeight },
editScale: { x: rect.editScaleX, y: rect.editScaleY },
contentScale: { x: contentScaleX, y: contentScaleY },
rotation: rotation.angle,
hasOffsetMember: !!pathOffsetMember,
fixedCenterStart: resizeFixedCenterStart ?? null,
initialBoxSize: opts.gestureRef.current?.initialBoxSize ?? null,
initialInlineStyle: sel.element.getAttribute("style"),
});
}
} catch (error) {
opts.gestureRef.current = null;
if (pathOffsetMember) restoreManualOffsetDragMembers([pathOffsetMember]);
else if (manualEditDragToken) endStudioManualEditGesture(sel.element, manualEditDragToken);
throw error;
}
return true;
}
@@ -0,0 +1,275 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeSelection } from "../../hooks/domSelectionTestHarness";
import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
import {
makePreview,
mountPlayerWithPreview,
paintShadow,
resetPlayerStore,
type TimelinePlayerApi,
} from "../../player/hooks/timelinePlayerTestHarness";
import type { DomEditSelection } from "./domEditing";
import "./domEditOverlayTestMocks";
import { DomEditOverlay } from "./DomEditOverlay";
import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "./manualEditsTypes";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const layout = vi.hoisted(() => ({
group: [] as unknown[],
rect: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
}));
vi.mock("./useDomEditOverlayRects", () => ({
useDomEditOverlayRects: () => {
const { rect, group } = layout;
const groupOverlayItemsRef = { current: group };
const noop = () => undefined;
return {
overlayRect: rect,
overlayRectRef: { current: rect },
groupOverlayItems: group,
groupOverlayItemsRef,
hoverRect: null,
childRects: [],
setOverlayRect: noop,
setGroupOverlayItems: noop,
};
},
}));
vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({
...(await importOriginal<typeof import("../../utils/gsapSoftReload")>()),
ensureMotionPathPluginLoaded: vi.fn(),
}));
const BOX = '[data-dom-edit-selection-box="true"]';
const RECT = layout.rect;
const TWO_LAYERS = '<h1 id="title">Title</h1><p id="sub">Sub</p>';
/** What the server serves: the file as the last commit left it. */
let file = { title: "", sub: "" };
const served = (query: string) =>
makePreview(
`<h1 id="title" style="${file.title}">Title</h1><p id="sub" style="${file.sub}">Sub</p>`,
query,
);
let overlayRoot: Root;
let player: ReturnType<typeof mountPlayerWithPreview>;
const marked = (doc: Document) => doc.querySelectorAll(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`);
const byId = (iframe: HTMLIFrameElement | null, id: string) =>
iframe!.contentDocument!.getElementById(id) as HTMLElement;
const api = (): TimelinePlayerApi => player.getApi();
function pointer(target: Element, type: string, x: number, y: number) {
const init = { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: x, clientY: y };
act(() => void target.dispatchEvent(new PointerEvent(type, init)));
}
/** The real player with a two-layer preview, and the real overlay editing it. */
function mountEditor(group: boolean) {
file = { title: "", sub: "" };
const live = makePreview(TWO_LAYERS);
player = mountPlayerWithPreview(live);
const selections = ["title", "sub"].map((id) => makeSelection(id, byId(live, id)));
layout.group = group
? selections.map((s) => ({ key: s.id, selection: s, element: s.element, rect: RECT }))
: [];
const write = (sel: DomEditSelection, next: { x: number; y: number }) => {
file = { ...file, [sel.id!]: `translate: ${next.x}px ${next.y}px` };
return Promise.resolve();
};
const onPathOffsetCommit = vi.fn(write);
const onGroupPathOffsetCommit = vi.fn(
async (updates: { selection: DomEditSelection; next: { x: number; y: number } }[]) => {
for (const { selection, next } of updates) await write(selection, next);
},
);
const host = document.body.appendChild(document.createElement("div"));
overlayRoot = createRoot(host);
const render = (readOnly: boolean) =>
act(() =>
overlayRoot.render(
<PreviewReadOnlyProvider readOnly={readOnly}>
<DomEditOverlay
iframeRef={api().iframeRef}
activeCompositionPath={null}
selection={group ? null : selections[0]!}
groupSelections={group ? selections : []}
hoverSelection={null}
onCanvasMouseDown={() => undefined}
onCanvasPointerMove={() => Promise.resolve(null)}
onCanvasPointerLeave={() => undefined}
onSelectionChange={() => undefined}
onBlockedMove={() => undefined}
onPathOffsetCommit={onPathOffsetCommit}
onGroupPathOffsetCommit={onGroupPathOffsetCommit}
onBoxSizeCommit={() => undefined}
onRotationCommit={() => undefined}
/>
</PreviewReadOnlyProvider>,
),
);
render(false);
const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLElement;
return { live, overlay, box: overlay.querySelector(BOX)!, onPathOffsetCommit, render };
}
/** A reload whose shadow paints while the gesture is still live. */
async function reloadMidGesture() {
act(() => api().refreshPlayer());
const shadow = served("?_t=1");
const gen = await paintShadow(api, shadow);
return { shadow, gen };
}
async function settle() {
await act(async () => {
for (let tick = 0; tick < 10; tick++) await Promise.resolve();
});
}
beforeEach(() => {
HTMLElement.prototype.setPointerCapture = () => undefined;
HTMLElement.prototype.releasePointerCapture = () => undefined;
});
afterEach(() => {
act(() => overlayRoot?.unmount());
act(() => player?.root.unmount());
layout.group = [];
document.body.innerHTML = "";
resetPlayerStore();
});
describe("a reload during a drag", () => {
it.each([
["one layer", false],
["a group", true],
])(
"that paints before the drop of %s waits, then shows the drop from a fresh load",
async (_, group) => {
const { live, overlay, box } = mountEditor(group);
pointer(box, "pointerdown", 150, 150);
pointer(overlay, "pointermove", 170, 160);
pointer(overlay, "pointermove", 190, 170);
const held = await reloadMidGesture();
expect(api().iframeRef.current, "promoted under the pointer").toBe(live);
pointer(overlay, "pointerup", 190, 170);
await settle();
expect(byId(live, "title").style.getPropertyValue("translate")).toBe("40px 20px");
expect(file.title).toBe("translate: 40px 20px");
if (group) expect(file.sub).toBe("translate: 40px 20px");
expect(marked(live.contentDocument!)).toHaveLength(0);
expect(api().iframeRef.current, "the shadow loaded before the drop").toBe(live);
const fresh = served("?_t=2");
expect(await paintShadow(api, fresh)).toBeGreaterThan(held.gen);
expect(api().iframeRef.current).toBe(fresh);
expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
if (group) expect(byId(fresh, "sub").style.getPropertyValue("translate")).toBe("40px 20px");
},
);
it("that paints after the drop shows the drop from a fresh load, not the file before it", async () => {
const { live, overlay, box } = mountEditor(false);
pointer(box, "pointerdown", 150, 150);
pointer(overlay, "pointermove", 190, 170);
act(() => api().refreshPlayer());
const beforeDrop = served("?_t=1");
pointer(overlay, "pointerup", 190, 170);
await settle();
expect(file.title).toBe("translate: 40px 20px");
const requested = await paintShadow(api, beforeDrop);
expect(api().iframeRef.current, "the file before the drop").toBe(live);
const fresh = served("?_t=2");
expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
});
});
type Editor = ReturnType<typeof mountEditor>;
describe("every way a drag ends without a drop clears its mark and promotes the held reload", () => {
it.each([
["pointercancel", (e: Editor) => pointer(e.overlay, "pointercancel", 0, 0)],
["lostpointercapture", (e: Editor) => pointer(e.box, "lostpointercapture", 0, 0)],
["window blur", () => act(() => void window.dispatchEvent(new Event("blur")))],
["a switch to read-only", (e: Editor) => e.render(true)],
["overlay unmount", () => act(() => overlayRoot.unmount())],
])("%s", async (_, end) => {
const editor = mountEditor(false);
pointer(editor.box, "pointerdown", 150, 150);
pointer(editor.overlay, "pointermove", 190, 170);
expect(marked(editor.live.contentDocument!)).toHaveLength(1);
const held = await reloadMidGesture();
expect(api().iframeRef.current).toBe(editor.live);
end(editor);
await settle();
expect(marked(editor.live.contentDocument!)).toHaveLength(0);
expect(byId(editor.live, "title").style.getPropertyValue("translate")).not.toBe("40px 20px");
expect(editor.onPathOffsetCommit).not.toHaveBeenCalled();
expect(api().iframeRef.current, "nothing was saved, so the held copy is current").toBe(
held.shadow,
);
});
it("a press that throws before the drag is armed leaves no mark behind", async () => {
const editor = mountEditor(false);
HTMLElement.prototype.setPointerCapture = () => {
throw new Error("capture refused");
};
const swallow = (event: ErrorEvent) => event.preventDefault();
window.addEventListener("error", swallow);
try {
pointer(editor.box, "pointerdown", 150, 150);
} catch {
// React may rethrow the handler's error; either way the mark must be gone.
} finally {
window.removeEventListener("error", swallow);
}
expect(marked(editor.live.contentDocument!)).toHaveLength(0);
const { shadow } = await reloadMidGesture();
expect(api().iframeRef.current).toBe(shadow);
});
});
describe("an inline text edit", () => {
it("holds a reload while open, and the reload after it shows the saved text", async () => {
const live = makePreview(TWO_LAYERS);
player = mountPlayerWithPreview(live);
let land: () => void = () => {};
const onCommit = vi.fn(() => new Promise<void>((resolve) => (land = resolve)));
let controls: ReturnType<typeof useInlineTextEdit> | null = null;
function Editor() {
controls = useInlineTextEdit({ onCommit });
return null;
}
overlayRoot = createRoot(document.body.appendChild(document.createElement("div")));
act(() => overlayRoot.render(<Editor />));
act(() => void controls!.start(byId(live, "title")));
const held = await reloadMidGesture();
expect(api().iframeRef.current).toBe(live);
act(() => controls!.commit());
await settle();
expect(onCommit).toHaveBeenCalledTimes(1);
expect(marked(live.contentDocument!), "closed, though the save is still out").toHaveLength(0);
expect(api().iframeRef.current, "the held copy predates the text").toBe(live);
expect(api().previewSlots.find((slot) => slot.role === "shadow")?.gen).toBe(held.gen);
await act(async () => land());
await settle();
const afterSave = served("?_t=2");
expect(await paintShadow(api, afterSave)).toBeGreaterThan(held.gen);
expect(api().iframeRef.current).toBe(afterSave);
});
});
@@ -46,9 +46,42 @@ export function beginStudioManualEditGesture(element: HTMLElement): string {
return token;
}
const GESTURE_ENDED = "hf-manual-edit-gesture-ended";
export function endStudioManualEditGesture(element: HTMLElement, token?: string): void {
if (token && element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR) !== token) return;
if (!element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
const doc = element.ownerDocument;
doc.dispatchEvent(new (doc.defaultView?.Event ?? Event)(GESTURE_ENDED));
}
export function isStudioManualEditGestureLiveIn(doc: Document): boolean {
return doc.querySelector(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`) !== null;
}
/** Runs `run` once the last gesture in `doc` ends; the returned function stops waiting. */
export function afterStudioManualEditGestures(doc: Document, run: () => void): () => void {
const onEnded = () => {
if (isStudioManualEditGestureLiveIn(doc)) return;
stop();
run();
};
const stop = () => doc.removeEventListener(GESTURE_ENDED, onEnded);
doc.addEventListener(GESTURE_ENDED, onEnded);
return stop;
}
const gestureSaves = new WeakMap<Document, number>();
export function studioManualEditSavesIn(doc: Document): number {
return gestureSaves.get(doc) ?? 0;
}
/** A gesture's save in `element`'s document: a reload requested before it shows the file before it. */
export function noteStudioManualEditSave(element: HTMLElement): void {
const doc = element.ownerDocument;
gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
}
function isStudioManualEditGestureActive(element: HTMLElement): boolean {
@@ -1,10 +1,20 @@
// @vitest-environment happy-dom
import { act, useRef } from "react";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
import { useMarqueeGestures } from "./marqueeCommit";
// Layout stands in for a real preview: every element is visible and sits at the band's start.
vi.mock("./domEditingElement", async (importOriginal) => ({
...(await importOriginal<typeof import("./domEditingElement")>()),
isElementComputedVisible: () => true,
}));
vi.mock("./domEditOverlayGeometry", async (importOriginal) => ({
...(await importOriginal<typeof import("./domEditOverlayGeometry")>()),
toVisibleOverlayRect: () => ({ left: 10, top: 10, width: 40, height: 40 }),
}));
installReactActEnvironment();
HTMLElement.prototype.setPointerCapture ??= () => {};
HTMLElement.prototype.releasePointerCapture ??= () => {};
@@ -82,3 +92,61 @@ it("an escape with no band in flight still reaches the host", () => {
expect(event.defaultPrevented).toBe(false);
expect(hostEscapes).toHaveLength(1);
});
it("a reload promoted mid-band selects from the preview on screen, not the retired one", async () => {
const preview = () => {
const iframe = document.createElement("iframe");
const doc = document.implementation.createHTMLDocument("preview");
doc.body.innerHTML = '<div data-composition-id="main"><h1 id="title">Title</h1></div>';
Object.defineProperty(iframe, "contentDocument", { value: doc });
return iframe;
};
const [retired, live] = [preview(), preview()];
const picked: HTMLElement[][] = [];
const iframeRef = { current: retired as HTMLIFrameElement | null };
function Band() {
const overlayRef = useRef<HTMLDivElement>(null);
const marquee = useMarqueeGestures({
iframeRef,
overlayRef,
activeCompositionPathRef: useRef<string | null>("index.html"),
onMarqueeSelectRef: useRef(() => undefined),
resolveHits: async (elements: HTMLElement[]) => {
picked.push(elements);
return [];
},
});
return (
<div
ref={overlayRef}
data-band-overlay
onPointerDown={marquee.begin}
onPointerMove={marquee.onPointerMove}
onPointerUp={marquee.onPointerUp}
/>
);
}
const band = mountReactHarness(<Band />);
const fire = (type: string, x: number, y: number) =>
act(() => {
document.querySelector("[data-band-overlay]")!.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
pointerId: 1,
clientX: x,
clientY: y,
}),
);
});
try {
fire("pointerdown", 0, 0);
fire("pointermove", 120, 90);
iframeRef.current = live;
await act(async () => fire("pointerup", 120, 90));
expect(picked.at(-1)?.length).toBeGreaterThan(0);
for (const element of picked.at(-1)!) expect(element.ownerDocument).toBe(live.contentDocument);
} finally {
act(() => band.unmount());
}
});
@@ -159,7 +159,12 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
const overlay = deps.overlayRef.current;
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
const acp = deps.activeCompositionPathRef.current ?? "index.html";
const candidates = candidatesRef.current ?? collectMarqueeCandidates(iframe, overlay, acp);
const cached = candidatesRef.current;
const measuredOnScreen = cached?.every(
(hit) => hit.element.ownerDocument === iframe.contentDocument,
);
const candidates =
cached && measuredOnScreen ? cached : collectMarqueeCandidates(iframe, overlay, acp);
const elements = hitsWithin(rect, candidates).map((hit) => hit.element);
const resolveHits = deps.resolveHits;
const picks = resolveHits
@@ -44,7 +44,7 @@ export function useInlineTextEditing(
} {
const actions = useDomEditActionsContextOptional();
const inlineText = useInlineTextEdit({
onCommit: (commit) => void actions?.handleDomRichTextCommit(commit),
onCommit: (commit) => actions?.handleDomRichTextCommit(commit),
onPause: () => usePlayerStore.getState().setIsPlaying(false),
});
const lastPressRef = useRef<PressMark | null>(null);
@@ -0,0 +1,180 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditing";
import { resolveElementForOverlay } from "../components/editor/domEditOverlayGeometry";
import {
makePreview,
mountPlayerWithPreview,
paintShadow,
resetPlayerStore,
type TimelinePlayerApi,
} from "../player/hooks/timelinePlayerTestHarness";
import { announcePreviewPromoted } from "../player/sceneSwap";
import { stageElementOffset } from "./elementOffsetStager";
import { savePlainRotation } from "./plainRotation";
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
import { useLivePreviewIframe } from "./useLivePreviewIframe";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
ensureMotionPathPluginLoaded: vi.fn(),
}));
afterEach(() => {
document.body.innerHTML = "";
resetPlayerStore();
});
const previewWithTitle = (query = "") => makePreview('<h1 id="title">Title</h1>', query);
const titleOf = (iframe: HTMLIFrameElement | null) =>
iframe!.contentDocument!.getElementById("title") as HTMLElement;
const selectionOf = (element: HTMLElement) =>
({ element, id: "title", selector: "#title", label: "title" }) as unknown as DomEditSelection;
/** A host shaped like Desktop's: it reads its iframe once and never re-renders for a reload. */
function mountHost(followPromotions: boolean) {
const selectionRef = { current: null as DomEditSelection | null };
function Session({ host }: { host: HTMLIFrameElement }) {
const live = useLivePreviewIframe(host);
useDomEditPreviewSync({
previewIframe: followPromotions ? live : host,
activeCompPath: null,
captionEditMode: false,
domEditSelectionRef: selectionRef,
domEditGroupSelectionsRef: { current: [] },
domEditSelection: selectionRef.current,
refreshDomEditGroupSelectionsFromPreview: async () => {},
applyDomSelection: (selection) => void (selectionRef.current = selection),
buildDomSelectionFromTarget: async (element) => selectionOf(element),
refreshPreviewDocumentVersion: () => {},
syncPreviewHotkeys: () => {},
applyStudioManualEditsToPreviewRef: { current: async () => {} },
});
return null;
}
const live = previewWithTitle();
const player = mountPlayerWithPreview(live);
selectionRef.current = selectionOf(titleOf(live));
const sessionRoot = createRoot(document.body.appendChild(document.createElement("div")));
act(() => sessionRoot.render(React.createElement(Session, { host: live })));
const unmount = () => {
act(() => sessionRoot.unmount());
act(() => player.root.unmount());
};
return { player: player.getApi, selectionRef, live, unmount };
}
/** The real shadow reload: load a shadow, report it painted, and let the player promote it. */
async function promoteShadow(player: () => TimelinePlayerApi): Promise<HTMLIFrameElement> {
act(() => player().refreshPlayer());
const shadow = previewWithTitle("?_t=1");
await paintShadow(player, shadow);
expect(player().iframeRef.current).toBe(shadow);
return shadow;
}
describe("a shadow reload promoted without a host re-render", () => {
// A host stage holds both players, each with the same #title, in either order.
it.each([
["second", true],
["first", false],
])(
"with the live preview %s on the stage, the selection, overlay, rotate and move land on it",
async (_, liveSecond) => {
const host = mountHost(true);
const shadow = await promoteShadow(host.player);
// The stage only orders the two players; nothing here needs their pages to load.
for (const frame of [host.live, shadow]) frame.removeAttribute("src");
const stage = document.body.appendChild(document.createElement("div"));
stage.append(...(liveSecond ? [host.live, shadow] : [shadow, host.live]));
const selection = host.selectionRef.current!;
expect(selection.element).toBe(titleOf(shadow));
const overlayNode = resolveElementForOverlay(shadow.contentDocument!, selection, null, {
current: null,
});
expect(overlayNode).toBe(selection.element);
const commitPositionPatchToHtml = vi.fn(async () => {});
await savePlainRotation({ commitPositionPatchToHtml }, selection, { angle: 30 });
await stageElementOffset(
{ commitPositionPatchToHtml, showToast: vi.fn() },
selection,
{ x: 10, y: 5 },
true,
).save();
expect(titleOf(shadow).style.getPropertyValue("rotate")).toBe("30deg");
expect(titleOf(shadow).style.getPropertyValue("translate")).toBe("10px 5px");
expect(titleOf(host.live).getAttribute("style")).toBeNull();
host.unmount();
},
);
it("left the selection on the retired node when the session kept the host's first iframe", async () => {
const host = mountHost(false);
await promoteShadow(host.player);
expect(host.selectionRef.current!.element).toBe(titleOf(host.live));
host.unmount();
});
});
describe("useLivePreviewIframe", () => {
function track(host: HTMLIFrameElement) {
const seen: Array<HTMLIFrameElement | null> = [];
function Probe({ iframe }: { iframe: HTMLIFrameElement }) {
seen.push(useLivePreviewIframe(iframe));
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(React.createElement(Probe, { iframe: host })));
const rerender = (iframe: HTMLIFrameElement) =>
act(() => root.render(React.createElement(Probe, { iframe })));
return { live: () => seen.at(-1), rerender, root };
}
it("follows each promotion of its own preview and ignores another preview's", () => {
const [a, b, c, other] = [0, 1, 2, 3].map(() => document.createElement("iframe"));
const probe = track(a!);
act(() => announcePreviewPromoted({ retired: other!, live: c! }));
expect(probe.live()).toBe(a);
act(() => announcePreviewPromoted({ retired: a!, live: b! }));
expect(probe.live()).toBe(b);
act(() => announcePreviewPromoted({ retired: b!, live: c! }));
expect(probe.live()).toBe(c);
act(() => probe.root.unmount());
});
it("lands on the same iframe wrapped twice, as when a host wraps what it hands the session", () => {
const [a, b, c] = [0, 1, 2].map(() => document.createElement("iframe"));
let seen: { outer: HTMLIFrameElement | null; inner: HTMLIFrameElement | null } | null = null;
function Probe({ host }: { host: HTMLIFrameElement }) {
const outer = useLivePreviewIframe(host);
seen = { outer, inner: useLivePreviewIframe(outer) };
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(React.createElement(Probe, { host: a! })));
act(() => announcePreviewPromoted({ retired: a!, live: b! }));
expect(seen).toEqual({ outer: b, inner: b });
act(() => announcePreviewPromoted({ retired: b!, live: c! }));
expect(seen).toEqual({ outer: c, inner: c });
act(() => root.unmount());
});
it("takes a new host iframe as given, and stops listening on unmount", () => {
const [a, b, d] = [0, 1, 2].map(() => document.createElement("iframe"));
const probe = track(a!);
act(() => announcePreviewPromoted({ retired: a!, live: b! }));
probe.rerender(d!);
expect(probe.live()).toBe(d);
const remove = vi.spyOn(document, "removeEventListener");
act(() => probe.root.unmount());
expect(remove).toHaveBeenCalledWith("hf-preview-promoted", expect.any(Function));
});
});
@@ -9,6 +9,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { TimelineElement } from "../player";
import type { UseDomEditSessionParams } from "./useDomEditSession";
import { makeSelection } from "./domSelectionTestHarness";
const styleOp = (property: string, value: string): PatchOperation => ({
type: "inline-style",
@@ -132,26 +133,30 @@ vi.mock("./useDomEditCommits", () => ({
};
},
}));
const iframesHandedOn: Array<{ hook: string; iframe: unknown }> = [];
vi.mock("./useDomSelection", () => ({
useDomSelection: () => ({
domEditSelection: domEditSelectionRef.current,
domEditGroupSelections: [],
domEditHoverSelection: null,
activeGroupElement: null,
domEditSelectionRef,
domEditGroupSelectionsRef,
setActiveGroupElement: vi.fn(),
applyDomSelection: vi.fn(),
clearDomSelection: vi.fn(),
buildDomSelectionFromTarget: vi.fn(),
resolveDomSelectionFromPreviewPoint: vi.fn(),
resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
updateDomEditHoverSelection: vi.fn(),
buildDomSelectionForTimelineElement: vi.fn(),
handleTimelineElementSelect: vi.fn(),
refreshDomEditSelectionFromPreview: vi.fn(),
applyMarqueeSelection: vi.fn(),
}),
useDomSelection: ({ previewIframe }: { previewIframe: unknown }) => {
iframesHandedOn.push({ hook: "selection", iframe: previewIframe });
return {
domEditSelection: domEditSelectionRef.current,
domEditGroupSelections: [],
domEditHoverSelection: null,
activeGroupElement: null,
domEditSelectionRef,
domEditGroupSelectionsRef,
setActiveGroupElement: vi.fn(),
applyDomSelection: vi.fn(),
clearDomSelection: vi.fn(),
buildDomSelectionFromTarget: vi.fn(),
resolveDomSelectionFromPreviewPoint: vi.fn(),
resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
updateDomEditHoverSelection: vi.fn(),
buildDomSelectionForTimelineElement: vi.fn(),
handleTimelineElementSelect: vi.fn(),
refreshDomEditSelectionFromPreview: vi.fn(),
applyMarqueeSelection: vi.fn(),
};
},
}));
vi.mock("./useAskAgentModal", () => ({
useAskAgentModal: () => ({
@@ -208,33 +213,36 @@ vi.mock("./useGroupCommits", () => ({
}),
}));
vi.mock("./useDomEditWiring", () => ({
useDomEditWiring: () => ({
onClickToSource: vi.fn(),
selectedGsapAnimations: [],
gsapMultipleTimelines: false,
gsapUnsupportedTimelinePattern: false,
trackGsapInteractionFailure: vi.fn(),
makeFetchFallback: vi.fn(),
handleGsapUpdateProperty: vi.fn(),
handleGsapUpdateMeta: vi.fn(),
handleGsapDeleteAnimation: vi.fn(),
handleGsapDeleteAllForElement: vi.fn(),
handleGsapAddAnimation: vi.fn(),
handleGsapAddProperty: vi.fn(),
handleGsapRemoveProperty: vi.fn(),
handleGsapUpdateFromProperty: vi.fn(),
handleGsapAddFromProperty: vi.fn(),
handleGsapRemoveFromProperty: vi.fn(),
handleGsapAddKeyframe: vi.fn(),
handleGsapAddKeyframeBatch: vi.fn(),
handleGsapRemoveKeyframe: vi.fn(),
handleGsapMoveKeyframeToPlayhead: vi.fn(),
handleGsapMoveKeyframe: vi.fn(),
handleGsapResizeKeyframedTween: vi.fn(),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveAllKeyframes: vi.fn(),
handleResetSelectedElementKeyframes: vi.fn(),
}),
useDomEditWiring: ({ previewIframe }: { previewIframe: unknown }) => {
iframesHandedOn.push({ hook: "wiring", iframe: previewIframe });
return {
onClickToSource: vi.fn(),
selectedGsapAnimations: [],
gsapMultipleTimelines: false,
gsapUnsupportedTimelinePattern: false,
trackGsapInteractionFailure: vi.fn(),
makeFetchFallback: vi.fn(),
handleGsapUpdateProperty: vi.fn(),
handleGsapUpdateMeta: vi.fn(),
handleGsapDeleteAnimation: vi.fn(),
handleGsapDeleteAllForElement: vi.fn(),
handleGsapAddAnimation: vi.fn(),
handleGsapAddProperty: vi.fn(),
handleGsapRemoveProperty: vi.fn(),
handleGsapUpdateFromProperty: vi.fn(),
handleGsapAddFromProperty: vi.fn(),
handleGsapRemoveFromProperty: vi.fn(),
handleGsapAddKeyframe: vi.fn(),
handleGsapAddKeyframeBatch: vi.fn(),
handleGsapRemoveKeyframe: vi.fn(),
handleGsapMoveKeyframeToPlayhead: vi.fn(),
handleGsapMoveKeyframe: vi.fn(),
handleGsapResizeKeyframedTween: vi.fn(),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveAllKeyframes: vi.fn(),
handleResetSelectedElementKeyframes: vi.fn(),
};
},
}));
vi.mock("./usePreviewInteraction", () => ({
usePreviewInteraction: () => ({
@@ -310,32 +318,9 @@ describe("onReorderShadow source filter", () => {
describe("bulk segment ease commits", () => {
it("uses one ordered batch for many ids and sane paths for one or no ids", async () => {
const { useDomEditSession } = await import("./useDomEditSession");
const selection: DomEditSelection = {
id: "hero",
element: document.createElement("div"),
label: "Hero",
tagName: "DIV",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
textContent: null,
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
};
const hero = document.createElement("div");
hero.id = "hero";
const selection = makeSelection("Hero", hero);
domEditSelectionRef.current = selection;
gsapCommitMutation.mockClear();
gsapCommitMutation.batch.mockClear();
@@ -601,3 +586,22 @@ describe("handleDomEditElementDelete routing", () => {
expect(handleDomEditElementsDeleteMock).not.toHaveBeenCalled();
});
});
describe("a shadow reload promoted without a host re-render", () => {
it("hands the promoted iframe to the session's selection and preview sync", async () => {
const { useDomEditSession } = await import("./useDomEditSession");
const { announcePreviewPromoted } = await import("../player/sceneSwap");
const [host, promoted] = [0, 1].map(() => document.createElement("iframe"));
function Probe() {
useDomEditSession(createSessionParams({ previewIframe: host! }));
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(<Probe />));
act(() => announcePreviewPromoted({ retired: host!, live: promoted! }));
const last = (hook: string) => iframesHandedOn.findLast((seen) => seen.hook === hook)?.iframe;
expect(last("selection")).toBe(promoted);
expect(last("wiring")).toBe(promoted);
act(() => root.unmount());
});
});
+7 -43
View File
@@ -24,6 +24,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
import type { RecordEditInput } from "./domEditDeleteMembers";
import type { DomEditTimelineParams } from "./useDomSelectionTypes";
import { useLivePreviewIframe } from "./useLivePreviewIframe";
// Re-exported: the delete rule lives in its own module; callers and its test import it from here.
export { membersForDelete };
@@ -86,14 +87,13 @@ export function useDomEditSession({
importedFontAssetsRef,
projectDir,
projectIdRef,
previewIframe,
previewIframe: hostPreviewIframe,
refreshKey,
previewDocumentVersion,
rightPanelTab,
applyStudioManualEditsToPreviewRef,
syncPreviewHotkeys,
reloadPreview,
setRefreshKey: _setRefreshKey,
openSourceForSelection,
sdkSession,
publishSdkSession,
@@ -103,7 +103,7 @@ export function useDomEditSession({
}: UseDomEditSessionParams) {
const isMasterView = !activeCompPath || activeCompPath === "index.html";
const previewCaptionEditMode = captionEditMode && !readOnlyPreview;
void _setRefreshKey;
const previewIframe = useLivePreviewIframe(hostPreviewIframe);
const {
domEditSelection,
domEditGroupSelections,
@@ -173,29 +173,11 @@ export function useDomEditSession({
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
// ── GSAP script commits ──
const {
commitMutation: gsapCommitMutation,
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
addKeyframeBatch,
removeKeyframe,
moveKeyframe,
resizeKeyframedTween,
convertToKeyframes,
removeAllKeyframes,
setArcPath,
updateArcSegment,
...gsapScriptEdits
} = useGsapScriptCommits({
projectIdRef,
activeCompPath,
@@ -211,8 +193,6 @@ export function useDomEditSession({
forceReloadSdkSession,
});
// ── DOM commit handlers ──
const {
resolveImportedFontAsset,
handleDomStyleCommit,
@@ -430,23 +410,7 @@ export function useDomEditSession({
applyDomSelection,
buildDomSelectionFromTarget,
openSourceForSelection,
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
addKeyframeBatch,
removeKeyframe,
moveKeyframe,
resizeKeyframedTween,
convertToKeyframes,
removeAllKeyframes,
...gsapScriptEdits,
handleDomManualEditsReset,
});
const {
@@ -492,8 +456,8 @@ export function useDomEditSession({
handleDomBoxSizeCommit,
handleDomRotationCommit: handleDomCssRotationCommit,
commitPositionPatchToHtml,
addGsapAnimation,
convertToKeyframes,
addGsapAnimation: gsapScriptEdits.addGsapAnimation,
convertToKeyframes: gsapScriptEdits.convertToKeyframes,
setArcPath,
updateArcSegment,
});
@@ -336,8 +336,7 @@ export function usePopulateKeyframeCacheForFile(
// fallow-ignore-next-line complexity
const tryRuntimeScan = () => {
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
const iframe =
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
const iframe = iframeRef?.current;
if (!iframe) return false;
// Clip dims per element so the scan converts tween-relative keyframes to
// clip-relative (matching the static path) instead of timeline-relative.
+16 -2
View File
@@ -1,6 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
import {
beginStudioManualEditGesture,
endStudioManualEditGesture,
noteStudioManualEditSave,
} from "../components/editor/manualEditsDom";
/**
* Editing an element's text where it sits, in the composition itself.
@@ -43,6 +49,8 @@ export interface InlineTextEditSession {
outline: string;
/** The element's own outline offset, restored with the outline. */
outlineOffset: string;
/** Holds the preview on screen while the edit is open. */
gesture?: string;
}
/** The live markup to persist and the session snapshot to restore on failure. */
@@ -86,7 +94,7 @@ export function useInlineTextEdit({
onPause,
}: {
/** Where the edited text goes. The caller owns persistence. */
onCommit: (commit: InlineTextEditCommit) => void;
onCommit: (commit: InlineTextEditCommit) => unknown;
/** Stop playback, so the element is not animating under the caret. */
onPause?: () => void;
}): InlineTextEditControls {
@@ -106,6 +114,7 @@ export function useInlineTextEdit({
framesRef.current = null;
}
openRef.current = null;
endStudioManualEditGesture(open.element, open.gesture);
setSession(null);
// An element removed from the document mid-session is not an error, it is
// just nothing left to clean up.
@@ -132,6 +141,7 @@ export function useInlineTextEdit({
original: element.innerHTML,
outline: element.style.outline,
outlineOffset: element.style.outlineOffset,
gesture: beginStudioManualEditGesture(element),
};
// Drawn on the element itself, not in Studio's overlay above it. This is
// the only mark that says the caret is in the TEXT rather than the
@@ -168,10 +178,14 @@ export function useInlineTextEdit({
// saved rather than something the server will quietly cut down.
sanitizeRichTextChildren(open.element);
const html = open.element.innerHTML;
// Counted and pending before the edit closes, so a reload waits for the save, not the mark.
let save: (saving: unknown) => void = () => {};
trackStudioPendingEdit(new Promise((resolve) => (save = resolve)));
noteStudioManualEditSave(open.element);
teardown();
// After teardown, so the commit path's own resync does not fight an
// element that is still editable.
onCommit({ element: open.element, html, previousHtml: open.original });
save(onCommit({ element: open.element, html, previousHtml: open.original }));
}, [onCommit, teardown]);
const cancel = useCallback(() => {
@@ -0,0 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { onPreviewPromoted } from "../player/sceneSwap";
/** The one source for which preview is on screen: the host's iframe, then each reload promoted in its place. */
export function useLivePreviewIframe(host: HTMLIFrameElement | null): HTMLIFrameElement | null {
const [promoted, setPromoted] = useState<{ host: HTMLIFrameElement; live: HTMLIFrameElement }>();
const live = host && promoted?.host === host ? promoted.live : host;
const liveRef = useRef(live);
liveRef.current = live;
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!host) return;
return onPreviewPromoted(host.ownerDocument, ({ retired, live: next }) => {
if (retired === liveRef.current) setPromoted({ host, live: next });
});
}, [host]);
return live;
}
+2
View File
@@ -181,6 +181,8 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
// DOM editing for a host outside EditorShell; the Commit hooks save without useDomEditSession.
export { useDomEditSession } from "./hooks/useDomEditSession";
export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
export type { PreviewPromotion } from "./player/sceneSwap";
export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
// Reads the preview on screen the way a host app does: by package name.
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, expectTypeOf, it } from "vitest";
import { useLivePreviewIframe, type PreviewPromotion } from "@hyperframes/studio";
import {
makePreview,
mountPlayerWithPreview,
paintShadow,
} from "./player/hooks/timelinePlayerTestHarness";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
it("gives a host the preview on screen after a real shadow reload is promoted", async () => {
expectTypeOf<PreviewPromotion>().toHaveProperty("live");
const host = makePreview("<h1>Title</h1>");
const player = mountPlayerWithPreview(host);
let live: HTMLIFrameElement | null = null;
function Probe() {
live = useLivePreviewIframe(host);
return null;
}
const root = createRoot(document.body.appendChild(document.createElement("div")));
act(() => root.render(<Probe />));
act(() => player.getApi().refreshPlayer());
const shadow = makePreview("<h1>Title</h1>", "?_t=1");
await paintShadow(player.getApi, shadow);
expect(player.getApi().iframeRef.current).toBe(shadow);
expect(live).toBe(shadow);
act(() => root.unmount());
act(() => player.root.unmount());
});
@@ -118,3 +118,30 @@ export function attachIframeAdapter(
attachIframeWindow(api, win);
return adapter;
}
export function makePreview(body: string, query = ""): HTMLIFrameElement {
const iframe = makeFakeIframe(makeAdapterWindow().win);
iframe.src = `http://localhost/api/projects/demo/preview${query}`;
iframe.contentDocument!.body.innerHTML = body;
return iframe;
}
export function mountPlayerWithPreview(live: HTMLIFrameElement) {
const harness = renderTimelinePlayerHarness();
act(() => {
harness.getApi().iframeRef.current = live;
harness.getApi().onIframeLoad();
});
return harness;
}
export async function paintShadow(getApi: () => TimelinePlayerApi, shadow: HTMLIFrameElement) {
const slot = getApi().previewSlots.find((s) => s.role === "shadow");
if (!slot) throw new Error("no shadow reload is pending");
act(() => getApi().setShadowIframeNode(shadow));
await act(async () => {
getApi().onShadowIframeLoad(slot.gen);
getApi().onShadowReadyChange(slot.gen, true);
});
return slot.gen;
}
@@ -3,6 +3,11 @@
import { useCallback, useRef, useState } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import {
afterStudioManualEditGestures,
isStudioManualEditGestureLiveIn,
studioManualEditSavesIn,
} from "../../components/editor/manualEditsDom";
import { logReload } from "../../utils/reloadDebug";
import {
useTimelineSyncCallbacks,
@@ -14,6 +19,8 @@ import {
} from "./useTimelineSyncCallbacks";
import type { IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
import { announcePreviewPromoted } from "../sceneSwap";
import { afterStudioPendingEdits, isStudioEditSaving } from "../../utils/studioPendingEdits";
import { usePlayerStore } from "../store/playerStore";
// One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
@@ -78,12 +85,18 @@ export function useShadowPreviewReload({
// The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
const budgetGenRef = useRef<number | null>(null);
const cancelPendingLoadRef = useRef<() => void>(() => {});
const stopHoldRef = useRef<() => void>(() => {});
const shadowUrlRef = useRef("");
const savesAtRequestRef = useRef(0);
const beginShadowReloadRef = useRef<(url: string) => void>(() => {});
const promoteWhenReadyRef = useRef<(gen: number) => void>(() => {});
const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
const stopPendingShadow = useCallback(() => {
clearTimeout(readyTimerRef.current);
budgetGenRef.current = null;
cancelPendingLoadRef.current();
stopHoldRef.current();
pendingCommitRef.current = null;
visuallyReadyGenRef.current = null;
}, []);
@@ -106,12 +119,58 @@ export function useShadowPreviewReload({
[stopPendingShadow, isRefreshingRef, pendingSeekRef],
);
const gestureSavesOnScreen = useCallback(() => {
const liveDoc = iframeRef.current?.contentDocument;
return liveDoc ? studioManualEditSavesIn(liveDoc) : 0;
}, [iframeRef]);
const waitThenRetry = useCallback(
(gen: number, why: string, wait: (retry: () => void) => () => void) => {
clearTimeout(readyTimerRef.current);
budgetGenRef.current = null;
stopHoldRef.current();
logReload("shadow-held", { gen, why });
stopHoldRef.current = wait(() => {
logReload("shadow-hold-ended", { gen, why });
promoteWhenReadyRef.current(gen);
});
return true;
},
[],
);
// Nothing replaces the preview under a gesture, and a shadow requested before a gesture's save
// shows the file before it: once that save has landed, a fresh one loads instead.
const heldOrStale = useCallback(
(gen: number) => {
const liveDoc = iframeRef.current?.contentDocument;
if (liveDoc && isStudioManualEditGestureLiveIn(liveDoc))
return waitThenRetry(gen, "gesture", (retry) =>
afterStudioManualEditGestures(liveDoc, retry),
);
if (gestureSavesOnScreen() === savesAtRequestRef.current) return false;
if (isStudioEditSaving()) return waitThenRetry(gen, "save", afterStudioPendingEdits);
logReload("shadow-reloaded-after-gesture-save", { gen });
const url = new URL(shadowUrlRef.current, window.location.origin);
url.searchParams.set("_t", String(Date.now()));
beginShadowReloadRef.current(url.toString());
return true;
},
[iframeRef, gestureSavesOnScreen, waitThenRetry],
);
const shadowReadyToPromote = useCallback((gen: number) => {
const shadow = shadowIframeRef.current;
const pending = pendingCommitRef.current;
const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
return shadow && pending && ready && gen === shadowGenRef.current ? { shadow, pending } : null;
}, []);
const promoteWhenReady = useCallback(
(gen: number) => {
const shadow = shadowIframeRef.current;
const pending = pendingCommitRef.current;
const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
const promotable = shadowReadyToPromote(gen);
if (!promotable || heldOrStale(gen)) return;
const { shadow, pending } = promotable;
stopPendingShadow();
// The live frame kept playing, stopped at the end or was seeked while the shadow loaded.
const live = getAdapter();
@@ -119,6 +178,7 @@ export function useShadowPreviewReload({
const playing = usePlayerStore.getState().isPlaying;
live?.pause();
// The store takes the new document's timeline only now that it is the one on screen, and reads it there.
const retired = iframeRef.current;
iframeRef.current = shadow;
pending.commit();
shadowIframeRef.current = null;
@@ -126,17 +186,21 @@ export function useShadowPreviewReload({
applyPreviewAudioState();
setPreviewSlots((prev) => planShadowPromotion(prev, gen));
if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
announcePreviewPromoted({ retired, live: shadow });
onPromotedRef.current?.();
thumbnailScheduler.setPreviewReloading(false);
},
[
stopPendingShadow,
shadowReadyToPromote,
heldOrStale,
getAdapter,
iframeRef,
attachIframeShortcutListeners,
applyPreviewAudioState,
],
);
promoteWhenReadyRef.current = promoteWhenReady;
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
@@ -216,13 +280,16 @@ export function useShadowPreviewReload({
stopPendingShadow();
budgetGenRef.current = gen;
budgetsSpentRef.current = 0;
shadowUrlRef.current = url;
savesAtRequestRef.current = gestureSavesOnScreen();
armReadyTimer(gen);
setPreviewSlots((prev) => planShadowReload(prev, gen, url));
// Thumbnails of the edit wait for the new preview instead of competing with it.
thumbnailScheduler.setPreviewReloading(true);
},
[stopPendingShadow, armReadyTimer],
[stopPendingShadow, armReadyTimer, gestureSavesOnScreen],
);
beginShadowReloadRef.current = beginShadowReload;
// Composition switch (not an edit reload): drop any in-flight shadow.
const resetPreviewSlots = useCallback(() => {
+18
View File
@@ -17,6 +17,24 @@ export function onPreviewContentReplaced(
};
}
const PREVIEW_PROMOTED = "hf-preview-promoted";
export type PreviewPromotion = { retired: HTMLIFrameElement | null; live: HTMLIFrameElement };
export function announcePreviewPromoted(promotion: PreviewPromotion): void {
promotion.live.ownerDocument.dispatchEvent(
new CustomEvent(PREVIEW_PROMOTED, { detail: promotion }),
);
}
export function onPreviewPromoted(
doc: Document,
listener: (promotion: PreviewPromotion) => void,
): () => void {
const handle = (event: Event) => listener((event as CustomEvent<PreviewPromotion>).detail);
doc.addEventListener(PREVIEW_PROMOTED, handle);
return () => doc.removeEventListener(PREVIEW_PROMOTED, handle);
}
export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonly string[]): void {
const doc = iframe?.contentDocument;
const meta = doc?.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]');
@@ -39,6 +39,27 @@ export function hasStudioPendingEdits(): boolean {
return pendingEditPromises.size > 0 || focusedField() !== null;
}
export function isStudioEditSaving(): boolean {
return pendingEditPromises.size > 0;
}
export function afterStudioPendingEdits(run: () => void): () => void {
let waiting = true;
const check = () => {
if (!waiting) return;
if (isStudioEditSaving()) {
void Promise.allSettled([...pendingEditPromises]).then(check);
return;
}
waiting = false;
run();
};
check();
return () => {
waiting = false;
};
}
export function trackStudioPendingEdit(
result: Promise<unknown> | unknown,
): Promise<unknown> | undefined {