Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f63a446
feat(studio): mirror canvas z-order actions into timeline lanes, badg…
ukimsanov Jul 14, 2026
f609bd7
fix(studio): fold mirrored z-order gestures into one undo entry acros…
ukimsanov Jul 14, 2026
b634dbd
feat(studio): flashless lane mirror, z-order menu icons, close-gap tr…
ukimsanov Jul 14, 2026
c6ad6c0
fix(studio): rebind-only preview sync for unmutated timing edits, cla…
ukimsanov Jul 14, 2026
8443867
fix(studio): bound forward/backward mirror to a one-element step
ukimsanov Jul 14, 2026
7fb6e68
feat(studio): permanent gap-menu rows with hover and click-select gap…
ukimsanov Jul 14, 2026
5f6e18d
feat(studio): stronger capcut-style timeline zoom steps
ukimsanov Jul 14, 2026
12be7e4
feat(studio): three-way z sync — layers drags mirror timeline lanes, …
ukimsanov Jul 14, 2026
ac69c32
feat(studio): full-track selection highlight, borderless gap hover st…
ukimsanov Jul 14, 2026
6975611
feat(studio): selected layer paints on top via a reader-transparent z…
ukimsanov Jul 14, 2026
6e7efeb
fix(studio): flashless undo/redo — three full-reload causes in the so…
ukimsanov Jul 14, 2026
8ebdf6f
feat(studio): left breathing pad before t=0, double zoom sensitivity …
ukimsanov Jul 14, 2026
8dca233
fix(studio): left pad renders as true empty space, not lane surface
ukimsanov Jul 14, 2026
b501ef3
fix(studio): no vertical line in the ruler band before 00:00
ukimsanov Jul 14, 2026
1bf7f33
refactor(studio): remove the timeline z-override badge
ukimsanov Jul 14, 2026
6f775ab
fix(studio): collision-free expanded child lanes and host-window gap …
ukimsanov Jul 14, 2026
1430e42
fix(studio): scope mirror references, insert writes, and crossed-neig…
ukimsanov Jul 14, 2026
0cfdb5f
fix(studio): z-to-lane gestures are one serialized transaction gated …
ukimsanov Jul 14, 2026
ba81c36
fix(studio): multi-clip GSAP batch mutations roll back on late failure
ukimsanov Jul 14, 2026
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
6 changes: 3 additions & 3 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ export function StudioApp() {
}>,
coalesceKey?: string,
operation: TimelineMoveOperation = "timing",
coalesceMs?: number,
) => {
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, {
handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove,
});
const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
},
[timelineEditing.handleTimelineGroupMove],
);
Expand Down
41 changes: 27 additions & 14 deletions packages/studio/src/components/editor/CanvasContextMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 { allMatched: true, changed: true };
},
}),
));
return null;
}
const hookHost = document.createElement("div");
Expand Down
77 changes: 71 additions & 6 deletions packages/studio/src/components/editor/CanvasContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" },
];

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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>
);
})}
Expand Down
7 changes: 5 additions & 2 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading