mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
* 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:
co-authored by
ukimsanov
parent
c6a508a9bc
commit
ebbd1eb2c2
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user