mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio): a reload requested while a canvas save is in flight loads again once it lands (#4874)
* fix(studio): a reload requested while a canvas save is in flight loads again once it lands * test(studio): type the late-reload drag helper for every edit kind
This commit is contained in:
@@ -32,7 +32,7 @@ import { CanvasContextMenu } from "./CanvasContextMenu";
|
||||
import { useInlineTextEditing } from "./useInlineTextEditing";
|
||||
import { usePreviewReadOnly } from "./previewReadOnlyContext";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { noteStudioManualEditSave as noteSave } from "./manualEditsDom";
|
||||
import { countStudioManualEditSave as counted } from "./manualEditsDom";
|
||||
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
||||
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
||||
import { logSelect } from "../../utils/selectDebug";
|
||||
@@ -195,33 +195,27 @@ 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.
|
||||
// Each canvas save is counted, so a reload requested before it settles loads again.
|
||||
const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
|
||||
onPathOffsetCommitRef.current = (sel, ...rest) => {
|
||||
noteSave(sel.element);
|
||||
return onPathOffsetCommit(sel, ...rest);
|
||||
};
|
||||
onPathOffsetCommitRef.current = (sel, ...rest) =>
|
||||
counted(sel.element, () => onPathOffsetCommit(sel, ...rest));
|
||||
const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
|
||||
onGroupPathOffsetCommitRef.current = (updates) => {
|
||||
if (updates[0]) noteSave(updates[0].selection.element);
|
||||
return onGroupPathOffsetCommit(updates);
|
||||
const save = () => onGroupPathOffsetCommit(updates);
|
||||
return updates[0] ? counted(updates[0].selection.element, save) : save();
|
||||
};
|
||||
const onBoxSizeCommitRef = useRef(onBoxSizeCommit);
|
||||
onBoxSizeCommitRef.current = (sel, ...rest) => {
|
||||
noteSave(sel.element);
|
||||
return onBoxSizeCommit(sel, ...rest);
|
||||
};
|
||||
onBoxSizeCommitRef.current = (sel, ...rest) =>
|
||||
counted(sel.element, () => onBoxSizeCommit(sel, ...rest));
|
||||
const onRotationCommitRef = useRef(onRotationCommit);
|
||||
onRotationCommitRef.current = (sel, next) => {
|
||||
noteSave(sel.element);
|
||||
return onRotationCommit(sel, next);
|
||||
};
|
||||
onRotationCommitRef.current = (sel, next) =>
|
||||
counted(sel.element, () => onRotationCommit(sel, next));
|
||||
const onStyleCommitRef = useRef(onStyleCommit);
|
||||
onStyleCommitRef.current =
|
||||
onStyleCommit &&
|
||||
((property, value) => {
|
||||
if (selectionRef.current) noteSave(selectionRef.current.element);
|
||||
return onStyleCommit(property, value);
|
||||
const save = () => onStyleCommit(property, value);
|
||||
return selectionRef.current ? counted(selectionRef.current.element, save) : save();
|
||||
});
|
||||
const onBlockedMoveRef = useRef(onBlockedMove);
|
||||
onBlockedMoveRef.current = onBlockedMove;
|
||||
|
||||
@@ -72,18 +72,22 @@ function pointer(target: Element, type: string, x: number, y: number) {
|
||||
}
|
||||
|
||||
/** The real player with a two-layer preview, and the real overlay editing it. */
|
||||
function mountEditor(group: boolean) {
|
||||
function mountEditor(
|
||||
group: boolean,
|
||||
saving: { landed: Promise<void> } = { landed: Promise.resolve() },
|
||||
) {
|
||||
file = { title: "", sub: "" };
|
||||
const live = makePreview(TWO_LAYERS);
|
||||
player = mountPlayerWithPreview(live);
|
||||
const selections = ["title", "sub"].map((id) => makeSelection(id, byId(live, id)));
|
||||
for (const s of selections) s.capabilities.canApplyManualRotation = true;
|
||||
const saveStyle = (sel: DomEditSelection | null, style: string) =>
|
||||
saving.landed.then(() => void (file = { ...file, [sel?.id ?? "title"]: style }));
|
||||
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 write = (sel: DomEditSelection, next: { x: number; y: number }) =>
|
||||
saveStyle(sel, `translate: ${next.x}px ${next.y}px`);
|
||||
const onPathOffsetCommit = vi.fn(write);
|
||||
const onGroupPathOffsetCommit = vi.fn(
|
||||
async (updates: { selection: DomEditSelection; next: { x: number; y: number } }[]) => {
|
||||
@@ -109,8 +113,9 @@ function mountEditor(group: boolean) {
|
||||
onBlockedMove={() => undefined}
|
||||
onPathOffsetCommit={onPathOffsetCommit}
|
||||
onGroupPathOffsetCommit={onGroupPathOffsetCommit}
|
||||
onBoxSizeCommit={() => undefined}
|
||||
onRotationCommit={() => undefined}
|
||||
onBoxSizeCommit={(sel, size) => saveStyle(sel, `width: ${size.width}px`)}
|
||||
onRotationCommit={(sel, next) => saveStyle(sel, `rotate: ${next.angle}deg`)}
|
||||
onStyleCommit={(property, value) => saveStyle(selections[0]!, `${property}: ${value}`)}
|
||||
/>
|
||||
</PreviewReadOnlyProvider>,
|
||||
),
|
||||
@@ -177,18 +182,70 @@ describe("a reload during a drag", () => {
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
const handle = (overlay: HTMLElement, selector: string) => overlay.querySelector(selector)!;
|
||||
const corner = (overlay: HTMLElement) =>
|
||||
[...overlay.querySelectorAll<HTMLElement>("div.h-4.w-4")].reduce((a, b) =>
|
||||
parseFloat(b.style.left) + parseFloat(b.style.top) >
|
||||
parseFloat(a.style.left) + parseFloat(a.style.top)
|
||||
? b
|
||||
: a,
|
||||
);
|
||||
// Each edit presses at `from`, drags to `to`, and releases on `on`.
|
||||
const edits = {
|
||||
move: (o: HTMLElement) => ({ press: handle(o, BOX), on: o, from: [150, 150], to: [190, 170] }),
|
||||
resize: (o: HTMLElement) => ({ press: corner(o), on: o, from: [300, 200], to: [340, 220] }),
|
||||
rotate: (o: HTMLElement) => ({
|
||||
press: handle(o, '[aria-label="Rotate selection"]'),
|
||||
on: o,
|
||||
from: [200, 80],
|
||||
to: [270, 150],
|
||||
}),
|
||||
crop: (o: HTMLElement) => {
|
||||
const edge = handle(o, '[aria-label="Crop left"]');
|
||||
return { press: edge, on: edge, from: [100, 150], to: [140, 150] };
|
||||
},
|
||||
};
|
||||
|
||||
function drag(edit: { press: Element; on: Element; from: number[]; to: number[] }) {
|
||||
pointer(edit.press, "pointerdown", edit.from[0]!, edit.from[1]!);
|
||||
pointer(edit.on, "pointermove", edit.to[0]!, edit.to[1]!);
|
||||
}
|
||||
|
||||
it.each(Object.keys(edits) as (keyof typeof edits)[])(
|
||||
"that paints after a %s shows it from a fresh load, not the file before it",
|
||||
async (kind) => {
|
||||
const { live, overlay } = mountEditor(false);
|
||||
const edit = edits[kind](overlay);
|
||||
drag(edit);
|
||||
act(() => api().refreshPlayer());
|
||||
const beforeDrop = served("?_t=1");
|
||||
pointer(edit.on, "pointerup", edit.to[0]!, edit.to[1]!);
|
||||
await settle();
|
||||
expect(file.title, "the edit saved").not.toBe("");
|
||||
|
||||
const requested = await paintShadow(api, beforeDrop);
|
||||
expect(api().iframeRef.current, "the file before the edit").toBe(live);
|
||||
const fresh = served("?_t=2");
|
||||
expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
|
||||
expect(byId(fresh, "title").getAttribute("style")).toBe(file.title);
|
||||
},
|
||||
);
|
||||
|
||||
it("requested while the drop is still saving shows the drop from a fresh load", async () => {
|
||||
let land: () => void = () => {};
|
||||
const { live, overlay } = mountEditor(false, { landed: new Promise((r) => (land = r)) });
|
||||
const edit = edits.move(overlay);
|
||||
drag(edit);
|
||||
pointer(edit.on, "pointerup", edit.to[0]!, edit.to[1]!);
|
||||
await settle();
|
||||
act(() => api().refreshPlayer());
|
||||
const beforeDrop = served("?_t=1");
|
||||
pointer(overlay, "pointerup", 190, 170);
|
||||
const beforeSave = served("?_t=1");
|
||||
await act(async () => land());
|
||||
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 requested = await paintShadow(api, beforeSave);
|
||||
expect(api().iframeRef.current, "the file before the save").toBe(live);
|
||||
const fresh = served("?_t=2");
|
||||
expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
|
||||
expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
|
||||
@@ -222,24 +279,30 @@ describe("every way a drag ends without a drop clears its mark and promotes the
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
it.each([
|
||||
["one layer", false],
|
||||
["a group", true],
|
||||
])(
|
||||
"a press on %s that throws before the drag is armed leaves no mark behind",
|
||||
async (_, group) => {
|
||||
const editor = mountEditor(group);
|
||||
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", () => {
|
||||
|
||||
@@ -78,10 +78,14 @@ 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 {
|
||||
/** Runs a gesture's save, counted as it starts and as it settles: a reload requested before shows the old file. */
|
||||
export function countStudioManualEditSave<R>(element: HTMLElement, save: () => R): R {
|
||||
const doc = element.ownerDocument;
|
||||
gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
|
||||
const count = () => void gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
|
||||
count();
|
||||
const result = save();
|
||||
void Promise.resolve(result).then(count, count);
|
||||
return result;
|
||||
}
|
||||
|
||||
function isStudioManualEditGestureActive(element: HTMLElement): boolean {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
|
||||
import {
|
||||
beginStudioManualEditGesture,
|
||||
endStudioManualEditGesture,
|
||||
noteStudioManualEditSave,
|
||||
countStudioManualEditSave,
|
||||
} from "../components/editor/manualEditsDom";
|
||||
|
||||
/**
|
||||
@@ -181,11 +181,13 @@ export function useInlineTextEdit({
|
||||
// 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.
|
||||
save(onCommit({ element: open.element, html, previousHtml: open.original }));
|
||||
const commitText = () => {
|
||||
teardown();
|
||||
// After teardown, so the commit path's own resync does not fight an
|
||||
// element that is still editable.
|
||||
return onCommit({ element: open.element, html, previousHtml: open.original });
|
||||
};
|
||||
save(countStudioManualEditSave(open.element, commitText));
|
||||
}, [onCommit, teardown]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user