mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user