fix(studio): continuation of #2281 (#2287)

* feat(studio): timeline collision and placement model

What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.

Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.

How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.

Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).

* feat(studio): timeline magnetic snapping

What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.

Why: the magnet math for clip drags/trims, reviewable standalone.

How: new files only; type-only playerStore imports; consumers land with the
drag engine.

Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.

* feat(studio): multi-clip drag preview math

What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.

Why: the group-drag math, standalone and DOM-free.

How: new files only; consumed later by TimelineLanes.

Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.

* feat(studio): timeline z-stacking sync model

What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.

Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.

How: new files only; consumed later by timelineZones and the stacking-sync
hook.

Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.

* feat(studio): timeline lane-zone model

What: new pure module timelineZones — visual/audio track-zone
classification (classifyZone) and normalizeToZones, which re-packs lanes
into zone-consistent rows; tests cover the stacking/zones interaction.

Why: completes the z-model started in the stacking-sync PR.

How: new files; consumes isAudioTimelineElement (leaf-helpers PR) and
computeStackingPatches (stacking-sync PR); type-only playerStore imports.

Test plan: bunx vitest run timelineZones.test.ts; tsc --noEmit; fallow
audit clean.

* feat(studio): asset click policy and canvas nudge gate

What: two small pure modules with tests — assetClickBehavior (click vs
double-click policy for sidebar assets) and canvasNudgeGate (debounce gate
for arrow-key canvas nudges).

Why: policy dependencies of the upcoming asset card and nudge hook,
reviewable as plain decision tables.

How: new files only.

Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit
clean.

* test(studio): characterization suites for resize commit and razor history

What: two test-only suites pinning CURRENT behavior before the NLE swap:
anchoredResizeReleaseShift.test.ts (manual-offset resize release commits)
and useRazorSplit.history.test.tsx (razor split undo/redo history).

Why: regression tripwires — the later glue-swap PRs must keep these green.

How: test files only; they import existing main modules unchanged and pass
against them as-is.

Test plan: bunx vitest run on both suites; fallow audit clean.

* feat(studio): canvas context menu and z-order actions (unwired)

What: CanvasContextMenu (right-click menu for canvas selections) and
canvasContextMenuZOrder (tie-aware bring-forward/send-backward z-order patch
computation) with its test suite. Shipped unwired.

Why: the z-order rules are the substance; mounting is one line in the later
overlay swap.

How: new files, compiled against current main. Nothing mounts the menu yet,
so .fallowrc.jsonc gains TEMP(studio-dnd) entries (entry registration +
ignoreExports) — removed by the app-shell swap PR that wires everything.

Test plan: bunx vitest run canvasContextMenuZOrder.test.ts; tsc --noEmit;
fallow audit clean.

