Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,44 @@ describe("collectRuntimeTimelinePayload", () => {
expect(result.clips[0].id).toBe("hf-headline");
});

// Regression: the authored data-track-index must round-trip verbatim, even
// when clips of DIFFERENT kinds (video vs element) share a track. The old
// mixed-kind renumber split them onto separate tracks, which made the
// written track drift from the displayed one on every editor move.
it("honors authored track indices verbatim for mixed-kind tracks", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);

const video = document.createElement("video");
video.id = "clip-video";
video.setAttribute("data-start", "1");
video.setAttribute("data-duration", "3");
video.setAttribute("data-track-index", "1");
root.appendChild(video);

const caption = document.createElement("div");
caption.id = "clip-caption";
caption.setAttribute("data-start", "8");
caption.setAttribute("data-duration", "3");
caption.setAttribute("data-track-index", "1");
root.appendChild(caption);

const other = document.createElement("div");
other.id = "clip-other";
other.setAttribute("data-start", "0");
other.setAttribute("data-duration", "3");
other.setAttribute("data-track-index", "2");
root.appendChild(other);

const result = collectRuntimeTimelinePayload(defaultParams);
const trackOf = (id: string) => result.clips.find((c) => c.id === id)?.track;
expect(trackOf("clip-video")).toBe(1);
expect(trackOf("clip-caption")).toBe(1);
expect(trackOf("clip-other")).toBe(2);
});

it("collects clips from elements with data-start and data-duration", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down
87 changes: 17 additions & 70 deletions packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,57 +52,15 @@ function maxDefinedNumber(...values: Array<number | null>): number | null {
}

/**
* When multiple content kinds share the same track number, split them
* onto separate tracks so the timeline UI shows distinct rows.
*
* Preferred kind order (top → bottom): composition, video, image, element, audio.
* Tracks that contain only one kind are left untouched.
* Parse an authored track attribute, honoring 0 (a valid top-lane index).
* `parseInt(...) || fallback` silently replaced authored track 0 with the
* synthetic fallback, so track-0 clips drifted to the bottom of the timeline.
*/
const KIND_ORDER: Record<string, number> = {
composition: 0,
video: 1,
image: 2,
element: 3,
audio: 4,
};

function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void {
if (clips.length === 0) return;

// Group clips by their raw track number and detect which tracks have mixed kinds
const trackKinds = new Map<number, Set<string>>();
for (const clip of clips) {
const kinds = trackKinds.get(clip.track) ?? new Set();
kinds.add(clip.kind);
trackKinds.set(clip.track, kinds);
}

const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1);
if (!hasMixedTracks) return;

// Build new contiguous track numbers, splitting mixed tracks by kind
let nextTrack = 0;
const newTrackMap = new Map<string, number>(); // "origTrack:kind" → newTrack

const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b);
for (const track of sortedTracks) {
const kinds = trackKinds.get(track)!;
if (kinds.size === 1) {
newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++);
} else {
// Split by kind in preferred order
const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99));
for (const kind of sorted) {
newTrackMap.set(`${track}:${kind}`, nextTrack++);
}
}
}

for (const clip of clips) {
const key = `${clip.track}:${clip.kind}`;
const newTrack = newTrackMap.get(key);
if (newTrack != null) clip.track = newTrack;
}
function parseAuthoredTrack(el: Element, fallback: number): number {
const raw = el.getAttribute("data-track-index") ?? el.getAttribute("data-track");
if (raw == null) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}

