mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order) (#2380)
* feat(studio): mirror canvas z-order actions into timeline lanes, badge z overrides
Track order = default paint order; authored z = advanced override.
- timelineZMirror.ts: pure resolver mapping a successful z-menu action to a
timeline lane move — closest track in the action's direction that is free
over the clip's whole span, else a new lane adjacent to the crossed
neighbor; temporal-overlap scope (default pending product sign-off, see
module doc); visual zone only; same-file reference scoping; persistTrack
via the shared authored-space rules. null for non-clips (menu stays
z-only) and at-extreme/no-overlap cases.
- useCanvasZOrderTimelineMirror.ts: after the z commit resolves, the mirror
persists the lane move through the same machinery as a timeline lane drag
(optimistic store update, authoredTrack refresh, rollback); inserts reuse
commitTrackInsert's renumber via a shared buildTrackInsertEdits core. Both
writes share one coalesce key (zReorderCoalesceKey) and fold into ONE undo
entry (test proves it over the real history reducer). The mirror never
triggers the lane->z stacking sync, so it cannot fight the z values the
action just set.
- timelineZOverride.ts + TimelineClip badge: clips whose paint order
contradicts lane order among temporally-overlapping same-context visual
neighbors (laneIsAbove XOR paintsAbove, the stacking-sync predicates) show
a 'z' badge — authored z overrides are surfaced instead of silently
disagreeing with the timeline.
- Timeline.tsx track derivations extracted to useTimelineTrackDerivations
(600-line cap).
* fix(studio): fold mirrored z-order gestures into one undo entry across slow persists
Live verification caught the z write and the mirrored lane write splitting
into two undo entries: the mirror runs after the z persist's server round
trip, which exceeds editHistory's default 300ms coalesce window under real
latency (the unit test's deterministic clock sat inside it).
zReorderCoalesceKey now mints a per-gesture-unique key (monotonic seq, the
laneChangeGestureSeq precedent) and both records carry coalesceMs Infinity —
distinct gestures can never merge, and one gesture always folds regardless
of write latency. coalesceMs threaded through the persist chain alongside
coalesceKey. Also hardens the existing lane-drag move->z fold, which had the
same latent split. Fold test now simulates a 400ms gap (failed before the
fix, passes after); a two-separate-gestures test asserts two entries.
* feat(studio): flashless lane mirror, z-order menu icons, close-gap track menu
- Track-only batch moves (the z-mirror's lane hop and the insert renumber)
skip the GSAP fallback round-trip and the preview reload entirely — the
renderer never reads data-track-index, and the live DOM patch + optimistic
store update cover the UI. Mixed batches keep current behavior. Kills the
canvas blink on mirrored Bring/Send actions (live-verified: an
iframe-scoped marker survives the whole gesture).
- The four z-order menu items get 16px stroke icons (single layer diamond +
directional arrow for Forward/Backward; pierced two-layer stack for
Front/Back); labels unchanged — they are the industry-standard names.
- New track context menu on empty lane space: 'Close gap' (shifts the next
clip and every clip after it on that lane left by the clicked gap's width;
leading gaps count, so a single clip with empty space before it compacts
to 0) and 'Close all gaps' (whole lane contiguous from 0). Pure gap math
in timelineGaps.ts; persists through the drag path's atomic batch move
(one undo per action); refuses when a clip that must shift is locked;
items disable when there is nothing to close.
* fix(studio): rebind-only preview sync for unmutated timing edits, classical z-menu order
Timing edits that rewrote NO GSAP positions (gap closes and moves of
selector-addressed caption clips, zero-delta batches, comps without a
rewritable script) full-reloaded the preview — and the rerun-current-scripts
attempt was wrong for real compositions: re-executing init-style scripts
(three.js scenes, caption engines) is exactly the unsafe case, verified live
by doubled init warnings and a fallback reload anyway.
The correct observation: when mutated === false the existing __timelines are
still valid — only the runtime's clip visibility windows are stale, and the
live DOM timing attributes were already patched. So the no-mutation path now
runs applySoftReloadFinalization only (seek + __hfForceTimelineRebind +
manual-edits reapply), extracted from the soft-reload machinery — zero
script execution. This also un-blinks comps with no GSAP script at all,
which previously always remounted. Rewritten-script soft reloads,
cannot-soft-reload, otherFileChanged, and mutation failures keep their
existing behavior. gsapSoftReload's undo/redo restore section moved verbatim
to gsapUndoRestore.ts for the 600-line cap.
Also: z-order menu items reordered to the classical arrangement (Bring to
Front, Bring Forward, Send Backward, Send to Back).
Live-verified on a three.js-heavy composition: Close-all-gaps shifted 4
caption clips with correct cumulative amounts, the preview iframe was never
remounted (marker survived), and one undo reverted everything.
* fix(studio): bound forward/backward mirror to a one-element step
User-specified semantic: Bring Forward / Send Backward move the clip past
EXACTLY ONE element. The mirror's lane target is now bounded by the next
temporally-overlapping element beyond the crossed neighbor: a free lane
strictly between the two is taken (closest to the neighbor), and when they
are back-to-back a new track is inserted immediately beyond the crossed
element — never past the second one. Previously the resolver took the
closest free lane anywhere beyond the neighbor, which could carry the track
past a second element while the z action only stepped past one — a
track/paint contradiction our own zOverride badge would flag. Front/back
keep whole-set semantics (past everything; back stays above the audio
zone). End-to-end test pins the 3-stacked case through commitZMirrorLaneMove
to the persisted renumbered tracks.
* feat(studio): permanent gap-menu rows with hover and click-select gap highlights
- TrackGapContextMenu always renders both rows; an inapplicable action dims
with a tooltip ("No gap here" / lock reason / "No gaps on this track")
instead of vanishing into a one-item menu. Width badge only when a gap
exists under the pointer.
- Hovering an ACTIONABLE row highlights the strip(s) it would close in the
timeline: the single gap for Close gap, every current gap (leading included)
for Close all gaps. New resolveAllGapIntervals in timelineGaps.ts reports
present-state intervals (epsilon-tolerant, overlap-safe), distinct from
resolveAllTrackGaps' post-compaction starts.
- Click-selecting a single clip paints a quieter tint over its lane's gaps
(suppressed for marquee multi-selection and during drags; the gap-menu hover
wins on its own lane). Derivation lives in useTimelineGapHighlights with the
pure buildTimelineGapStrips exported and unit-tested.
- Strips render in TimelineCanvas with the drop-placeholder geometry (row top
+ clip inset), dashed accent for hover, faint tint for selection.
- Timeline.tsx stayed under the 600-line cap by extracting the scroll-viewport
plumbing (ResizeObserver width + shortcut-hint sync) into
useTimelineScrollViewport, behavior unchanged.
* feat(studio): stronger capcut-style timeline zoom steps
One button press / pinch gesture now moves the zoom meaningfully: step
factors 1.25x/0.8x -> 1.5x/(2/3) (kept reciprocal so in+out round-trips) and
pinch sensitivity 0.0035 -> 0.007. Addresses "zooming several times to get
anywhere" feedback; cursor anchoring unchanged.
* feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits
Completes the layers/canvas/timeline sync triangle: the Layers panel was the
one surface whose reorders never reached the timeline, and the one that went
stale when the other two wrote z flashlessly.
- Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses
the canvas menu's realization core via resolveZOrderReposition (one
between-z write when a strict gap exists, band-safe scoped renumber
otherwise) instead of computeReorderZValues' all-sibling stamp — that
helper is deleted, completing the #2347 unification follow-up. The drop
then mirrors into a timeline lane move through the same machinery as the
canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane
strictly between its NEW paint neighbors' lanes — nearest clip siblings in
the desired render order, decorations skipped — else a track insert at
that boundary; audio zone never crossed). Both writes share one
per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag
is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is
factored into useMirrorLaneMoveCommit and reused by the new
useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new
order-equality guard in resolveZOrderReposition).
- Panel staleness fix: flashless z commits (skipReload) reload nothing and
bump no refreshKey, so the panel's z-sorted order went stale while paused.
handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND
rollback; the panel re-collects on it. Verified live: the panel re-sorts
the instant a drag commits and again on undo.
- Layer click reveal (useLayerRevealOverride): clicking a layer that stays
hidden at the current frame (animation-parked opacity, non-clip
display/visibility hides, hidden ancestors) temporarily forces the chain
visible with live inline styles — exact priors restored on deselect, on
another reveal, on play, and on unmount; never persisted (file diff == 0
verified live). Clips keep the existing seek-into-window behavior; the
override applies on a short defer so a seek-revealed clip needs none.
- layerOrdering's unused hasExplicitZIndex probe (zero callers) removed.
Live-verified on a bed copy: a 2-position layers drag wrote exactly one
element (z 6->23 + data-track-index 15->2), the timeline lane moved without
a reload, and a single Cmd+Z restored the file byte-identically.
* feat(studio): full-track selection highlight, borderless gap hover strips
- Click-selecting a clip now lights the WHOLE lane minus its clips — leading
gap, inter-clip gaps, and the open space after the last clip to the rendered
end (new resolveLaneEmptyIntervals; displayDuration threaded into the strip
derivation). Still click-only: any drag/resize suppresses the strips, and a
marquee multi-select never shows them.
- The gap-menu hover strips drop the dashed border (user feedback) — fill only,
nudged to 0.18 alpha to keep the same visual weight.
* feat(studio): selected layer paints on top via a reader-transparent z lift
Clicking a layer in the Layers tab now shows the element as if it were at the
very top of the stack while selected — whatever its authored z or panel
position — extending the reveal override (which already forced hidden chains
visible) with a temporary inline z lift:
- liftElementToTop parks the TRUE effective z in data-hf-reveal-prior-z and
writes a far-top inline z; a static element gets a layout-preserving
position:relative with its prior parked in data-hf-reveal-prior-pos. Only
the RENDERER sees the lift: all three studio z readers
(readTimelineElementZIndex, getElementZIndex, readEffectiveZIndex) return
the parked prior while the attribute is present, so the canvas z-menu, the
zOverride badge, the lane mirror, the stacking sync, and the panel sort
keep reasoning on the element's real z.
- Strictly ephemeral: exact priors restored on deselect / another reveal /
play / unmount, each property only while it still holds the value the
override wrote (a later real edit is never clobbered). File diff == 0
verified live across a full lift/restore cycle.
- A z-reorder commit CONSUMES an active lift (handleDomZIndexReorderCommit
reads the parked position for its persist-position:relative static check,
then drops the attributes) — the committed z becomes the truth and the
later restore is a guarded no-op.
* fix(studio): flashless undo/redo — three full-reload causes in the soft-restore path
Cmd+Z blinked the canvas on essentially every undo. Three independent causes
in applyUndoRestoreToPreview, each sufficient on its own:
1. Master-view path gate: activeCompPath is NULL at the master view, so the
'paths[0] === activeCompPath' eligibility check could never match the
index.html restore and every default-view undo full-reloaded at the first
gate. Normalized to the codebase-wide 'activeCompPath ?? "index.html"'.
2. Nested identity innerHTML check: the diff compared each identified
element's innerHTML, but the composition root wraps every clip — any child
change re-detected at the root rejected the restore. Change detection now
compares only each element's OWN attribute surface; structure/text
integrity is still guaranteed by the normalize-residual whole-doc pass
(text nodes, added/removed elements, and un-identified attrs all remain
after normalization and force the full reload).
3. id-only identity: elements addressed by data-hf-id / selector (no DOM id)
fell outside the diff entirely. Identity is now id OR data-hf-id, with the
live sync resolving either.
Also stop re-running an UNCHANGED GSAP script: attribute-only restores (z,
lane, timing, style — the overwhelmingly common undo) now use the rebind-only
finalization (seek + __hfForceTimelineRebind + manual reapply, zero script
execution — the same path as flashless timing edits), instead of tearing down
and rebuilding live timelines or full-reloading when the script can't be
scoped. A restore whose script text genuinely changed still re-runs it via
applySoftReload, and structural restores (split/delete) still full-reload.
Live-verified on the bed (iframe marker): gap-close undo AND redo both keep
the iframe mounted, live DOM lands on the restored values, disk restored
byte-identically.
* feat(studio): left breathing pad before t=0, double zoom sensitivity again
TRACKS_LEFT_PAD (48px) — the horizontal sibling of TRACKS_TOP_PAD: empty lane
surface between the sticky gutter and the ruler's 00:00 / the first clips,
scrolling WITH the content.
- The lanes and the ruler realize it as a plain flow spacer between the
sticky gutter cell and the time-mapped content div, so every
content-relative computation (clip left = t*pps, beat lines, lane-menu
time, clip drag deltas) is untouched by construction.
- Canvas-space overlays shift by the pad: playhead (getTimelinePlayheadLeft),
gap strips, drop placeholder, snap guide, range highlight, marquee clip
rects, beat SVG; the insert line spans the pad.
- Every pointer->time inverse subtracts it symmetrically: seekFromX, razor,
range/marquee anchors, asset drops, and the zoom-anchor gutter basis; fit
pps and the display width account for the consumed viewport width.
- Live-verified: t=0 clip edge, the 00:00 tick, and the playhead line center
all sit at GUTTER + TRACKS_LEFT_PAD, and a ruler click lands the playhead
center exactly under the pointer.
Also doubles the timeline zoom sensitivity again (user feedback after
feel-testing the first bump): button steps 1.5x/(2/3) -> 2x/0.5, pinch
0.007 -> 0.014.
* fix(studio): left pad renders as true empty space, not lane surface
The pad before t=0 inherited each row's background and bottom border from the
row wrapper, so it read as track lanes. Lane visuals now live on the cells:
the sticky gutter keeps its own separator (header column stays delineated),
the time-mapped content div carries the row background + separator, and the
pad spacer stays transparent — bare shell background, no lines. The
new-track insertion line also starts at the pad's end instead of crossing it.
* fix(studio): no vertical line in the ruler band before 00:00
The ruler corner's right border drew the header-boundary line through the
ruler strip, so the band didn't read as starting at 00:00. Dropped it — the
boundary line belongs to the track rows below; the ruler stays completely
clean from the panel edge to the first tick, matching the empty left pad.
* refactor(studio): remove the timeline z-override badge
User decision: the "z" chip on clips never earned its place — dropped
entirely (timelineZOverride.ts + test deleted, TimelineClip badge rendering
and the zOverrideKeys derivation/threading removed). This also eliminates the
review's D2 finding at the root: the badge's cross-document comparison
(stackingContextId ?? null collides across source files in the expanded view)
produced false positives, and there is no longer a detector to mis-fire.
overlapsInTime/paintsAbove lose their export (the badge was their only
external consumer); the paint-order predicate itself is unchanged.
* fix(studio): collision-free expanded child lanes and host-window gap floors
Review findings D1 (blocker) and 4.
- D1: buildChildElements assigned expanded children synthetic display rows as
`host.track + index` — integers that can EQUAL a real clip's lane in another
file (host on 0 with two children puts child #2 on 1). Lane grouping merges
purely by track number, so the collision fused clips from different source
files into one display lane, and lane-scoped actions (the gap menu) then
batch-persisted a foreign file's clip. Children now take FRACTIONS strictly
between the host's lane and the next integer — structurally unable to
collide with any normalized lane, while still rendering as ordered rows
under the host. Regression test pins the reviewer's exact two-file scenario.
- Finding 4: gap math compacted toward absolute 0, but an expanded child's
display time is host-anchored — close/compact could drag it before its host
window and persist a wrong (even negative) local time. All gap functions
now take a lane FLOOR (laneGapFloor: 0 for ordinary lanes, the children's
expandedParentStart for child lanes — single-origin per lane post-D1),
threaded through the menu model, hover highlights, selected-lane strips,
and both commits. Close-gap shifts clamp at the gap's own left edge.
* fix(studio): scope mirror references, insert writes, and crossed-neighbor identity
Review findings 1, 2, and 3.
- Finding 1: buildTrackInsertEdits normalized the FULL display set and
persisted every shifted clip — writing host-lane numbers into OTHER
composition files when expanded children were showing. The renumber write
set is now the edited element's own source file (the sanctioned multi-write
converges one FILE to lane space, never neighbors' files); foreign clips
keep their authored tracks and re-derive display lanes. The locked-clip
refusal scopes the same way. Expanded-origin elements refuse the insert
outright (a new lane is a host-space renumber, meaningless in the child's
file), and the mirrors restrict an expanded child's lane candidates to its
own siblings' lanes — a sub-comp child still mirrors WITHIN its sub-comp
(persisting the sibling's authored track) but can never land on a host lane
with no same-file occupant. authoredTrackForLane's offset fallback rounds:
fractional synthetic rows must never leak fractions into data-track-index.
- Finding 2: the mirror comparison sets required only sameSourceFile, but a
file can contain several CSS stacking contexts and leaf z is only
comparable within one. Both resolvers now scope by samePaintScope — same
source file AND same stackingContextId (the file check also stops null root
contexts of different files from comparing equal in the expanded view).
- Finding 3: the crossed-neighbor key was derived without selectorIndex, so
duplicate class selectors (.sub) resolved to occurrence 0 — a different
clip. The key now carries getSelectorIndex, matching how z-reorder entries
derive theirs.
* fix(studio): z-to-lane gestures are one serialized transaction gated on durable persists
Review findings 5 and 7.
- Finding 5: commitDomEditPatchBatches resolved successfully even when the
server matched NO patch target — the z write never reached disk (the
preview reloads to reconverge) yet the lane mirror still ran, desyncing
track order from what actually paints. The commit now resolves a durability
report ({allMatched, changed}; the save queue and commit types are generic
over the result), and the mirror phase is skipped on allMatched === false.
- Finding 7: the z persist rides the DOM-edit save queue while the lane move
rides the timeline/SDK path — two queues, so a second rapid gesture's z
write could land BETWEEN the first gesture's z and lane phases. Every
z-to-lane gesture (canvas z-order menu AND Layers-panel drag) now runs
through runZLaneGesture: a single module-level tail that serializes the
COMPLETE two-phase transaction, with unit tests for ordering, the
durability gate, and queue resilience to failed gestures. The timeline
lane-drag's inverse (move-then-z-sync) shares its phases' await ordering
already; cross-gesture serialization for that path is noted as follow-up.
- LayersPanel's pure sort helpers moved to layersPanelSort.ts (600-line cap).
* fix(studio): multi-clip GSAP batch mutations roll back on late failure
Review finding 6. finishGroupTimingGsapFallback mutates files sequentially
per clip; a late per-clip failure left the earlier rewrites on disk with no
aggregate history entry — unreachable by undo. foldGsapMutationIntoHistory
already snapshots every touched path before mutating; on a mutation failure
it now restores each path whose disk content changed (all-or-nothing batch),
reports restore errors without masking the original failure, and rethrows.
Regression test drives a two-clip batch whose second rewrite fails and
asserts the first clip's write is restored byte-identically.
* fix(studio): scope mirror inserts to their lane zone
* fix(studio): unify source-scoped clip identity
* fix(studio): isolate track insert topology
* fix(studio): harden timeline paint synchronization
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
This commit is contained in:
co-authored by
Miguel Angel Simon Sierra
parent
d7204ac47f
commit
89db718899
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
|
||||
import { resolveZIndexEntries } from "../nle/PreviewOverlays";
|
||||
import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
|
||||
import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
|
||||
import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
|
||||
import { CanvasContextMenu } from "./CanvasContextMenu";
|
||||
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
||||
@@ -102,9 +103,26 @@ describe("CanvasContextMenu — handler gating", () => {
|
||||
|
||||
renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
|
||||
|
||||
expect(zOrderButtons()).toHaveLength(4);
|
||||
const buttons = zOrderButtons();
|
||||
expect(buttons).toHaveLength(4);
|
||||
expect(hasDeleteItem()).toBe(false);
|
||||
expect(document.body.querySelector(".border-t")).toBeNull();
|
||||
|
||||
// Labels stay the exact industry-standard names (the icons add no text)...
|
||||
expect(buttons.map((b) => b.textContent)).toEqual([
|
||||
"Bring to front",
|
||||
"Bring forward",
|
||||
"Send backward",
|
||||
"Send to back",
|
||||
]);
|
||||
// ...and each item leads with a stroke icon that inherits the item's text
|
||||
// color (currentColor), so the disabled muted tone applies to it too.
|
||||
for (const button of buttons) {
|
||||
const svg = button.firstElementChild;
|
||||
expect(svg?.tagName.toLowerCase()).toBe("svg");
|
||||
expect(svg?.getAttribute("stroke")).toBe("currentColor");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
|
||||
@@ -158,19 +176,14 @@ function renderCommitHook(captured: CapturedBatchCall[]) {
|
||||
type Commit = ReturnType<typeof useElementLifecycleOps>["handleDomZIndexReorderCommit"];
|
||||
let commit: Commit | undefined;
|
||||
function Harness() {
|
||||
({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps({
|
||||
activeCompPath: "index.html",
|
||||
showToast: vi.fn(),
|
||||
writeProjectFile: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
editHistory: { recordEdit: vi.fn(async () => {}) },
|
||||
projectIdRef: { current: null },
|
||||
reloadPreview: vi.fn(),
|
||||
clearDomSelection: vi.fn(),
|
||||
commitDomEditPatchBatches: async (batches, options) => {
|
||||
captured.push({ batches, options });
|
||||
},
|
||||
}));
|
||||
({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
|
||||
makeLifecycleOpsParams({
|
||||
commitDomEditPatchBatches: async (batches, options) => {
|
||||
captured.push({ batches, options });
|
||||
return { durable: true, allMatched: true, changed: true };
|
||||
},
|
||||
}),
|
||||
));
|
||||
return null;
|
||||
}
|
||||
const hookHost = document.createElement("div");
|
||||
|
||||
@@ -50,8 +50,17 @@ interface CanvasContextMenuProps {
|
||||
* affected siblings). The menu does NOT touch the live DOM — wire to
|
||||
* handleDomZIndexReorderCommit, which applies the live styles itself
|
||||
* (see module-level wiring comment).
|
||||
*
|
||||
* `crossed` is the sibling a forward/backward step moved past, resolved from
|
||||
* the SAME pre-mutation render order as the patches (null for front/back or
|
||||
* when there is no neighbor). The host uses it to mirror the z action into a
|
||||
* timeline lane move (resolveZMirrorLaneMove's crossedKey).
|
||||
*/
|
||||
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
|
||||
onApplyZIndex?: (
|
||||
patches: ZOrderPatch[],
|
||||
action: ZOrderAction,
|
||||
crossed: HTMLElement | null,
|
||||
) => void;
|
||||
/**
|
||||
* Called after a successful bring-forward / send-backward with the sibling
|
||||
* the target stepped over (resolved from the SAME pre-mutation state as the
|
||||
@@ -70,10 +79,61 @@ interface CanvasContextMenuProps {
|
||||
|
||||
type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
|
||||
|
||||
// Stacked-layer + arrow glyphs, one per z action (16px, stroke, currentColor —
|
||||
// matches the studio's inline-SVG conventions: fill="none", 1.2 stroke, round
|
||||
// caps/joins). Single actions show ONE layer diamond with the arrow stepping
|
||||
// one way; front/back show a TWO-diamond stack with the arrow piercing through
|
||||
// and beyond it. `paths` are the d attributes, drawn in order.
|
||||
const Z_ACTION_ICONS: Record<ZAction, string[]> = {
|
||||
"bring-forward": [
|
||||
"M3 11 L8 8.5 L13 11 L8 13.5 Z", // layer diamond (bottom)
|
||||
"M8 8.5 L8 2", // arrow shaft up
|
||||
"M5.5 4.5 L8 2 L10.5 4.5", // arrow head
|
||||
],
|
||||
"send-backward": [
|
||||
"M3 5 L8 2.5 L13 5 L8 7.5 Z", // layer diamond (top)
|
||||
"M8 7.5 L8 14", // arrow shaft down
|
||||
"M5.5 11.5 L8 14 L10.5 11.5", // arrow head
|
||||
],
|
||||
"bring-to-front": [
|
||||
"M3 9.5 L8 7 L13 9.5 L8 12 Z", // upper layer of the stack
|
||||
"M3 12.5 L8 10 L13 12.5 L8 15 Z", // lower layer of the stack
|
||||
"M8 12.5 L8 2", // arrow piercing up through/above the stack
|
||||
"M5.5 4.5 L8 2 L10.5 4.5", // arrow head
|
||||
],
|
||||
"send-to-back": [
|
||||
"M3 4 L8 1.5 L13 4 L8 6.5 Z", // upper layer of the stack
|
||||
"M3 7 L8 4.5 L13 7 L8 9.5 Z", // lower layer of the stack
|
||||
"M8 3.5 L8 14", // arrow piercing down through/below the stack
|
||||
"M5.5 11.5 L8 14 L10.5 11.5", // arrow head
|
||||
],
|
||||
};
|
||||
|
||||
function ZActionIcon({ action }: { action: ZAction }) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Z_ACTION_ICONS[action].map((d) => (
|
||||
<path key={d} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
|
||||
{ action: "bring-to-front", label: "Bring to front" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
@@ -113,14 +173,16 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
||||
const patches = resolveZOrderChange(el, action);
|
||||
if (patches === null) return;
|
||||
// Resolve the crossed neighbor BEFORE the commit path mutates live styles —
|
||||
// both resolvers must read the same pre-change render order.
|
||||
const crossed = onZOrderCrossed ? resolveCrossedNeighbor(el, action) : null;
|
||||
// both resolvers must read the same pre-change render order. Always resolved
|
||||
// (not only for the flash): onApplyZIndex forwards it so the host can mirror
|
||||
// the z step into a timeline lane move.
|
||||
const crossed = resolveCrossedNeighbor(el, action);
|
||||
// Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
|
||||
// live z-index (and injects position:relative for static elements) in the
|
||||
// same synchronous flow, so feedback is still instant — and it must read
|
||||
// the PRE-change styles itself, both to capture true rollback values and
|
||||
// to detect a static position that needs persisting.
|
||||
onApplyZIndex(patches, action);
|
||||
onApplyZIndex(patches, action, crossed);
|
||||
if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action);
|
||||
onClose();
|
||||
}
|
||||
@@ -185,7 +247,10 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
||||
if (enabled) handleZAction(action);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{/* Icon inherits the item's text color via currentColor, so the
|
||||
disabled muted tone applies to both icon and label. */}
|
||||
<ZActionIcon action={action} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -103,6 +103,9 @@ interface DomEditOverlayProps {
|
||||
selection: DomEditSelection,
|
||||
patches: ZOrderPatch[],
|
||||
action: ZOrderAction,
|
||||
/** Sibling a forward/backward step moved past (pre-mutation render order);
|
||||
* null for front/back. Feeds the timeline z-mirror's crossedKey. */
|
||||
crossed: HTMLElement | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -529,8 +532,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
}
|
||||
onApplyZIndex={
|
||||
onApplyZIndex
|
||||
? (patches, action) => {
|
||||
onApplyZIndex(contextMenu.sel, patches, action);
|
||||
? (patches, action, crossed) => {
|
||||
onApplyZIndex(contextMenu.sel, patches, action, crossed);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -14,8 +14,14 @@ import {
|
||||
} from "../../utils/studioHelpers";
|
||||
import { Layers } from "../../icons/SystemIcons";
|
||||
import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
|
||||
import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering";
|
||||
import { getVisibleLayers, sortLayersByZIndex } from "./layersPanelSort";
|
||||
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
||||
import { resolveZOrderReposition } from "./canvasContextMenuZOrder";
|
||||
import { buildStableSelector } from "./domEditingDom";
|
||||
import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
|
||||
import { useLayerReorderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
|
||||
import { runZLaneGesture } from "../nle/zLaneGesture";
|
||||
import { useLayerRevealOverride } from "./useLayerRevealOverride";
|
||||
|
||||
const TAG_ICONS: Record<string, string> = {
|
||||
video: "Vi",
|
||||
@@ -85,8 +91,13 @@ interface CollapsedState {
|
||||
// fallow-ignore-next-line complexity
|
||||
export const LayersPanel = memo(function LayersPanel() {
|
||||
const { previewIframeRef, activeCompPath, showToast } = useStudioShellContext();
|
||||
const { refreshKey, compositionLoading, timelineElements } = useStudioPlaybackContext();
|
||||
const { refreshKey, compositionLoading, timelineElements, isPlaying } =
|
||||
useStudioPlaybackContext();
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
// Flashless z commits (canvas menu, timeline lane-drag z-sync) mutate iframe
|
||||
// z-indexes with no reload and no refreshKey bump — while paused, nothing
|
||||
// else re-collects, so the panel's z-sorted order would go stale.
|
||||
const zEditVersion = usePlayerStore((s) => s.zEditVersion);
|
||||
const {
|
||||
domEditSelection,
|
||||
activeGroupElement,
|
||||
@@ -100,6 +111,11 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
const [collapsed, setCollapsed] = useState<CollapsedState>({});
|
||||
const prevDocVersionRef = useRef(0);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mirrorLayerReorderToTimeline = useLayerReorderTimelineMirror();
|
||||
const { scheduleReveal } = useLayerRevealOverride({
|
||||
isPlaying,
|
||||
selectedElement: domEditSelection?.element ?? null,
|
||||
});
|
||||
|
||||
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
||||
|
||||
@@ -131,7 +147,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
collectLayers();
|
||||
}, [collectLayers, refreshKey]);
|
||||
}, [collectLayers, refreshKey, zEditVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = previewIframeRef.current;
|
||||
@@ -230,8 +246,13 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
if (!selection) return;
|
||||
applyDomSelection(selection);
|
||||
await seekToLayer(layer);
|
||||
// Force-reveal AFTER the seek's runtime visibility sync has had a beat:
|
||||
// a clip made active by the seek shows naturally and needs no override,
|
||||
// so the reveal only touches nodes that REMAIN hidden (animation-parked
|
||||
// opacity, non-clip display/visibility hides, hidden ancestors).
|
||||
scheduleReveal(selection.element, 150);
|
||||
},
|
||||
[resolveSelection, applyDomSelection, seekToLayer],
|
||||
[resolveSelection, applyDomSelection, seekToLayer, scheduleReveal],
|
||||
);
|
||||
|
||||
// Double-click a group row → drill into it; any other row → select it.
|
||||
@@ -264,6 +285,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleReorder = useCallback(
|
||||
(event: LayerReorderEvent) => {
|
||||
const { siblingLayers, fromIndex, toIndex } = event;
|
||||
@@ -271,29 +293,88 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
|
||||
const existingValues = siblingLayers.map((l) => getElementZIndex(l.element));
|
||||
const zValues = computeReorderZValues(existingValues, fromIndex, toIndex);
|
||||
// Panel order is top-first (sortLayersByZIndex: z desc, later-DOM-first),
|
||||
// so the desired RENDER order (bottom→top) is the reverse. The minimal
|
||||
// resolver (shared with the canvas z-menu) then writes one between-z
|
||||
// value when a strict gap exists, band-safe renumber otherwise — instead
|
||||
// of the old computeReorderZValues stamp of every sibling.
|
||||
const desiredBottomToTop = [...reordered].reverse();
|
||||
const patches = resolveZOrderReposition(
|
||||
moved.element,
|
||||
desiredBottomToTop.map((l) => l.element),
|
||||
);
|
||||
if (!patches || patches.length === 0) return; // paint order unchanged
|
||||
|
||||
const entries = reordered.map((layer, i) => ({
|
||||
element: layer.element,
|
||||
zIndex: zValues[i],
|
||||
id: layer.id,
|
||||
selector: layer.selector,
|
||||
selectorIndex: layer.selectorIndex,
|
||||
sourceFile: layer.sourceFile,
|
||||
key: deriveTimelineStoreKey({
|
||||
domId: layer.id,
|
||||
selector: layer.selector,
|
||||
selectorIndex: layer.selectorIndex,
|
||||
sourceFile: layer.sourceFile,
|
||||
}),
|
||||
}));
|
||||
const layerByElement = new Map(siblingLayers.map((l) => [l.element, l]));
|
||||
const entries: Array<{
|
||||
element: HTMLElement;
|
||||
zIndex: number;
|
||||
id?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile: string;
|
||||
key?: string;
|
||||
}> = [];
|
||||
for (const patch of patches) {
|
||||
// The renumber fallback can patch a painting sibling the panel didn't
|
||||
// list (non-collected family member): derive its identity from the DOM
|
||||
// node, exactly like the canvas menu's siblingZIndexEntry. Un-targetable
|
||||
// nodes get a live-only style write (reverts on reload).
|
||||
const layer = layerByElement.get(patch.element);
|
||||
const id = layer?.id ?? (patch.element.id || undefined);
|
||||
const selector = layer?.selector ?? buildStableSelector(patch.element);
|
||||
if (!id && !selector) {
|
||||
patch.element.style.zIndex = String(patch.zIndex);
|
||||
continue;
|
||||
}
|
||||
const sourceFile = layer?.sourceFile ?? moved.sourceFile;
|
||||
entries.push({
|
||||
element: patch.element,
|
||||
zIndex: patch.zIndex,
|
||||
id,
|
||||
selector,
|
||||
selectorIndex: layer?.selectorIndex,
|
||||
sourceFile,
|
||||
key: deriveTimelineStoreKey({
|
||||
domId: id,
|
||||
selector,
|
||||
selectorIndex: layer?.selectorIndex,
|
||||
sourceFile,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (entries.length === 0) return;
|
||||
|
||||
// "layer-drag" keeps consecutive drops of the same sibling set coalescing
|
||||
// into one undo step, without merging with a context-menu z action.
|
||||
handleDomZIndexReorderCommit(entries, undefined, "layer-drag");
|
||||
// ONE undo entry for the whole gesture: the z persist and the timeline
|
||||
// lane mirror below share this per-gesture-unique key (same contract as
|
||||
// the canvas menu's wiring in PreviewOverlays).
|
||||
const coalesceKey = zReorderCoalesceKey(entries, "layer-drag");
|
||||
const desiredOrderKeys = desiredBottomToTop.map(
|
||||
(l) =>
|
||||
deriveTimelineStoreKey({
|
||||
domId: l.id,
|
||||
selector: l.selector,
|
||||
selectorIndex: l.selectorIndex,
|
||||
sourceFile: l.sourceFile,
|
||||
}) ?? null,
|
||||
);
|
||||
const movedKey = deriveTimelineStoreKey({
|
||||
domId: moved.id,
|
||||
selector: moved.selector,
|
||||
selectorIndex: moved.selectorIndex,
|
||||
sourceFile: moved.sourceFile,
|
||||
});
|
||||
// One serialized z→lane transaction (shared queue with the canvas
|
||||
// z-order menu): the mirror runs only after a DURABLE z persist, and
|
||||
// rapid successive gestures cannot interleave phases — see
|
||||
// runZLaneGesture.
|
||||
runZLaneGesture({
|
||||
commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, "layer-drag"),
|
||||
mirror: () =>
|
||||
mirrorLayerReorderToTimeline({ selectionKey: movedKey, desiredOrderKeys, coalesceKey }),
|
||||
}).catch(() => undefined);
|
||||
},
|
||||
[handleDomZIndexReorderCommit],
|
||||
[handleDomZIndexReorderCommit, mirrorLayerReorderToTimeline],
|
||||
);
|
||||
|
||||
const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;
|
||||
@@ -436,76 +517,6 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
);
|
||||
});
|
||||
|
||||
// ── Pure helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] {
|
||||
if (layers.length <= 1) return layers;
|
||||
|
||||
const minDepth = layers[0].depth;
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
if (layers[i].depth < minDepth) return layers;
|
||||
}
|
||||
|
||||
const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> =
|
||||
[];
|
||||
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].depth === minDepth) {
|
||||
const children: DomEditLayerItem[] = [];
|
||||
let j = i + 1;
|
||||
while (j < layers.length && layers[j].depth > minDepth) {
|
||||
children.push(layers[j]);
|
||||
j++;
|
||||
}
|
||||
chunks.push({ root: layers[i], children, domIndex: chunks.length });
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length <= 1) {
|
||||
if (chunks.length === 1 && chunks[0].children.length > 0) {
|
||||
const sorted = sortLayersByZIndex(chunks[0].children);
|
||||
return [chunks[0].root, ...sorted];
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
chunks.sort((a, b) => {
|
||||
const zA = getElementZIndex(a.root.element);
|
||||
const zB = getElementZIndex(b.root.element);
|
||||
if (zA !== zB) return zB - zA;
|
||||
return b.domIndex - a.domIndex;
|
||||
});
|
||||
|
||||
const result: DomEditLayerItem[] = [];
|
||||
for (const chunk of chunks) {
|
||||
result.push(chunk.root);
|
||||
if (chunk.children.length > 0) {
|
||||
result.push(...sortLayersByZIndex(chunk.children));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getVisibleLayers(
|
||||
layers: DomEditLayerItem[],
|
||||
collapsed: CollapsedState,
|
||||
): DomEditLayerItem[] {
|
||||
if (Object.keys(collapsed).length === 0) return layers;
|
||||
|
||||
const result: DomEditLayerItem[] = [];
|
||||
let skipDepth = -1;
|
||||
|
||||
for (const layer of layers) {
|
||||
if (skipDepth >= 0 && layer.depth > skipDepth) continue;
|
||||
skipDepth = -1;
|
||||
|
||||
result.push(layer);
|
||||
|
||||
if (collapsed[layer.key] && layer.childCount > 0) {
|
||||
skipDepth = layer.depth;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
// The sort helper lives in layersPanelSort.ts (600-line studio cap);
|
||||
// re-exported so existing imports from "./LayersPanel" still resolve.
|
||||
export { sortLayersByZIndex } from "./layersPanelSort";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseZIndex,
|
||||
resolveCrossedNeighbor,
|
||||
resolveZOrderChange,
|
||||
resolveZOrderReposition,
|
||||
type ZOrderAction,
|
||||
type ZOrderPatch,
|
||||
} from "./canvasContextMenuZOrder";
|
||||
@@ -617,3 +618,59 @@ describe("isZOrderActionEnabled", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveZOrderReposition (Layers-panel arbitrary drop)", () => {
|
||||
it("multi-position jump with distinct z resolves to ONE between-z write", () => {
|
||||
// Render order bottom→top today: target(1), a(3), b(5). Drop target between
|
||||
// a and b → single write: z strictly between 3 and 5.
|
||||
const { target, byId } = makeFamily("1", [
|
||||
["a", "3"],
|
||||
["b", "5"],
|
||||
]);
|
||||
const patches = resolveZOrderReposition(target, [byId.a, target, byId.b]);
|
||||
expect(patches).toEqual([{ element: target, zIndex: 4 }]);
|
||||
});
|
||||
|
||||
it("jump to the very top writes one z above the previous top", () => {
|
||||
const { target, byId } = makeFamily("1", [
|
||||
["a", "3"],
|
||||
["b", "5"],
|
||||
]);
|
||||
const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
|
||||
expect(patches).toEqual([{ element: target, zIndex: 6 }]);
|
||||
});
|
||||
|
||||
it("no-op drop (unchanged order) returns null", () => {
|
||||
const { target, byId } = makeFamily("1", [
|
||||
["a", "3"],
|
||||
["b", "5"],
|
||||
]);
|
||||
expect(resolveZOrderReposition(target, [target, byId.a, byId.b])).toBeNull();
|
||||
});
|
||||
|
||||
it("tied z values renumber the scoped set minimally (band-safe)", () => {
|
||||
const { target, byId } = makeFamily("2", [
|
||||
["a", "2"],
|
||||
["b", "2"],
|
||||
]);
|
||||
// All tied at 2; DOM order target,a,b → render bottom→top target,a,b.
|
||||
// Move target to the top: scoped renumber within the band.
|
||||
const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
|
||||
expect(patches).not.toBeNull();
|
||||
const z = new Map(patches!.map((p) => [(p.element as HTMLElement).id, p.zIndex]));
|
||||
const zOf = (id: string) => z.get(id) ?? 2;
|
||||
expect(zOf("a")).toBeLessThan(zOf("b"));
|
||||
expect(zOf("b")).toBeLessThan(zOf("target"));
|
||||
});
|
||||
|
||||
it("rejects elements that are not painting siblings of the target", () => {
|
||||
const { target, byId } = makeFamily("1", [["a", "3"]]);
|
||||
const stranger = makeEl("stranger", "2");
|
||||
expect(resolveZOrderReposition(target, [stranger, target, byId.a])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for sets too small to reorder", () => {
|
||||
const { target } = makeFamily("1", []);
|
||||
expect(resolveZOrderReposition(target, [target])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
*/
|
||||
|
||||
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
||||
import { readLayerRevealPriorZ } from "../../player/lib/timelineElementHelpers";
|
||||
|
||||
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
|
||||
|
||||
@@ -107,8 +108,11 @@ export function parseZIndex(value: string | null | undefined): number {
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** Read the effective z-index for an element (inline style preferred). */
|
||||
/** Read the effective z-index for an element (inline style preferred).
|
||||
* Reveal-lift transparent: an active Layers-panel lift reports the TRUE z. */
|
||||
export function readEffectiveZIndex(el: HTMLElement): number {
|
||||
const prior = readLayerRevealPriorZ(el);
|
||||
if (prior != null) return prior;
|
||||
const inline = el.style.zIndex;
|
||||
if (inline && inline !== "auto") return parseZIndex(inline);
|
||||
try {
|
||||
@@ -439,6 +443,41 @@ export function resolveZOrderChange(
|
||||
return realizeOrder(order, desired, target, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Realize an ARBITRARY repositioning of `target` within a scoped sibling set —
|
||||
* the Layers-panel drag, which can jump several siblings in one drop, unlike
|
||||
* the menu's four fixed actions. `desiredOrderBottomToTop` is the scoped set
|
||||
* (target included at its new slot) in the intended render order. Reuses the
|
||||
* menu's minimal-write realization (realizeOrder): one between-z write when a
|
||||
* strict gap exists, band-safe scoped renumber otherwise — replacing the old
|
||||
* LayersPanel computeReorderZValues path that stamped EVERY sibling.
|
||||
*
|
||||
* Null when nothing changes, the set is too small, or an element in the
|
||||
* desired order is not actually a painting sibling of `target`.
|
||||
*/
|
||||
export function resolveZOrderReposition(
|
||||
target: HTMLElement,
|
||||
desiredOrderBottomToTop: readonly HTMLElement[],
|
||||
): ZOrderPatch[] | null {
|
||||
const { entries } = getFamily(target);
|
||||
if (entries.length < 2) return null;
|
||||
const byElement = new Map(entries.map((entry) => [entry.element, entry]));
|
||||
const desired: RenderEntry[] = [];
|
||||
for (const el of desiredOrderBottomToTop) {
|
||||
const entry = byElement.get(el);
|
||||
if (!entry) return null;
|
||||
desired.push(entry);
|
||||
}
|
||||
if (desired.length < 2 || !desired.some((entry) => entry.element === target)) return null;
|
||||
const currentOrder = toRenderOrder(desired);
|
||||
// A drop back into the same slot is a no-op. The menu actions guard this via
|
||||
// their position checks before realizeOrder; an arbitrary reposition must
|
||||
// compare the orders itself — realizeOrder would otherwise "normalize" an
|
||||
// end-of-set target to a fresh z value it doesn't need.
|
||||
if (currentOrder.every((entry, i) => entry.element === desired[i].element)) return null;
|
||||
return realizeOrder(currentOrder, desired, target, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sibling a forward/backward step crosses: the visible overlapping
|
||||
* neighbor directly above (bring-forward) or below (send-backward) the target
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* No imports from other domEditing* modules — safe to import from anywhere.
|
||||
*/
|
||||
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
||||
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
||||
import { CURATED_STYLE_PROPERTIES } from "./domEditingTypes";
|
||||
|
||||
// ─── Type guard ───────────────────────────────────────────────────────────────
|
||||
@@ -296,11 +297,9 @@ export function getSelectorIndex(
|
||||
): number | undefined {
|
||||
if (!selector?.startsWith(".")) return undefined;
|
||||
|
||||
const candidates = querySelectorAllSafely(doc, selector).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
isHtmlElement(candidate) &&
|
||||
getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile,
|
||||
return getSourceScopedSelectorIndex(doc, el, selector, sourceFile, (candidate) =>
|
||||
isHtmlElement(candidate)
|
||||
? getSourceFileForElement(candidate, activeCompositionPath).sourceFile
|
||||
: undefined,
|
||||
);
|
||||
const index = candidates.indexOf(el);
|
||||
return index >= 0 ? index : undefined;
|
||||
}
|
||||
|
||||
@@ -246,45 +246,28 @@ export function findElementForSelection(
|
||||
selection: FindElementSelection,
|
||||
activeCompositionPath: string | null = null,
|
||||
): HTMLElement | null {
|
||||
const sourceMatches = (candidate: Element): candidate is HTMLElement =>
|
||||
isHtmlElement(candidate) &&
|
||||
(!selection.sourceFile ||
|
||||
getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
|
||||
selection.sourceFile);
|
||||
const findAll = (selector: string): HTMLElement[] =>
|
||||
querySelectorAllSafely(doc, selector).filter(sourceMatches);
|
||||
|
||||
if (selection.hfId) {
|
||||
const byHfId = doc.querySelector(`[data-hf-id="${CSS.escape(selection.hfId)}"]`);
|
||||
if (isHtmlElement(byHfId)) return byHfId;
|
||||
const byHfId = findAll(`[data-hf-id="${escapeCssString(selection.hfId)}"]`)[0];
|
||||
if (byHfId) return byHfId;
|
||||
}
|
||||
|
||||
if (selection.id) {
|
||||
const byId = doc.getElementById(selection.id);
|
||||
if (
|
||||
isHtmlElement(byId) &&
|
||||
(!selection.sourceFile ||
|
||||
getSourceFileForElement(byId, activeCompositionPath).sourceFile === selection.sourceFile)
|
||||
) {
|
||||
return byId;
|
||||
}
|
||||
// Flattened sub-compositions can repeat authored ids. getElementById returns
|
||||
// only the first document match, so filter every id match by source first.
|
||||
const byId = findAll(`[id="${escapeCssString(selection.id)}"]`)[0];
|
||||
if (byId) return byId;
|
||||
}
|
||||
|
||||
if (!selection.selector) return null;
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
|
||||
const matches = querySelectorAllSafely(doc, selection.selector).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
isHtmlElement(candidate) &&
|
||||
(!selection.sourceFile ||
|
||||
getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
|
||||
selection.sourceFile),
|
||||
);
|
||||
return matches[selection.selectorIndex] ?? null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const matches = querySelectorAllSafely(doc, selection.selector).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
isHtmlElement(candidate) &&
|
||||
(!selection.sourceFile ||
|
||||
getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
|
||||
selection.sourceFile),
|
||||
);
|
||||
return matches[0] ?? null;
|
||||
return findAll(selection.selector)[selection.selectorIndex ?? 0] ?? null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { DomEditLayerItem } from "./domEditingTypes";
|
||||
import { getElementZIndex } from "../../player/lib/layerOrdering";
|
||||
|
||||
interface CollapsedState {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
// ── Pure helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] {
|
||||
if (layers.length <= 1) return layers;
|
||||
|
||||
const minDepth = layers[0].depth;
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
if (layers[i].depth < minDepth) return layers;
|
||||
}
|
||||
|
||||
const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> =
|
||||
[];
|
||||
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].depth === minDepth) {
|
||||
const children: DomEditLayerItem[] = [];
|
||||
let j = i + 1;
|
||||
while (j < layers.length && layers[j].depth > minDepth) {
|
||||
children.push(layers[j]);
|
||||
j++;
|
||||
}
|
||||
chunks.push({ root: layers[i], children, domIndex: chunks.length });
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length <= 1) {
|
||||
if (chunks.length === 1 && chunks[0].children.length > 0) {
|
||||
const sorted = sortLayersByZIndex(chunks[0].children);
|
||||
return [chunks[0].root, ...sorted];
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
chunks.sort((a, b) => {
|
||||
const zA = getElementZIndex(a.root.element);
|
||||
const zB = getElementZIndex(b.root.element);
|
||||
if (zA !== zB) return zB - zA;
|
||||
return b.domIndex - a.domIndex;
|
||||
});
|
||||
|
||||
const result: DomEditLayerItem[] = [];
|
||||
for (const chunk of chunks) {
|
||||
result.push(chunk.root);
|
||||
if (chunk.children.length > 0) {
|
||||
result.push(...sortLayersByZIndex(chunk.children));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getVisibleLayers(
|
||||
layers: DomEditLayerItem[],
|
||||
collapsed: CollapsedState,
|
||||
): DomEditLayerItem[] {
|
||||
if (Object.keys(collapsed).length === 0) return layers;
|
||||
|
||||
const result: DomEditLayerItem[] = [];
|
||||
let skipDepth = -1;
|
||||
|
||||
for (const layer of layers) {
|
||||
if (skipDepth >= 0 && layer.depth > skipDepth) continue;
|
||||
skipDepth = -1;
|
||||
|
||||
result.push(layer);
|
||||
|
||||
if (collapsed[layer.key] && layer.childCount > 0) {
|
||||
skipDepth = layer.depth;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
LAYER_REVEAL_LIFT_Z,
|
||||
LAYER_REVEAL_PENDING_COMMIT_ATTR,
|
||||
beginLayerRevealCommit,
|
||||
completeLayerRevealCommit,
|
||||
liftElementToTop,
|
||||
restoreLiftedElement,
|
||||
useLayerRevealOverride,
|
||||
} from "./useLayerRevealOverride";
|
||||
import { readEffectiveZIndex } from "./canvasContextMenuZOrder";
|
||||
import { getElementZIndex } from "../../player/lib/layerOrdering";
|
||||
import {
|
||||
LAYER_REVEAL_PRIOR_Z_ATTR,
|
||||
readTimelineElementZIndex,
|
||||
} from "../../player/lib/timelineElementHelpers";
|
||||
import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
|
||||
|
||||
installReactActEnvironment();
|
||||
|
||||
function makeEl(zIndex?: string, position?: string): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
if (zIndex != null) el.style.zIndex = zIndex;
|
||||
if (position != null) el.style.position = position;
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("liftElementToTop / restoreLiftedElement", () => {
|
||||
it("paints on top but every z reader keeps reporting the TRUE z", () => {
|
||||
const el = makeEl("6", "absolute");
|
||||
const lift = liftElementToTop(el);
|
||||
expect(lift).not.toBeNull();
|
||||
// The renderer sees the lifted value…
|
||||
expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
|
||||
// …every studio reader sees the true z.
|
||||
expect(readEffectiveZIndex(el)).toBe(6);
|
||||
expect(getElementZIndex(el)).toBe(6);
|
||||
expect(readTimelineElementZIndex(el)).toBe(6);
|
||||
|
||||
restoreLiftedElement(el, lift!);
|
||||
expect(el.style.zIndex).toBe("6");
|
||||
expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
|
||||
});
|
||||
|
||||
it("gives a static element a temporary position:relative and restores it", () => {
|
||||
const el = makeEl();
|
||||
const lift = liftElementToTop(el)!;
|
||||
expect(el.style.position).toBe("relative");
|
||||
expect(lift.positionLifted).toBe(true);
|
||||
restoreLiftedElement(el, lift);
|
||||
expect(el.style.position).toBe("");
|
||||
expect(el.style.zIndex).toBe("");
|
||||
});
|
||||
|
||||
it("a z-reorder commit consumes the lift: restore becomes a no-op", () => {
|
||||
const el = makeEl("3", "absolute");
|
||||
const lift = liftElementToTop(el)!;
|
||||
// Simulate handleDomZIndexReorderCommit: real z written, attrs removed.
|
||||
el.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
|
||||
el.style.zIndex = "8";
|
||||
restoreLiftedElement(el, lift);
|
||||
expect(el.style.zIndex).toBe("8"); // the commit's value survives
|
||||
expect(readEffectiveZIndex(el)).toBe(8);
|
||||
});
|
||||
|
||||
it("durable z persistence consumes its pending reveal ownership", () => {
|
||||
const el = makeEl("3", "absolute");
|
||||
const lift = liftElementToTop(el)!;
|
||||
const ownership = beginLayerRevealCommit(el)!;
|
||||
el.style.zIndex = "8";
|
||||
|
||||
expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(true);
|
||||
completeLayerRevealCommit(el, ownership);
|
||||
restoreLiftedElement(el, lift);
|
||||
|
||||
expect(el.style.zIndex).toBe("8");
|
||||
expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
|
||||
expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not clobber a z someone else wrote while lifted", () => {
|
||||
const el = makeEl("3", "absolute");
|
||||
const lift = liftElementToTop(el)!;
|
||||
el.style.zIndex = "42"; // e.g. a GSAP seek or manual edit
|
||||
restoreLiftedElement(el, lift);
|
||||
expect(el.style.zIndex).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLayerRevealOverride — delayed reveal ownership", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("cancels a pending reveal when selection changes or playback begins", () => {
|
||||
const host = document.createElement("div");
|
||||
const selected = makeEl("2", "absolute");
|
||||
const other = makeEl("3", "absolute");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
|
||||
|
||||
function Harness({ element, isPlaying }: { element: HTMLElement; isPlaying: boolean }) {
|
||||
({ scheduleReveal } = useLayerRevealOverride({
|
||||
isPlaying,
|
||||
selectedElement: element,
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(Harness, { element: selected, isPlaying: false }));
|
||||
});
|
||||
act(() => scheduleReveal!(selected, 150));
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(Harness, { element: other, isPlaying: false }));
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
act(() => scheduleReveal!(other, 150));
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
act(() => {
|
||||
root.render(React.createElement(Harness, { element: other, isPlaying: true }));
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
act(() => vi.advanceTimersByTime(150));
|
||||
expect(selected.style.zIndex).toBe("2");
|
||||
expect(other.style.zIndex).toBe("3");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("revalidates the current selection before a delayed reveal runs", () => {
|
||||
const host = document.createElement("div");
|
||||
const selected = makeEl("2", "absolute");
|
||||
const stale = makeEl("3", "absolute");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
|
||||
|
||||
function Harness() {
|
||||
({ scheduleReveal } = useLayerRevealOverride({
|
||||
isPlaying: false,
|
||||
selectedElement: selected,
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => root.render(React.createElement(Harness)));
|
||||
act(() => scheduleReveal!(stale, 150));
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
act(() => vi.advanceTimersByTime(150));
|
||||
|
||||
expect(stale.style.zIndex).toBe("3");
|
||||
expect(stale.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
LAYER_REVEAL_PRIOR_POSITION_ATTR,
|
||||
LAYER_REVEAL_PRIOR_Z_ATTR,
|
||||
} from "../../player/lib/timelineElementHelpers";
|
||||
import { readEffectiveZIndex } from "./canvasContextMenuZOrder";
|
||||
|
||||
/** The lifted paint order — far above any authored z. Only the RENDERER sees
|
||||
* it: every studio z reader is reveal-transparent (readLayerRevealPriorZ). */
|
||||
export const LAYER_REVEAL_LIFT_Z = "2147483000";
|
||||
export const LAYER_REVEAL_PENDING_COMMIT_ATTR = "data-hf-studio-reveal-pending-commit";
|
||||
|
||||
interface RevealedNode {
|
||||
element: HTMLElement;
|
||||
priors: { display: string; visibility: string; opacity: string };
|
||||
/** Values THIS override wrote — restore only while they are still in place. */
|
||||
applied: { display?: string; visibility?: string; opacity?: string };
|
||||
}
|
||||
|
||||
interface RevealLift {
|
||||
priors: { zIndex: string; position: string };
|
||||
positionLifted: boolean;
|
||||
}
|
||||
|
||||
type PendingRevealCommit = {
|
||||
token: string;
|
||||
releasedPriors?: RevealLift["priors"];
|
||||
};
|
||||
|
||||
export type LayerRevealCommitOwnership = {
|
||||
token: string;
|
||||
priorZ: string;
|
||||
priorPosition: string | null;
|
||||
activeLiftStyles: RevealLift["priors"];
|
||||
};
|
||||
|
||||
let revealCommitSequence = 0;
|
||||
const zPersistCounts = new WeakMap<HTMLElement, number>();
|
||||
const REVEAL_RETRY_MS = 16;
|
||||
|
||||
/** Prevent delayed reveals from capturing an optimistic z as authored state. */
|
||||
export function beginLayerZPersist(element: HTMLElement): () => void {
|
||||
zPersistCounts.set(element, (zPersistCounts.get(element) ?? 0) + 1);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const remaining = (zPersistCounts.get(element) ?? 1) - 1;
|
||||
if (remaining > 0) zPersistCounts.set(element, remaining);
|
||||
else zPersistCounts.delete(element);
|
||||
};
|
||||
}
|
||||
|
||||
interface RevealState {
|
||||
/** The layer element the reveal was applied for (deselect detection). */
|
||||
base: HTMLElement;
|
||||
nodes: RevealedNode[];
|
||||
lift: RevealLift | null;
|
||||
}
|
||||
|
||||
function restoreInline(el: HTMLElement, property: string, prior: string): void {
|
||||
if (prior) el.style.setProperty(property, prior);
|
||||
else el.style.removeProperty(property);
|
||||
}
|
||||
|
||||
// This decoder owns the complete serialized reveal-commit schema. Splitting
|
||||
// field checks across helpers would create multiple authorities for validity.
|
||||
// fallow-ignore-next-line complexity
|
||||
function readPendingRevealCommit(element: HTMLElement): PendingRevealCommit | null {
|
||||
const raw = element.getAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (typeof value !== "object" || value === null || !("token" in value)) return null;
|
||||
const token = value.token;
|
||||
if (typeof token !== "string") return null;
|
||||
if (!("releasedPriors" in value) || value.releasedPriors === undefined) return { token };
|
||||
const releasedPriors = value.releasedPriors;
|
||||
if (
|
||||
typeof releasedPriors !== "object" ||
|
||||
releasedPriors === null ||
|
||||
!("zIndex" in releasedPriors) ||
|
||||
!("position" in releasedPriors)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const { zIndex, position } = releasedPriors;
|
||||
return typeof zIndex === "string" && typeof position === "string"
|
||||
? { token, releasedPriors: { zIndex, position } }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePendingRevealCommit(element: HTMLElement, state: PendingRevealCommit): void {
|
||||
element.setAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR, JSON.stringify(state));
|
||||
}
|
||||
|
||||
/** Hand an active reveal lift to one pending durable z commit. */
|
||||
export function beginLayerRevealCommit(element: HTMLElement): LayerRevealCommitOwnership | null {
|
||||
const priorZ = element.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
|
||||
if (priorZ == null) return null;
|
||||
const ownership = {
|
||||
token: `reveal-z-${revealCommitSequence++}`,
|
||||
priorZ,
|
||||
priorPosition: element.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR),
|
||||
activeLiftStyles: { zIndex: element.style.zIndex, position: element.style.position },
|
||||
};
|
||||
writePendingRevealCommit(element, { token: ownership.token });
|
||||
element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
|
||||
element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
|
||||
return ownership;
|
||||
}
|
||||
|
||||
/** Durable success consumes the reveal regardless of whether selection released it meanwhile. */
|
||||
export function completeLayerRevealCommit(
|
||||
element: HTMLElement,
|
||||
ownership: LayerRevealCommitOwnership,
|
||||
): void {
|
||||
if (readPendingRevealCommit(element)?.token === ownership.token) {
|
||||
element.removeAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back only the reveal transaction this commit owns. An active reveal gets
|
||||
* its temporary lift back; a reveal released while persistence was pending gets
|
||||
* its exact authored inline styles back, with no resurrected metadata.
|
||||
*/
|
||||
export function rollbackLayerRevealCommit(
|
||||
element: HTMLElement,
|
||||
ownership: LayerRevealCommitOwnership,
|
||||
): void {
|
||||
const pending = readPendingRevealCommit(element);
|
||||
if (pending?.token !== ownership.token) return;
|
||||
element.removeAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR);
|
||||
element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
|
||||
element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
|
||||
if (pending.releasedPriors) {
|
||||
restoreInline(element, "z-index", pending.releasedPriors.zIndex);
|
||||
restoreInline(element, "position", pending.releasedPriors.position);
|
||||
return;
|
||||
}
|
||||
restoreInline(element, "z-index", ownership.activeLiftStyles.zIndex);
|
||||
restoreInline(element, "position", ownership.activeLiftStyles.position);
|
||||
element.setAttribute(LAYER_REVEAL_PRIOR_Z_ATTR, ownership.priorZ);
|
||||
if (ownership.priorPosition != null) {
|
||||
element.setAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR, ownership.priorPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a property ONLY when its current inline value is still the one this
|
||||
* override wrote — a later real edit (commit, animation seek) is the new
|
||||
* truth and must not be clobbered. */
|
||||
function restoreIfOurs(
|
||||
el: HTMLElement,
|
||||
property: "display" | "visibility" | "opacity",
|
||||
applied: string | undefined,
|
||||
prior: string,
|
||||
): void {
|
||||
if (applied == null) return;
|
||||
if (el.style.getPropertyValue(property) !== applied) return;
|
||||
restoreInline(el, property, prior);
|
||||
}
|
||||
|
||||
/** What hides this node at the current frame, per computed style. */
|
||||
function readHideSignals(el: HTMLElement, win: Window) {
|
||||
const computed = win.getComputedStyle(el);
|
||||
const opacity = Number.parseFloat(computed.opacity);
|
||||
return {
|
||||
display: computed.display === "none",
|
||||
visibility: computed.visibility === "hidden" || computed.visibility === "collapse",
|
||||
opacity: Number.isFinite(opacity) && opacity <= 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
/** Force one hidden node visible with inline styles; returns priors + applied. */
|
||||
function revealNode(
|
||||
el: HTMLElement,
|
||||
win: Window,
|
||||
needs: ReturnType<typeof readHideSignals>,
|
||||
): RevealedNode {
|
||||
const priors = {
|
||||
display: el.style.display,
|
||||
visibility: el.style.visibility,
|
||||
opacity: el.style.opacity,
|
||||
};
|
||||
const applied: RevealedNode["applied"] = {};
|
||||
if (needs.display) {
|
||||
// Prefer whatever the stylesheet says once the inline hide is lifted;
|
||||
// only force block when the sheet itself hides it.
|
||||
el.style.removeProperty("display");
|
||||
if (win.getComputedStyle(el).display === "none") el.style.display = "block";
|
||||
applied.display = el.style.display;
|
||||
}
|
||||
if (needs.visibility) {
|
||||
el.style.visibility = "visible";
|
||||
applied.visibility = "visible";
|
||||
}
|
||||
if (needs.opacity) {
|
||||
el.style.opacity = "1";
|
||||
applied.opacity = "1";
|
||||
}
|
||||
return { element: el, priors, applied };
|
||||
}
|
||||
|
||||
/** Walk `element` → body, force-revealing every hiding node; returns the touched nodes. */
|
||||
function revealHiddenChain(element: HTMLElement): RevealedNode[] {
|
||||
const doc = element.ownerDocument;
|
||||
const win = doc.defaultView;
|
||||
if (!win) return [];
|
||||
const nodes: RevealedNode[] = [];
|
||||
let el: HTMLElement | null = element;
|
||||
while (el && el !== doc.body && el !== doc.documentElement) {
|
||||
let needs: ReturnType<typeof readHideSignals>;
|
||||
try {
|
||||
needs = readHideSignals(el, win);
|
||||
} catch {
|
||||
break; // detached / cross-realm — leave the rest alone
|
||||
}
|
||||
if (needs.display || needs.visibility || needs.opacity) nodes.push(revealNode(el, win, needs));
|
||||
el = el.parentElement;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift the selected element to the TOP of the paint order while selected —
|
||||
* regardless of its authored z or panel position. The true z is parked in
|
||||
* LAYER_REVEAL_PRIOR_Z_ATTR so every studio z reader keeps reporting it (the
|
||||
* lift is invisible to menus, badges, the lane mirror, and the panel sort);
|
||||
* only the renderer sees the lifted inline value. A static element gets a
|
||||
* temporary position:relative (layout-preserving) so the z applies, with the
|
||||
* prior position parked in LAYER_REVEAL_PRIOR_POSITION_ATTR for the z-commit's
|
||||
* static check. Exported for direct unit testing.
|
||||
*/
|
||||
export function liftElementToTop(element: HTMLElement): RevealLift | null {
|
||||
const win = element.ownerDocument.defaultView;
|
||||
if (!win) return null;
|
||||
const priors = { zIndex: element.style.zIndex, position: element.style.position };
|
||||
let positionLifted = false;
|
||||
try {
|
||||
element.setAttribute(LAYER_REVEAL_PRIOR_Z_ATTR, String(readEffectiveZIndex(element)));
|
||||
if (win.getComputedStyle(element).position === "static") {
|
||||
element.setAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR, "static");
|
||||
element.style.position = "relative";
|
||||
positionLifted = true;
|
||||
}
|
||||
} catch {
|
||||
element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
|
||||
return null; // detached / cross-realm — no lift
|
||||
}
|
||||
element.style.zIndex = LAYER_REVEAL_LIFT_Z;
|
||||
return { priors, positionLifted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo an active lift. Skipped entirely when the prior-z attribute is gone —
|
||||
* a z-reorder commit consumed the lift (handleDomZIndexReorderCommit removes
|
||||
* the attributes and writes the new real z), and that commit is the truth.
|
||||
* Exported for direct unit testing.
|
||||
*/
|
||||
export function restoreLiftedElement(element: HTMLElement, lift: RevealLift): void {
|
||||
if (!element.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)) {
|
||||
const pending = readPendingRevealCommit(element);
|
||||
if (pending && !pending.releasedPriors) {
|
||||
// Persistence temporarily owns the live z. Hand it the exact authored
|
||||
// priors so a later rejection can restore them after this hook lets go.
|
||||
writePendingRevealCommit(element, { ...pending, releasedPriors: lift.priors });
|
||||
}
|
||||
return;
|
||||
}
|
||||
element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
|
||||
element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
|
||||
if (element.style.zIndex === LAYER_REVEAL_LIFT_Z) {
|
||||
restoreInline(element, "z-index", lift.priors.zIndex);
|
||||
}
|
||||
if (lift.positionLifted && element.style.position === "relative") {
|
||||
restoreInline(element, "position", lift.priors.position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary "show me this element" override for the Layers panel
|
||||
* (Webflow-navigator style): clicking a layer forces it (and every hiding
|
||||
* ancestor up to the body) visible with LIVE inline styles, and paints it on
|
||||
* TOP of the stack while selected (see liftElementToTop).
|
||||
*
|
||||
* Strictly ephemeral by construction:
|
||||
* - Exact prior inline values are recorded per touched node and restored on
|
||||
* every exit path — reveal of a different layer, deselect, playback start,
|
||||
* unmount. Nothing is ever sent to a persist path, and each property is
|
||||
* restored only while it still holds the value this override wrote.
|
||||
* - A post-edit iframe reload replaces the DOM; detached nodes are skipped on
|
||||
* restore (the fresh document never had the override).
|
||||
* - Scrubbing/playing lets the runtime and GSAP rewrite these same inline
|
||||
* styles — that is the animation showing reality, and the override is
|
||||
* dropped on play for exactly that reason.
|
||||
*/
|
||||
export function useLayerRevealOverride({
|
||||
isPlaying,
|
||||
selectedElement,
|
||||
}: {
|
||||
isPlaying: boolean;
|
||||
selectedElement: HTMLElement | null;
|
||||
}): {
|
||||
scheduleReveal: (element: HTMLElement, delayMs: number) => void;
|
||||
} {
|
||||
const stateRef = useRef<RevealState | null>(null);
|
||||
const pendingRevealRef = useRef<{ timer: number; element: HTMLElement } | null>(null);
|
||||
const currentRef = useRef({ isPlaying, selectedElement });
|
||||
currentRef.current = { isPlaying, selectedElement };
|
||||
|
||||
const restoreReveal = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
stateRef.current = null;
|
||||
if (!state) return;
|
||||
for (const { element, priors, applied } of state.nodes) {
|
||||
if (!element.isConnected) continue;
|
||||
restoreIfOurs(element, "display", applied.display, priors.display);
|
||||
restoreIfOurs(element, "visibility", applied.visibility, priors.visibility);
|
||||
restoreIfOurs(element, "opacity", applied.opacity, priors.opacity);
|
||||
}
|
||||
if (state.lift && state.base.isConnected) restoreLiftedElement(state.base, state.lift);
|
||||
}, []);
|
||||
|
||||
const reveal = useCallback(
|
||||
(element: HTMLElement) => {
|
||||
restoreReveal();
|
||||
const nodes = revealHiddenChain(element);
|
||||
const lift = liftElementToTop(element);
|
||||
if (nodes.length > 0 || lift) stateRef.current = { base: element, nodes, lift };
|
||||
},
|
||||
[restoreReveal],
|
||||
);
|
||||
|
||||
const cancelScheduledReveal = useCallback(() => {
|
||||
const pending = pendingRevealRef.current;
|
||||
pendingRevealRef.current = null;
|
||||
if (pending) window.clearTimeout(pending.timer);
|
||||
}, []);
|
||||
|
||||
const scheduleReveal = useCallback(
|
||||
(element: HTMLElement, delayMs: number) => {
|
||||
cancelScheduledReveal();
|
||||
const current = currentRef.current;
|
||||
if (current.isPlaying) return;
|
||||
const attemptReveal = () => {
|
||||
const latest = currentRef.current;
|
||||
if (latest.isPlaying || latest.selectedElement !== element || !element.isConnected) {
|
||||
pendingRevealRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (zPersistCounts.has(element)) {
|
||||
const timer = window.setTimeout(attemptReveal, REVEAL_RETRY_MS);
|
||||
pendingRevealRef.current = { timer, element };
|
||||
return;
|
||||
}
|
||||
pendingRevealRef.current = null;
|
||||
reveal(element);
|
||||
};
|
||||
const timer = window.setTimeout(attemptReveal, delayMs);
|
||||
pendingRevealRef.current = { timer, element };
|
||||
},
|
||||
[cancelScheduledReveal, reveal],
|
||||
);
|
||||
|
||||
// Selection and playback jointly own eligibility for both pending and active
|
||||
// reveals. Cancel first; playback or a different selection then restores the
|
||||
// animation/authored styles.
|
||||
useEffect(() => {
|
||||
const pending = pendingRevealRef.current;
|
||||
if (isPlaying || (pending && pending.element !== selectedElement)) cancelScheduledReveal();
|
||||
const base = stateRef.current?.base;
|
||||
if (isPlaying || (base && selectedElement !== base)) restoreReveal();
|
||||
}, [cancelScheduledReveal, isPlaying, restoreReveal, selectedElement]);
|
||||
|
||||
// Unmount: never leave a timer or override behind.
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelScheduledReveal();
|
||||
restoreReveal();
|
||||
},
|
||||
[cancelScheduledReveal, restoreReveal],
|
||||
);
|
||||
|
||||
return { scheduleReveal };
|
||||
}
|
||||
Reference in New Issue
Block a user