---------

Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
This commit is contained in:
Miguel Ángel
2026-07-12 00:19:43 -04:00
committed by GitHub
co-authored by ukimsanov
parent c6a508a9bc
commit ebbd1eb2c2
5 changed files with 1115 additions and 0 deletions
+15
View File
@@ -53,6 +53,9 @@
"packages/studio/src/hooks/gsapTargetCache.ts",
// Preview helper consumed dynamically from the studio iframe bridge.
"packages/studio/src/hooks/gsapRuntimePreview.ts",
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
],
"ignorePatterns": [
"docs/**",
@@ -90,6 +93,11 @@
"packages/cli/src/cloud/_gen/**",
],
"ignoreExports": [
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
{
"file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts",
"exports": ["readEffectiveZIndex"],
},
// drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
// a per-PR audit diffing against the merge base sees these exports as
@@ -433,6 +441,13 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
// removed by studio-dnd/pr22 when the final config lands.
"packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
"packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts",
"packages/studio/src/player/components/timelineCollision.test.ts",
"packages/studio/src/player/components/timelineStackingSync.test.ts",
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
// patch*InTag pre-date this PR; only the PatchOperation type gained two
// optional fields, but the line-shift fingerprint re-flags the inherited
@@ -0,0 +1,115 @@
// @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 { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
import { CanvasContextMenu } from "./CanvasContextMenu";
import type { DomEditSelection } from "./domEditing";
installReactActEnvironment();
let host: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
host = document.createElement("div");
document.body.append(host);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
});
function renderMenu(props: {
selection: DomEditSelection;
onApplyZIndex?: () => void;
onDelete?: (selection: DomEditSelection) => void;
}) {
root = createRoot(host);
act(() => {
root!.render(
React.createElement(CanvasContextMenu, {
x: 10,
y: 10,
selection: props.selection,
onClose: () => {},
onApplyZIndex: props.onApplyZIndex,
onDelete: props.onDelete,
}),
);
});
}
/** All menu buttons live in the portal under document.body. */
function menuButtons(): HTMLButtonElement[] {
return [...document.body.querySelectorAll("button")];
}
function hasDeleteItem(): boolean {
return menuButtons().some((b) => b.textContent?.includes("Delete"));
}
function zOrderButtons(): HTMLButtonElement[] {
return menuButtons().filter((b) => !b.textContent?.includes("Delete"));
}
describe("CanvasContextMenu — handler gating", () => {
it("renders all four z-order items, a divider, and Delete when both handlers are present", () => {
const el = document.createElement("div");
el.id = "target";
document.body.append(el);
renderMenu({
selection: makeSelection("Target", el),
onApplyZIndex: vi.fn(),
onDelete: vi.fn(),
});
expect(zOrderButtons()).toHaveLength(4);
expect(hasDeleteItem()).toBe(true);
// The divider only appears between the two groups.
expect(document.body.querySelector(".border-t")).not.toBeNull();
});
it("hides every item and does NOT render the menu when no handlers are present", () => {
const el = document.createElement("div");
el.id = "target";
// A z-index that a stray optimistic write would clobber — assert it is
// untouched, since the menu must not mutate the DOM without a persist path.
el.style.zIndex = "3";
document.body.append(el);
renderMenu({ selection: makeSelection("Target", el) });
// No menu opened at all — no buttons, no dead-end items, no DOM mutation.
expect(menuButtons()).toHaveLength(0);
expect(document.body.querySelector(".fixed.z-50")).toBeNull();
expect(el.style.zIndex).toBe("3");
});
it("shows only the z-order items (no Delete, no divider) when onDelete is absent", () => {
const el = document.createElement("div");
el.id = "target";
document.body.append(el);
renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
expect(zOrderButtons()).toHaveLength(4);
expect(hasDeleteItem()).toBe(false);
expect(document.body.querySelector(".border-t")).toBeNull();
});
it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
const el = document.createElement("div");
el.id = "target";
document.body.append(el);
renderMenu({ selection: makeSelection("Target", el), onDelete: vi.fn() });
expect(zOrderButtons()).toHaveLength(0);
expect(hasDeleteItem()).toBe(true);
expect(document.body.querySelector(".border-t")).toBeNull();
});
});
@@ -0,0 +1,198 @@
/**
* Right-click context menu for a selected canvas element.
*
* Mirrors the look, positioning, and dismiss behavior of
* player/components/ClipContextMenu.tsx — portaled to document.body,
* overflow-adjusted, dismissed on outside-click or Escape via
* useContextMenuDismiss.
*
* ── Wiring (z-order persistence) ─────────────────────────────────────────────
* Z-index changes are applied optimistically to the live iframe element(s) via
* `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
* moving a target past an equal-z sibling can require renumbering the affected
* set). The patches are surfaced through the `onApplyZIndex` prop.
*
* The prop MUST be wired at the call site to route through the full persist
* path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
* element carries its full selection identity; sibling elements are iframe DOM
* nodes, so their id / selector are derived from the node and they share the
* selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
* ─────────────────────────────────────────────────────────────────────────────
*/
import { memo } from "react";
import { createPortal } from "react-dom";
import type { DomEditSelection } from "./domEditing";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import {
isZOrderActionEnabled,
resolveZOrderChange,
type ZOrderPatch,
} from "./canvasContextMenuZOrder";
interface CanvasContextMenuProps {
/** Viewport x of the right-click event. */
x: number;
/** Viewport y of the right-click event. */
y: number;
selection: DomEditSelection;
onClose: () => void;
/**
* Called with the resolved z-order patch list after an optimistic DOM update.
* Each patch is an { element, zIndex } pair (the target and, when a renumber
* is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
* module-level wiring comment).
*/
onApplyZIndex?: (patches: ZOrderPatch[]) => void;
/**
* Delete the selected element. Wire to handleDomEditElementDelete from
* useDomEditActionsContext — same path as the Delete/Backspace hotkey.
* Absent when the caller wires no delete persist path (e.g. a legacy mount):
* the Delete item is then hidden rather than shown as a silent no-op.
*/
onDelete?: (selection: DomEditSelection) => void;
}
type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
{ action: "bring-forward", label: "Bring forward" },
{ action: "send-backward", label: "Send backward" },
{ action: "bring-to-front", label: "Bring to front" },
{ action: "send-to-back", label: "Send to back" },
];
export const CanvasContextMenu = memo(function CanvasContextMenu({
x,
y,
selection,
onClose,
onApplyZIndex,
onDelete,
}: CanvasContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
// Gate each item group on the presence of its persist handler. Without the
// handler the action can't be persisted, so showing it would be a dead-end:
// a z-write reverts on reload and Delete silently no-ops. Hide the group
// instead. If nothing is actionable (a legacy mount with no handlers at all),
// don't render the menu — an empty menu is itself a dead-end.
const hasZActions = Boolean(onApplyZIndex);
const hasDelete = Boolean(onDelete);
const hasDivider = hasZActions && hasDelete;
// Overflow correction — match ClipContextMenu approach. Only the rendered
// groups contribute height (keeps positioning correct when a group is hidden).
const menuWidth = 200;
const menuHeight =
8 + (hasZActions ? Z_ACTIONS.length * 28 : 0) + (hasDivider ? 1 : 0) + (hasDelete ? 28 : 0) + 8; // padding + items + divider + delete + padding
const overflowY = y + menuHeight - window.innerHeight;
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
const el = selection.element;
function handleZAction(action: ZAction) {
// No persist handler → do NOT touch the live iframe DOM. An optimistic
// write with nothing to persist just reverts on the next reload.
if (!onApplyZIndex) return;
const patches = resolveZOrderChange(el, action);
if (patches === null) return;
// Optimistic update — visible immediately even before persist completes.
for (const patch of patches) {
patch.element.style.zIndex = String(patch.zIndex);
const view = patch.element.ownerDocument?.defaultView;
if (view && view.getComputedStyle(patch.element).position === "static") {
patch.element.style.position = "relative";
}
}
onApplyZIndex(patches);
onClose();
}
function handleDelete() {
if (!onDelete) return;
onDelete(selection);
onClose();
}
if (!hasZActions && !hasDelete) return null;
// The menu is portaled to document.body, but in the React tree it is still a
// child of the DomEditOverlay <div>. React synthetic events bubble through the
// REACT tree (not the DOM tree), so a click on any menu control would otherwise
// bubble into the overlay's onPointerDown / onMouseDown handlers — which
// preventDefault() to start a marquee and re-resolve the selection. That
// preventDefault cancels the button's own click and the item action never runs.
//
// Stop pointer/mouse propagation at the menu root so overlay gesture handlers
// never see these events, and drive the item actions on pointerDown (which
// fires before any outside-click / dismiss logic can unmount the menu).
const stopBubble = (e: React.SyntheticEvent) => {
e.stopPropagation();
};
return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
onPointerDown={stopBubble}
onMouseDown={stopBubble}
onClick={stopBubble}
onContextMenu={(e) => {
// Keep a right-click on the menu itself from re-opening / bubbling.
e.preventDefault();
e.stopPropagation();
}}
>
{hasZActions &&
Z_ACTIONS.map(({ action, label }) => {
const enabled = isZOrderActionEnabled(el, action);
return (
<button
key={action}
type="button"
className={`w-full flex items-center px-3 py-1.5 text-xs text-left ${
enabled
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
}`}
disabled={!enabled}
// Act on pointerDown, not click: a pointerDown that reaches the
// overlay/document would otherwise re-select or dismiss the menu
// before the trailing click fires. Running here guarantees the
// action lands. Guard `button === 0` so a right-press is ignored.
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
if (enabled) handleZAction(action);
}}
>
{label}
</button>
);
})}
{hasDivider && <div className="my-1 border-t border-neutral-700/60" />}
{hasDelete && (
<button
type="button"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
handleDelete();
}}
>
<span>Delete</span>
<span className="text-neutral-500 text-[10px] ml-3"></span>
</button>
)}
</div>,
document.body,
);
});
@@ -0,0 +1,435 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
isZOrderActionEnabled,
parseZIndex,
resolveZOrderChange,
type ZOrderAction,
type ZOrderPatch,
} from "./canvasContextMenuZOrder";
// ── helpers ───────────────────────────────────────────────────────────────────
//
// In jsdom getBoundingClientRect returns all zeros, so the target rect is 0×0
// and getOverlappingFamily returns the whole family — i.e. every sibling is
// treated as overlapping. That makes forward/backward and front/back exercise
// the same scoped set here, which is exactly what we want for order logic.
function makeEl(id: string, zIndex?: string): HTMLElement {
const el = document.createElement("div");
el.id = id;
if (zIndex !== undefined) el.style.zIndex = zIndex;
return el;
}
/**
* Build a parent and append `[target, ...siblings]` in DOM order. Each spec is
* [id, z]. The target's z is `targetZ`; it is appended FIRST unless
* `targetLast` is set, in which case it is appended LAST (later in DOM).
*/
function makeFamily(
targetZ: string,
siblingSpecs: Array<[string, string]>,
opts: { targetLast?: boolean } = {},
): { target: HTMLElement; parent: HTMLElement; byId: Record<string, HTMLElement> } {
const parent = document.createElement("div");
const target = makeEl("target", targetZ);
const siblings = siblingSpecs.map(([id, z]) => makeEl(id, z));
const byId: Record<string, HTMLElement> = { target };
for (const s of siblings) byId[s.id] = s;
if (opts.targetLast) {
for (const s of siblings) parent.appendChild(s);
parent.appendChild(target);
} else {
parent.appendChild(target);
for (const s of siblings) parent.appendChild(s);
}
return { target, parent, byId };
}
/** Resolve a z-order change and assert it produced patches (fails otherwise). */
function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] {
const patches = resolveZOrderChange(target, action);
expect(patches).not.toBeNull();
if (!patches) throw new Error("expected z-order patches");
return patches;
}
/** Look up a patch for a given element id in a patch list. */
function patchFor(patches: ZOrderPatch[], byId: Record<string, HTMLElement>, id: string) {
return patches.find((p) => p.element === byId[id]);
}
/** Apply patches, then return the render-ordered ids (bottom→top). */
function renderOrderIds(
parent: HTMLElement,
byId: Record<string, HTMLElement>,
patches: ZOrderPatch[],
): string[] {
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
const children = Array.from(parent.children) as HTMLElement[];
const withPos = children.map((el, domIndex) => ({
id: Object.keys(byId).find((k) => byId[k] === el) ?? el.id,
z: parseZIndex(el.style.zIndex || "0"),
domIndex,
}));
withPos.sort((a, b) => a.z - b.z || a.domIndex - b.domIndex);
return withPos.map((e) => e.id);
}
// ── parseZIndex ───────────────────────────────────────────────────────────────
describe("parseZIndex", () => {
it("parses integers", () => {
expect(parseZIndex("5")).toBe(5);
expect(parseZIndex("0")).toBe(0);
expect(parseZIndex("-3")).toBe(-3);
});
it("treats 'auto' / null / undefined / empty as 0", () => {
expect(parseZIndex("auto")).toBe(0);
expect(parseZIndex(null)).toBe(0);
expect(parseZIndex(undefined)).toBe(0);
expect(parseZIndex("")).toBe(0);
});
});
// ── distinct-z fast path (single-element patch) ────────────────────────────────
describe("resolveZOrderChange distinct z values (fast path)", () => {
it("bring-to-front moves target above all with a single patch", () => {
const { target, byId } = makeFamily("2", [
["a", "1"],
["b", "5"],
["c", "3"],
]);
const patches = resolveZOrderPatches(target, "bring-to-front");
expect(patches).toHaveLength(1);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(6);
});
it("bring-to-front returns null when already on top", () => {
const { target } = makeFamily("6", [
["a", "1"],
["b", "5"],
["c", "3"],
]);
expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
});
it("send-to-back moves target below all", () => {
const { target, byId, parent } = makeFamily("3", [
["a", "1"],
["b", "5"],
["c", "2"],
]);
const patches = resolveZOrderPatches(target, "send-to-back");
// target must end up strictly below the current min (1) in render order.
expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
});
it("send-to-back returns null when already at back", () => {
const { target } = makeFamily("0", [
["a", "1"],
["b", "5"],
["c", "3"],
]);
expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
});
it("bring-forward steps up exactly one in render order", () => {
const { target, byId, parent } = makeFamily("2", [
["a", "1"],
["b", "4"],
["c", "7"],
]);
// render order bottom→top: a(1), target(2), b(4), c(7). forward → above b.
const patches = resolveZOrderPatches(target, "bring-forward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "b", "target", "c"]);
});
it("send-backward steps down exactly one in render order", () => {
const { target, byId, parent } = makeFamily("5", [
["a", "1"],
["b", "3"],
["c", "8"],
]);
// bottom→top: a(1), b(3), target(5), c(8). backward → below b.
const patches = resolveZOrderPatches(target, "send-backward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target", "b", "c"]);
});
it("bring-forward returns null when already top of set", () => {
const { target } = makeFamily("8", [
["a", "1"],
["b", "4"],
["c", "7"],
]);
expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
});
it("send-backward returns null when already bottom of set", () => {
const { target } = makeFamily("0", [
["a", "1"],
["b", "3"],
["c", "8"],
]);
expect(resolveZOrderChange(target, "send-backward")).toBeNull();
});
it("returns null when no siblings", () => {
const target = makeEl("solo", "2");
document.createElement("div").appendChild(target);
for (const action of [
"bring-forward",
"send-backward",
"bring-to-front",
"send-to-back",
] as ZOrderAction[]) {
expect(resolveZOrderChange(target, action)).toBeNull();
}
});
});
// ── DOM-order ties (the repro) ─────────────────────────────────────────────────
describe("resolveZOrderChange DOM-order ties (repro: equal z)", () => {
it("send-backward: tied target LATER in DOM (visually on top) can go below", () => {
// img#a (z=0, earlier in DOM) then video#target (z=0, later) → video paints
// on top. send-backward must put target below the image.
const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
const patches = resolveZOrderPatches(target, "send-backward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["target", "a"]);
// target ends strictly below the image.
const tz = patchFor(patches, byId, "target")?.zIndex ?? 0;
expect(tz).toBeGreaterThanOrEqual(0);
});
it("send-to-back: tied target LATER in DOM goes to the very back", () => {
const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
const patches = resolveZOrderPatches(target, "send-to-back");
expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
});
it("bring-forward: tied target EARLIER in DOM (visually below) can go above", () => {
// target#target (z=0, earlier) then #a (z=0, later) → a paints on top.
// bring-forward on target must lift it above a.
const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
const patches = resolveZOrderPatches(target, "bring-forward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target"]);
});
it("bring-to-front: tied target EARLIER in DOM goes to the very front", () => {
const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
const patches = resolveZOrderPatches(target, "bring-to-front");
const order = renderOrderIds(parent, byId, patches);
expect(order[order.length - 1]).toBe("target");
});
it("send-backward: tied target EARLIER in DOM is already at back → null", () => {
// target earlier + a later, both z=0. target already paints below a.
const { target } = makeFamily("0", [["a", "0"]]);
expect(resolveZOrderChange(target, "send-backward")).toBeNull();
expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
});
it("bring-forward: tied target LATER in DOM is already on top → null", () => {
const { target } = makeFamily("0", [["a", "0"]], { targetLast: true });
expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
});
it("renumber emits a real patch per changed element and none for the unchanged (minimal, no no-ops)", () => {
// Three tied at z=0, target in the middle of DOM order. Sending it back must
// renumber to distinct values but leave the target (which keeps its bottom
// slot's value 0) unpatched — and every emitted patch must be a genuine change.
const parent = document.createElement("div");
const a = makeEl("a", "0");
const target = makeEl("target", "0");
const b = makeEl("b", "0");
parent.append(a, target, b);
const originalZ = new Map<HTMLElement, number>([
[a, 0],
[target, 0],
[b, 0],
]);
// render order bottom→top by (z, dom): a, target, b. send-backward → below a.
const patches = resolveZOrderPatches(target, "send-backward");
// Every emitted patch is a REAL change: its new z differs from the old z.
for (const p of patches) expect(p.zIndex).not.toBe(originalZ.get(p.element));
// target renumbers to 0 (its existing value) → it must NOT be in the patch set.
expect(patchFor(patches, { a, target, b }, "target")).toBeUndefined();
// No two patches collide on the same element (a well-formed minimal set).
expect(new Set(patches.map((p) => p.element)).size).toBe(patches.length);
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
expect(renderOrderIds(parent, { a, target, b }, [])).toEqual(["target", "a", "b"]);
});
});
// ── overlap scoping (real getBoundingClientRect) ────────────────────────────────
//
// jsdom's getBoundingClientRect is 0×0, so getOverlappingFamily keeps the whole
// family and the SCOPED (overlapping-only) path is never exercised above. These
// mock rects so a sibling can be genuinely NON-overlapping and thus non-scoped.
interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}
function setRect(el: HTMLElement, r: Rect): void {
el.getBoundingClientRect = (): DOMRect =>
({
left: r.left,
top: r.top,
right: r.right,
bottom: r.bottom,
width: r.right - r.left,
height: r.bottom - r.top,
x: r.left,
y: r.top,
toJSON: () => ({}),
}) as DOMRect;
}
describe("resolveZOrderChange overlap scoping preserves untouched non-scoped pairs (#2202)", () => {
it("send-backward renumber keeps a scoped sibling above an untouched NON-overlapping one", () => {
// A (z5, overlaps target) and target (z5) are tied and overlap; C (z3) does NOT
// overlap target, so it is non-scoped. Old renumber sent the scoped set to
// 0..n-1 (A→1), dropping A BELOW C (z3) — an untouched (A, C) pair inverting.
// The band-preserving renumber keeps the scoped block above C: A→6, C untouched.
const parent = document.createElement("div");
const a = makeEl("a", "5");
const target = makeEl("target", "5");
const c = makeEl("c", "3");
parent.append(a, target, c);
setRect(a, { left: 0, top: 0, right: 10, bottom: 10 });
setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
setRect(c, { left: 100, top: 100, right: 110, bottom: 110 }); // disjoint → non-scoped
const byId = { a, target, c };
const patches = resolveZOrderPatches(target, "send-backward");
// C (untouched, non-scoped) is never patched.
expect(patchFor(patches, byId, "c")).toBeUndefined();
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
const order = renderOrderIds(parent, byId, []);
// Deliberate move: target below a. Preserved untouched pair: a stays above c.
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
expect(order.indexOf("a")).toBeGreaterThan(order.indexOf("c"));
});
it("scopes forward/backward to the overlapping set (a non-overlapping sibling is ignored)", () => {
// target (z1) overlaps a (z2) only; far (z5) does not overlap target. bring-
// forward must step target above a (its sole overlapping neighbour), NOT chase
// the non-overlapping far — proving the scoping actually runs with real rects.
const parent = document.createElement("div");
const target = makeEl("target", "1");
const a = makeEl("a", "2");
const far = makeEl("far", "5");
parent.append(target, a, far);
setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
setRect(a, { left: 5, top: 5, right: 15, bottom: 15 }); // overlaps target
setRect(far, { left: 200, top: 200, right: 210, bottom: 210 }); // disjoint
const byId = { target, a, far };
const patches = resolveZOrderPatches(target, "bring-forward");
// far is untouched (not in the overlapping scope).
expect(patchFor(patches, byId, "far")).toBeUndefined();
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
const order = renderOrderIds(parent, byId, []);
// target rose just above its overlapping neighbour a, staying below far.
expect(order.indexOf("target")).toBeGreaterThan(order.indexOf("a"));
expect(order.indexOf("target")).toBeLessThan(order.indexOf("far"));
});
});
// ── non-painting sibling hygiene ───────────────────────────────────────────────
describe("resolveZOrderChange excludes non-painting siblings", () => {
it("ignores <audio>/<script>/<style> siblings in the family", () => {
// Parent holds: img#a (z0), <audio> (a prior renumber wrote z=2 onto it),
// video#target (z0, later in DOM), plus a <script> and <style>. Only the two
// painting elements should form the family — the audio's z=2 must NOT pad the
// renumber or count as a sibling above the target.
const parent = document.createElement("div");
const a = makeEl("a", "0");
const audio = document.createElement("audio");
audio.style.zIndex = "2";
const script = document.createElement("script");
const style = document.createElement("style");
const target = makeEl("target", "0");
parent.append(a, audio, script, style, target);
// target is later in DOM than a, tied at z=0 → paints on top. send-to-back
// must put it below a. If audio (z=2) were counted, the renumber would differ.
const patches = resolveZOrderPatches(target, "send-to-back");
// No patch may target the audio/script/style elements.
for (const p of patches) {
expect(p.element).not.toBe(audio);
expect(p.element).not.toBe(script);
expect(p.element).not.toBe(style);
}
// Order among the painting pair: target below a.
const order = renderOrderIds(parent, { a, target }, patches);
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
});
it("a lone painting element beside only non-painting siblings has no family → null", () => {
const parent = document.createElement("div");
const target = makeEl("target", "1");
const audio = document.createElement("audio");
parent.append(target, audio);
// Only sibling is <audio> (excluded) → family size 1 → every action is a no-op.
for (const action of [
"bring-forward",
"send-backward",
"bring-to-front",
"send-to-back",
] as ZOrderAction[]) {
expect(resolveZOrderChange(target, action)).toBeNull();
}
});
});
// ── isZOrderActionEnabled ─────────────────────────────────────────────────────
describe("isZOrderActionEnabled", () => {
it("mirrors resolveZOrderChange non-null", () => {
// target z=2 (DOM 0), a z=5 (DOM 1): render order = target, a. target is at
// the bottom, so forward/front are enabled and backward/back are no-ops.
const { target } = makeFamily("2", [["a", "5"]]);
expect(isZOrderActionEnabled(target, "bring-to-front")).toBe(true);
expect(isZOrderActionEnabled(target, "bring-forward")).toBe(true);
expect(isZOrderActionEnabled(target, "send-to-back")).toBe(false);
expect(isZOrderActionEnabled(target, "send-backward")).toBe(false);
});
it("false when already on top", () => {
const { target } = makeFamily("6", [
["a", "1"],
["b", "5"],
]);
expect(isZOrderActionEnabled(target, "bring-to-front")).toBe(false);
expect(isZOrderActionEnabled(target, "bring-forward")).toBe(false);
});
it("tie repro: send-backward enabled for a visually-on-top tied target", () => {
const { target } = makeFamily("0", [["a", "0"]], { targetLast: true });
expect(isZOrderActionEnabled(target, "send-backward")).toBe(true);
expect(isZOrderActionEnabled(target, "send-to-back")).toBe(true);
});
it("all actions disabled when there are no siblings", () => {
const target = makeEl("solo", "1");
document.createElement("div").appendChild(target);
for (const action of [
"bring-forward",
"send-backward",
"bring-to-front",
"send-to-back",
] as ZOrderAction[]) {
expect(isZOrderActionEnabled(target, action)).toBe(false);
}
});
});
@@ -0,0 +1,352 @@
/**
* Pure z-order helpers for the canvas right-click context menu.
*
* Layering strategy: z-index + CSS stacking context (position ≠ static).
* All sibling z-index values are read from the live iframe DOM via
* element.style.zIndex (inline style, set by the editor) falling back to
* the computed value. Treat missing / "auto" as 0 for comparison purposes.
*
* "Overlapping siblings" = siblings whose bounding rects intersect the
* target's bounding rect. Forward/backward operate within that set;
* front/back operate across all siblings.
*
* ── Tie-awareness ────────────────────────────────────────────────────────────
* CSS paint order for elements that share a z-index is DOM document order:
* the element that comes LATER in the DOM paints ON TOP. The old resolver
* compared z-index alone, so a target tied with the element visually below it
* (equal z, target later in DOM) had an empty "below" set and silently
* no-op'd. This module computes true render order — sort by
* (zIndex asc, DOM position asc), bottom→top — moves the target one step (or
* to an end) in that order, then realizes the new order back into z values.
*
* The result is a MULTI-element patch: a single-element patch when a
* strictly-between z value can express the new order given DOM-order
* tie-breaking, otherwise a minimal renumber of the affected set (emitting
* patches only for elements whose z actually changes). z is never negative
* (project convention clamps z ≥ 0).
*/
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
/** A resolved change: set `element`'s z-index to `zIndex`. */
export interface ZOrderPatch {
element: HTMLElement;
zIndex: number;
}
interface RenderEntry {
element: HTMLElement;
zIndex: number;
/** Position within the shared parent's children (DOM document order). */
domIndex: number;
}
/** Parse a z-index string to a number; treats "auto" / empty as 0. */
export function parseZIndex(value: string | null | undefined): number {
if (!value || value === "auto") return 0;
const n = parseInt(value, 10);
return Number.isFinite(n) ? n : 0;
}
/** Read the effective z-index for an element (inline style preferred). */
export function readEffectiveZIndex(el: HTMLElement): number {
const inline = el.style.zIndex;
if (inline && inline !== "auto") return parseZIndex(inline);
try {
const win = el.ownerDocument?.defaultView;
if (win) return parseZIndex(win.getComputedStyle(el).zIndex);
} catch {
/* cross-origin / detached */
}
return 0;
}
/**
* Realm-safe HTMLElement check. The target lives in the preview IFRAME's
* document, but this module runs in the top window, so `child instanceof
* HTMLElement` (top-window constructor) is ALWAYS false for iframe elements —
* which silently emptied the sibling list and left every z-order action
* permanently disabled. Compare against the element's own realm instead, with
* a nodeType fallback for detached / cross-realm edge cases.
*/
function isElementNode(node: Node): node is HTMLElement {
const view = node.ownerDocument?.defaultView;
if (view && node instanceof view.HTMLElement) return true;
return node.nodeType === 1;
}
/**
* Tags that never paint pixels and so must be excluded from z-order siblings.
* `<audio>` is the real offender here: a prior renumber wrote a meaningless
* z-index onto the qa-clean audio element, and counting it as a sibling skews the
* renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
* non-painting and could otherwise pad the family / eat a z slot.
*/
const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
/** A painting element: an element node whose tag actually renders pixels. */
function isPaintingElement(node: Node): node is HTMLElement {
return isElementNode(node) && !NON_PAINTING_TAGS.has(node.tagName);
}
/**
* Collect the target plus every PAINTING HTMLElement sibling (same parent),
* tagged with DOM document position. Non-painting siblings (audio/script/style/
* link/meta) are skipped so they neither pad the family nor consume a z slot in
* the renumber path. Returns the target's own index within the result.
*/
function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex: number } {
const parent = target.parentElement;
if (!parent) return { entries: [], targetIndex: -1 };
const entries: RenderEntry[] = [];
let targetIndex = -1;
let domIndex = 0;
for (const child of Array.from(parent.children)) {
// The target is always retained even if its own tag is non-painting.
if (child !== target && !isPaintingElement(child)) continue;
if (!isElementNode(child)) continue;
if (child === target) targetIndex = entries.length;
entries.push({ element: child, zIndex: readEffectiveZIndex(child), domIndex });
domIndex += 1;
}
return { entries, targetIndex };
}
/** True if two DOM bounding rects intersect (even if touching). */
function rectsIntersect(
a: { left: number; top: number; right: number; bottom: number },
b: { left: number; top: number; right: number; bottom: number },
): boolean {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
/**
* Restrict a family to the target plus siblings whose bounding rect overlaps
* the target's rect. The target is always retained. If the target's rect is
* unavailable or empty (headless / happy-dom returns 0×0), all entries are
* kept — matching the prior behavior.
*/
function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
let targetRect: DOMRect;
try {
targetRect = target.getBoundingClientRect();
} catch {
return entries;
}
if (targetRect.width === 0 && targetRect.height === 0) return entries;
const tr = {
left: targetRect.left,
top: targetRect.top,
right: targetRect.right,
bottom: targetRect.bottom,
};
return entries.filter((entry) => {
if (entry.element === target) return true;
try {
const r = entry.element.getBoundingClientRect();
return rectsIntersect(tr, { left: r.left, top: r.top, right: r.right, bottom: r.bottom });
} catch {
return false;
}
});
}
/** Sort a family into render order (bottom→top): z asc, then DOM position asc. */
function toRenderOrder(entries: RenderEntry[]): RenderEntry[] {
return [...entries].sort((a, b) => a.zIndex - b.zIndex || a.domIndex - b.domIndex);
}
/**
* A z that lands the target strictly between `below` and `above` in render order,
* or null when no such value exists (a tie-prone gap, or no room below the floor)
* and the caller must renumber. Equal-z ties break by DOM order, so a plain
* equality can flip order unpredictably; require a strict gap and clamp at 0.
*/
function computeBetweenZ(
below: RenderEntry | undefined,
above: RenderEntry | undefined,
): number | null {
if (below && above) {
return above.zIndex - below.zIndex >= 2 ? below.zIndex + 1 : null;
}
if (below) return below.zIndex + 1; // move to top
if (above) {
const candidate = Math.max(0, above.zIndex - 1); // move to bottom
return candidate >= above.zIndex ? null : candidate; // no room below → renumber
}
return null;
}
/**
* Realize a desired render order (bottom→top) into z-index patches for the
* given family, emitting patches ONLY for elements whose z actually changes.
*
* Fast path: if the SCOPED z values are all distinct, the render order is fully
* determined by z alone — a single-element move can be expressed by placing the
* target's z strictly between its new neighbours (or at an end), so at most one
* element changes (and it never disturbs an untouched pair, since only the target
* moves). When ties exist a between value can be impossible, so renumber — but the
* scoped set is only a SUBSET of the family (the target's overlapping siblings),
* so a naive 0..n-1 renumber can drop a scoped sibling below an untouched
* non-scoped one, reordering an untouched pair (#2202). `renumberScoped` keeps the
* scoped block inside its original z-band, bounded by the non-scoped siblings.
*/
function realizeOrder(
currentOrder: RenderEntry[],
desiredOrder: RenderEntry[],
target: HTMLElement,
family: RenderEntry[],
): ZOrderPatch[] | null {
const targetPos = desiredOrder.findIndex((e) => e.element === target);
if (targetPos === -1) return null;
const targetZ = readEffectiveZIndex(target);
// ── Fast path: distinct z values → a single between-value move suffices.
const zValues = currentOrder.map((e) => e.zIndex);
const hasDupes = zValues.some((v, i) => zValues.indexOf(v) !== i);
if (!hasDupes) {
const candidate = computeBetweenZ(desiredOrder[targetPos - 1], desiredOrder[targetPos + 1]);
if (candidate !== null) {
if (candidate === targetZ) return null;
return [{ element: target, zIndex: candidate }];
}
// else fall through to renumber
}
return renumberScoped(currentOrder, desiredOrder, target, family);
}
/**
* Renumber the SCOPED set (the reordered subset) to distinct z, keeping the whole
* block within the band its members already occupied so no untouched scoped /
* non-scoped pair is reordered (#2202). The block is placed near its original base
* `lo`, but clamped to sit strictly above the highest non-scoped sibling below the
* band and strictly below the lowest non-scoped sibling above it. Only scoped
* members are patched; non-scoped siblings keep their authored z.
*
* If a non-scoped sibling sits INSIDE or tied to the band (no clean bracket), or
* the bracket is too narrow to hold `n` distinct integers, fall back to a
* whole-family renumber — less minimal but still preserves every relative order.
*/
function renumberScoped(
currentOrder: RenderEntry[],
desiredOrder: RenderEntry[],
target: HTMLElement,
family: RenderEntry[],
): ZOrderPatch[] | null {
const scoped = new Set(desiredOrder.map((e) => e.element));
const nonScoped = family.filter((e) => !scoped.has(e.element));
const n = desiredOrder.length;
const zs = currentOrder.map((e) => e.zIndex);
const lo = Math.min(...zs);
const hi = Math.max(...zs);
const bracketed = !nonScoped.some((e) => e.zIndex >= lo && e.zIndex <= hi);
if (bracketed) {
const below = nonScoped.filter((e) => e.zIndex < lo).map((e) => e.zIndex);
const above = nonScoped.filter((e) => e.zIndex > hi).map((e) => e.zIndex);
const minStart = below.length > 0 ? Math.max(...below) + 1 : 0; // z ≥ 0 convention
const hasUpper = above.length > 0;
const maxStart = hasUpper ? Math.min(...above) - n : Number.POSITIVE_INFINITY;
if (minStart <= maxStart) {
let start = Math.max(lo, minStart);
if (hasUpper) start = Math.min(start, maxStart);
const patches: ZOrderPatch[] = [];
desiredOrder.forEach((entry, i) => {
if (entry.zIndex !== start + i) patches.push({ element: entry.element, zIndex: start + i });
});
return patches.length === 0 ? null : patches;
}
}
// ── Fallback: renumber the whole family so relative order is still preserved.
const desiredGlobal = buildGlobalOrder(family, desiredOrder, target);
const patches: ZOrderPatch[] = [];
desiredGlobal.forEach((entry, i) => {
if (entry.zIndex !== i) patches.push({ element: entry.element, zIndex: i });
});
return patches.length === 0 ? null : patches;
}
/**
* A whole-family render order (bottom→top) with the non-scoped siblings kept in
* their current relative order and the target reinserted beside its new SCOPED
* neighbour (just above the scoped element below it, else just below the scoped
* element above it). Used only by the renumber fallback.
*/
function buildGlobalOrder(
family: RenderEntry[],
desiredOrder: RenderEntry[],
target: HTMLElement,
): RenderEntry[] {
const full = toRenderOrder(family);
const targetEntry = full.find((e) => e.element === target);
const rest = full.filter((e) => e.element !== target);
if (!targetEntry) return rest;
const targetPos = desiredOrder.findIndex((e) => e.element === target);
const prev = desiredOrder[targetPos - 1];
const next = desiredOrder[targetPos + 1];
const prevIdx = prev ? rest.findIndex((e) => e.element === prev.element) : -1;
const nextIdx = next ? rest.findIndex((e) => e.element === next.element) : -1;
if (prevIdx >= 0) rest.splice(prevIdx + 1, 0, targetEntry);
else if (nextIdx >= 0) rest.splice(nextIdx, 0, targetEntry);
else rest.unshift(targetEntry);
return rest;
}
/**
* Resolve the z-order patches for an action.
*
* Returns null when the action is a no-op (target already at the relevant
* end of its set), otherwise the minimal list of {element, zIndex} changes.
*/
export function resolveZOrderChange(
target: HTMLElement,
action: ZOrderAction,
): ZOrderPatch[] | null {
const { entries } = getFamily(target);
// Family always includes the target; fewer than 2 means no siblings at all.
if (entries.length < 2) return null;
const scoped =
action === "bring-to-front" || action === "send-to-back"
? entries
: getOverlappingFamily(target, entries);
if (scoped.length < 2) return null;
const order = toRenderOrder(scoped);
const pos = order.findIndex((e) => e.element === target);
if (pos === -1) return null;
const desired = [...order];
const [moved] = desired.splice(pos, 1);
switch (action) {
case "bring-forward":
if (pos >= order.length - 1) return null; // already top of set
desired.splice(pos + 1, 0, moved);
break;
case "send-backward":
if (pos <= 0) return null; // already bottom of set
desired.splice(pos - 1, 0, moved);
break;
case "bring-to-front":
if (pos >= order.length - 1) return null;
desired.push(moved);
break;
case "send-to-back":
if (pos <= 0) return null;
desired.unshift(moved);
break;
}
return realizeOrder(order, desired, target, entries);
}
/**
* Whether a z-order action is available for the target.
* "disabled" = the element is already at that limit.
*/
export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
return resolveZOrderChange(target, action) !== null;
}