function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
Expand Down Expand Up @@ -441,11 +399,7 @@ export function collectRuntimeTimelinePayload(params: {
label: buildTimelineClipLabel(node, kind, clips.length),
start,
duration,
track:
Number.parseInt(
node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i),
10,
) || 0,
track: parseAuthoredTrack(node, i),
zIndex: readInlineZIndex(node),
stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId,
kind,
Expand Down Expand Up @@ -554,11 +508,7 @@ export function collectRuntimeTimelinePayload(params: {
el.id,
start: range.start,
duration: clampedDuration,
track:
Number.parseInt(
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
10,
) || gsapTrack,
track: parseAuthoredTrack(el, gsapTrack),
zIndex: readInlineZIndex(el),
stackingContextId: rootCompositionIdForGsap,
kind: "element",
Expand Down Expand Up @@ -613,11 +563,7 @@ export function collectRuntimeTimelinePayload(params: {
el.id,
start: 0,
duration: clampedDuration,
track:
Number.parseInt(
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
10,
) || overlayTrack,
track: parseAuthoredTrack(el, overlayTrack),
zIndex: readInlineZIndex(el),
stackingContextId: rootCompositionIdForGsap,
kind: "element",
Expand All @@ -637,11 +583,12 @@ export function collectRuntimeTimelinePayload(params: {
}
}

// ── Track normalization ────────────────────────────────────────────────
// When multiple content kinds (composition, audio, video, …) share the same
// data-track-index value, split them onto separate tracks so the timeline UI
// shows distinct rows for each kind.
normalizeTrackAssignments(clips);
// Track assignment honors the authored data-track-index verbatim: a clip stays
// on the track it was placed on, regardless of kind. (Previously mixed-kind
// tracks were split onto separate rows, but that renumbered tracks — breaking
// "drop a clip onto an existing track" and causing the written track to drift
// from the displayed one on every move. Track index is display-only; render
// never reads it, so honoring it verbatim is the correct NLE behavior.)

for (const compositionNode of compositionNodes) {
if (compositionNode === root) continue;
Expand Down
153 changes: 152 additions & 1 deletion packages/studio/src/components/editor/CanvasContextMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ 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 { resolveZIndexEntries } from "../nle/PreviewOverlays";
import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
import { CanvasContextMenu } from "./CanvasContextMenu";
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
import type { DomEditSelection } from "./domEditing";

installReactActEnvironment();
Expand All @@ -24,7 +28,8 @@ afterEach(() => {

function renderMenu(props: {
selection: DomEditSelection;
onApplyZIndex?: () => void;
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
onDelete?: (selection: DomEditSelection) => void;
}) {
root = createRoot(host);
Expand All @@ -36,6 +41,7 @@ function renderMenu(props: {
selection: props.selection,
onClose: () => {},
onApplyZIndex: props.onApplyZIndex,
onZOrderCrossed: props.onZOrderCrossed,
onDelete: props.onDelete,
}),
);
Expand Down Expand Up @@ -113,3 +119,148 @@ describe("CanvasContextMenu — handler gating", () => {
expect(document.body.querySelector(".border-t")).toBeNull();
});
});

// ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ──

function pressMenuItem(label: string) {
const button = zOrderButtons().find((b) => b.textContent === label);
expect(button).toBeDefined();
act(() => {
button!.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
);
});
}

/** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */
function makeStaticFamily() {
const parent = document.createElement("div");
const target = document.createElement("div");
target.id = "target";
// In happy-dom an unset computed position is "" (not "static"), which would
// skip the commit hook's static-position injection; declare it explicitly so
// the test exercises the browser default.
target.style.position = "static";
const other = document.createElement("div");
other.id = "other";
parent.append(target, other);
document.body.append(parent);
return { parent, target, other };
}

interface CapturedBatchCall {
batches: DomEditPatchBatch[];
options: { label: string; coalesceKey: string };
}

/** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */
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 });
},
}));
return null;
}
const hookHost = document.createElement("div");
document.body.append(hookHost);
const hookRoot = createRoot(hookHost);
act(() => hookRoot.render(<Harness />));
return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
}

describe("CanvasContextMenu — z-action commit path", () => {
it("never mutates live styles itself and persists the position patch for a static element", async () => {
const { target } = makeStaticFamily();
const selection = makeSelection("Target", target);
const captured: CapturedBatchCall[] = [];
const { commit, cleanup } = renderCommitHook(captured);

// Wire onApplyZIndex the way the app does (PreviewOverlays → the commit
// hook), asserting the menu has NOT touched the DOM when it fires — the
// hook must capture true pre-change styles for its rollback.
const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
renderMenu({
selection,
onApplyZIndex: (patches, action) => {
stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
const { entries } = resolveZIndexEntries(selection, patches);
void commit(entries, undefined, action);
},
});

await act(async () => pressMenuItem("Bring forward"));

// The menu left the element pristine; only the commit hook wrote styles.
expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]);
expect(target.style.zIndex).toBe("1");
expect(target.style.position).toBe("relative");

// The persisted payload carries BOTH the z-index and the injected position,
// so the reorder survives the post-commit reloadPreview().
expect(captured).toHaveLength(1);
const targetPatch = captured[0]?.batches
.flatMap((batch) => batch.patches)
.find((patch) => patch.target.id === "target");
expect(targetPatch?.operations).toEqual(
expect.arrayContaining([
{ type: "inline-style", property: "z-index", value: "1" },
{ type: "inline-style", property: "position", value: "relative" },
]),
);
// F7: the action kind is part of the default undo coalesce key, so two
// different menu actions never merge into one undo step.
expect(captured[0]?.options.coalesceKey).toContain("bring-forward");

cleanup();
});

it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => {
// target (earlier in DOM) and other are tied — bring-forward steps over
// `other`, and the flash callback must receive exactly that element, after
// onApplyZIndex ran (call order lets the host measure post-commit rects).
const { target, other } = makeStaticFamily();
const selection = makeSelection("Target", target);
const calls: Array<{ kind: string; crossed?: HTMLElement }> = [];

renderMenu({
selection,
onApplyZIndex: () => calls.push({ kind: "apply" }),
onZOrderCrossed: (crossed, action) => {
expect(action).toBe("bring-forward");
calls.push({ kind: "crossed", crossed });
},
});

await act(async () => pressMenuItem("Bring forward"));

expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]);
expect(calls[1]?.crossed).toBe(other);
});

it("does not call onZOrderCrossed for bring-to-front", async () => {
const { target } = makeStaticFamily();
const onZOrderCrossed = vi.fn();

renderMenu({
selection: makeSelection("Target", target),
onApplyZIndex: vi.fn(),
onZOrderCrossed,
});

await act(async () => pressMenuItem("Bring to front"));

expect(onZOrderCrossed).not.toHaveBeenCalled();
});
});
Loading
Loading