feat(studio): a host can show the hover box and give the stage its starting size (#5116)

* test(studio): pin a host's hover box and a starting stage size

* feat(studio): a host can show the hover box and give the stage its starting size

* fix(studio): keep the comment share flat and let a measured size win over the hint
This commit is contained in:
Miguel Ángel
2026-10-07 09:13:07 +00:00
committed by GitHub
parent 17c699db4a
commit 1e711b087d
4 changed files with 56 additions and 7 deletions
@@ -61,7 +61,7 @@ export interface DomEditOverlayProps {
hoverSelection: DomEditSelection | null;
allowCanvasMovement?: boolean;
allowBodyDrag?: boolean;
/** "host": no hover, marquee, box re-select, or body drag if allowBodyDrag is false; Enter still opens text. */
/** "host": no marquee, box re-select, or body drag if allowBodyDrag is false; Enter still opens text. */
canvasInput?: "overlay" | "host";
onTextEditingChange?: (editing: boolean) => void;
/** A click on a single selection's box, in either mode; the event may be the pointerup. */
@@ -500,7 +500,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onLostPointerCapture={gestures.onLostPointerCapture}
onContextMenu={hostInput ? undefined : handleContextMenu}
>
{!hostInput && hoverSelection && hoverRect && compRect.width > 0 && (
{hoverSelection && hoverRect && compRect.width > 0 && (
<div
aria-hidden="true"
data-dom-edit-hover-box="true"
@@ -311,7 +311,7 @@ describe("DomEditOverlay with canvasInput host", () => {
expect(fixture(HOST).overlay.className).toContain("pointer-events-none");
});
it("draws no hover box", () => {
it("draws the hover box for the selection the host hovers, and none without one", () => {
layout.hover = RECT;
const hovered = (props = {}) => {
const { overlay } = fixture(props);
@@ -321,7 +321,8 @@ describe("DomEditOverlay with canvasInput host", () => {
};
const selection = makeSelection("Hover", textElement("hover"));
expect(hovered({ hoverSelection: selection })).not.toBeNull();
expect(hovered({ ...HOST, hoverSelection: selection })).toBeNull();
expect(hovered({ ...HOST, hoverSelection: selection })).not.toBeNull();
expect(hovered({ ...HOST, hoverSelection: null })).toBeNull();
});
it("starts no marquee and makes no selection from a press on empty canvas", () => {
@@ -12,6 +12,12 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const playerMounts: string[] = [];
let livePlayerProps: { onReadyToShowChange?: (ready: boolean) => void } = {};
const measured = vi.hoisted(() => ({ size: null as { width: number; height: number } | null }));
vi.mock("../../utils/previewCompositionSize", async (original) => ({
...(await original<typeof import("../../utils/previewCompositionSize")>()),
readPreviewCompositionSize: () => measured.size,
}));
vi.mock("../../player", async () => {
const React = await import("react");
@@ -81,7 +87,12 @@ function renderPreview(
{
box = { width: 800, height: 600 },
fillBox,
}: { box?: { width: number; height: number }; fillBox?: boolean } = {},
compositionSizeHint,
}: {
box?: { width: number; height: number };
fillBox?: boolean;
compositionSizeHint?: { width: number; height: number };
} = {},
) {
resizeCallbacks = [];
const host = document.createElement("div");
@@ -103,6 +114,7 @@ function renderPreview(
setShadowIframeNode: () => {},
resetPreviewSlots: () => {},
fillBox,
compositionSizeHint,
}),
);
});
@@ -378,6 +390,37 @@ describe("NLEPreview", () => {
filled.cleanup();
});
it("takes the host's composition size as the stage's shape until the preview measures its own", () => {
const box = { width: 800, height: 600 };
const plain = renderPreview(undefined, { box });
expect([plain.stage.style.width, plain.stage.style.height]).toEqual(["784px", "441px"]);
plain.cleanup();
const hinted = renderPreview(undefined, {
box,
compositionSizeHint: { width: 1276, height: 1078 },
});
const shape = parseFloat(hinted.stage.style.width) / parseFloat(hinted.stage.style.height);
expect(shape).toBeCloseTo(1276 / 1078, 3);
expect(hinted.stage.style.height).toBe("584px");
hinted.cleanup();
});
it("lets the size the preview measures win over the host's hint", () => {
measured.size = { width: 1920, height: 1080 };
try {
const box = { width: 800, height: 600 };
const hinted = renderPreview(undefined, {
box,
compositionSizeHint: { width: 1276, height: 1078 },
});
expect([hinted.stage.style.width, hinted.stage.style.height]).toEqual(["784px", "441px"]);
hinted.cleanup();
} finally {
measured.size = null;
}
});
it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
const view = renderPreview([
{ gen: 0, role: "live" },
@@ -50,6 +50,7 @@ interface NLEPreviewProps extends Pick<
onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
/** Draws the picture edge to edge in this box, without Studio's inset band. */
fillBox?: boolean;
compositionSizeHint?: PreviewCompositionSize;
}
export function getPreviewPlayerKey({
@@ -127,6 +128,7 @@ export const NLEPreview = memo(function NLEPreview({
onStageRef,
onCompositionSizeChange,
fillBox,
compositionSizeHint,
}: NLEPreviewProps) {
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
const viewportRef = useRef<HTMLDivElement>(null);
@@ -151,6 +153,8 @@ export const NLEPreview = memo(function NLEPreview({
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
const hintWidth = compositionSizeHint?.width;
const hintHeight = compositionSizeHint?.height;
const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
@@ -183,7 +187,8 @@ export const NLEPreview = memo(function NLEPreview({
resolvePreviewStageSize(
rect.width,
rect.height,
compositionSize,
compositionSize ??
(hintWidth && hintHeight ? { width: hintWidth, height: hintHeight } : null),
portrait,
gutterPx,
insetPx,
@@ -195,7 +200,7 @@ export const NLEPreview = memo(function NLEPreview({
const observer = new ResizeObserver(updateStageSize);
observer.observe(viewport);
return () => observer.disconnect();
}, [compositionSize, portrait, gutterPx, insetPx]);
}, [compositionSize, hintWidth, hintHeight, portrait, gutterPx, insetPx]);
const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
onCompositionSizeChangeRef.current = onCompositionSizeChange;