fix(studio): an escape cancels a preview marquee band and stops there (#4703)

* fix(studio): an escape cancels a preview marquee band and stops there

* fix(studio): name the band escape handler instead of commenting it

* test(studio): check the band is gone before the pointer is released
This commit is contained in:
Miguel Ángel
2026-09-28 22:24:32 +00:00
committed by GitHub
parent 0c2c4248e6
commit a519927436
2 changed files with 97 additions and 1 deletions
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { act, useRef } from "react";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it } from "vitest";
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
import { useMarqueeGestures } from "./marqueeCommit";
installReactActEnvironment();
HTMLElement.prototype.setPointerCapture ??= () => {};
HTMLElement.prototype.releasePointerCapture ??= () => {};
function Overlay() {
const overlayRef = useRef<HTMLDivElement>(null);
const marquee = useMarqueeGestures({
iframeRef: useRef<HTMLIFrameElement>(null),
overlayRef,
activeCompositionPathRef: useRef<string | null>("index.html"),
onMarqueeSelectRef: useRef(undefined),
});
return (
<div
ref={overlayRef}
data-overlay
onPointerDown={marquee.begin}
onPointerMove={marquee.onPointerMove}
onPointerUp={marquee.onPointerUp}
>
{marquee.marqueeRect && <div data-band />}
</div>
);
}
const pointer = (type: string, clientX: number, clientY: number) =>
act(() => {
document
.querySelector("[data-overlay]")!
.dispatchEvent(
new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX, clientY }),
);
});
const escape = () => {
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
act(() => {
document.body.dispatchEvent(event);
});
return event;
};
let root: Root;
let hostEscapes: KeyboardEvent[];
const hostListener = (e: KeyboardEvent) => {
if (e.key === "Escape") hostEscapes.push(e);
};
beforeEach(() => {
hostEscapes = [];
window.addEventListener("keydown", hostListener);
root = mountReactHarness(<Overlay />);
});
afterEach(() => {
window.removeEventListener("keydown", hostListener);
act(() => root.unmount());
document.body.innerHTML = "";
});
it("an escape cancels a preview band while the pointer is still down, and stops there", () => {
pointer("pointerdown", 10, 10);
pointer("pointermove", 120, 90);
expect(document.querySelector("[data-band]")).not.toBeNull();
const event = escape();
expect(document.querySelector("[data-band]")).toBeNull();
expect(event.defaultPrevented).toBe(true);
expect(hostEscapes).toHaveLength(0);
});
it("an escape with no band in flight still reaches the host", () => {
const event = escape();
expect(event.defaultPrevented).toBe(false);
expect(hostEscapes).toHaveLength(1);
});
@@ -1,5 +1,5 @@
// fallow-ignore-file code-duplication
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { DomEditSelection } from "./domEditing";
import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
import { isElementComputedVisible } from "./domEditingElement";
@@ -209,6 +209,18 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
reset();
}, [reset]);
useEffect(() => {
const cancelBandBeforeHostEscape = (e: KeyboardEvent) => {
if (e.key !== "Escape" || !marqueeRef.current) return;
e.preventDefault();
e.stopPropagation();
cancel();
};
window.addEventListener("keydown", cancelBandBeforeHostEscape, { capture: true });
return () =>
window.removeEventListener("keydown", cancelBandBeforeHostEscape, { capture: true });
}, [cancel]);
const onPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const m = marqueeRef.current;