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
18 changes: 18 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@
"packages/studio/src/hooks/gsapRuntimePreview.ts",
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
"packages/studio/src/components/nle/NLEContext.tsx",
"packages/studio/src/components/nle/PreviewPane.tsx",
"packages/studio/src/components/nle/AssetPreviewOverlay.tsx",
"packages/studio/src/player/components/TimelineOverlays.tsx",
"packages/studio/src/player/components/timelineClipChildren.tsx",
"packages/studio/src/player/components/timelineClipDragTypes.ts",
"packages/studio/src/player/hooks/useTimelinePlayerLoop.ts",
"packages/studio/src/utils/assetPreviewStore.ts",
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
],
"ignorePatterns": [
Expand Down Expand Up @@ -93,6 +103,11 @@
"packages/cli/src/cloud/_gen/**",
],
"ignoreExports": [
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
{
"file": "packages/studio/src/components/editor/domEditNudge.ts",
"exports": ["CANVAS_NUDGE_COMMIT_DEBOUNCE_MS"],
},
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
{
"file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts",
Expand Down Expand Up @@ -441,6 +456,9 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
// removed by studio-dnd/pr22 when the final config lands.
"packages/studio/src/player/components/TimelineOverlays.tsx",
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
// removed by studio-dnd/pr22 when the final config lands.
"packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
Expand Down
80 changes: 80 additions & 0 deletions packages/studio/src/components/editor/domEditNudge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
CANVAS_NUDGE_SHIFT_STEP_PX,
CANVAS_NUDGE_STEP_PX,
canCanvasNudgeTargets,
resolveCanvasNudgeDelta,
} from "./domEditNudge";

function mockKeyboardEvent(
key: string,
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "shiftKey">> = {},
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "key"> {
return {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
key,
...overrides,
};
}

describe("resolveCanvasNudgeDelta", () => {
it("maps plain arrows to 1px composition deltas", () => {
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft"))).toEqual({
dx: -CANVAS_NUDGE_STEP_PX,
dy: 0,
});
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowRight"))).toEqual({
dx: CANVAS_NUDGE_STEP_PX,
dy: 0,
});
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowUp"))).toEqual({
dx: 0,
dy: -CANVAS_NUDGE_STEP_PX,
});
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowDown"))).toEqual({
dx: 0,
dy: CANVAS_NUDGE_STEP_PX,
});
});

it("maps Shift+arrow to 10px deltas", () => {
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowRight", { shiftKey: true }))).toEqual({
dx: CANVAS_NUDGE_SHIFT_STEP_PX,
dy: 0,
});
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowUp", { shiftKey: true }))).toEqual({
dx: 0,
dy: -CANVAS_NUDGE_SHIFT_STEP_PX,
});
});

it("ignores browser and app shortcut chords", () => {
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBeNull();
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { ctrlKey: true }))).toBeNull();
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { metaKey: true }))).toBeNull();
});

it("ignores non-arrow keys", () => {
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("Escape"))).toBeNull();
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("a"))).toBeNull();
});
});

describe("canCanvasNudgeTargets", () => {
const movable = { capabilities: { canApplyManualOffset: true } };
const locked = { capabilities: { canApplyManualOffset: false } };

it("requires at least one target", () => {
expect(canCanvasNudgeTargets([])).toBe(false);
});

it("allows only when every target accepts a manual offset", () => {
expect(canCanvasNudgeTargets([movable])).toBe(true);
expect(canCanvasNudgeTargets([movable, movable])).toBe(true);
expect(canCanvasNudgeTargets([locked])).toBe(false);
expect(canCanvasNudgeTargets([movable, locked])).toBe(false);
});
});
44 changes: 44 additions & 0 deletions packages/studio/src/components/editor/domEditNudge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Pure helpers for the canvas arrow-key nudge (DomEditOverlay).
*
* Mirrors captions/keyboard.ts conventions; the two nudge surfaces can't
* double-fire because PreviewOverlays mounts CaptionOverlay and DomEditOverlay
* mutually exclusively (caption edit mode returns before the DOM overlay).
*/

const CANVAS_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);

export const CANVAS_NUDGE_STEP_PX = 1;
export const CANVAS_NUDGE_SHIFT_STEP_PX = 10;
/** One undo entry per key burst: the commit fires after this idle gap. */
export const CANVAS_NUDGE_COMMIT_DEBOUNCE_MS = 400;

type CanvasNudgeKeyEvent = Pick<
KeyboardEvent,
"altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "key"
>;

/**
* Arrow key → composition-px delta (Shift = 10). Null when the key is not a
* plain/Shift arrow, so browser and app shortcut chords pass through.
*/
export function resolveCanvasNudgeDelta(
event: CanvasNudgeKeyEvent,
): { dx: number; dy: number } | null {
if (event.metaKey || event.ctrlKey || event.altKey) return null;
if (!CANVAS_NUDGE_KEYS.has(event.key)) return null;
const step = event.shiftKey ? CANVAS_NUDGE_SHIFT_STEP_PX : CANVAS_NUDGE_STEP_PX;
return {
dx: event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0,
dy: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0,
};
}

export interface CanvasNudgeTarget {
capabilities: { canApplyManualOffset: boolean };
}

/** A nudge needs at least one target and every target must accept manual offsets. */
export function canCanvasNudgeTargets(targets: ReadonlyArray<CanvasNudgeTarget>): boolean {
return targets.length > 0 && targets.every((t) => t.capabilities.canApplyManualOffset);
}
147 changes: 147 additions & 0 deletions packages/studio/src/components/nle/AssetPreviewOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* CapCut-style asset preview overlay rendered inside PreviewPane.
*
* Shown when the user clicks an asset card that has NOT yet been added to the
* timeline. Displays the media (image / video / audio) without modifying the
* composition — no undo entry, no file mutation.
*
* Dismiss: X button, Escape key, or click on the scrim.
* Switching to another not-added asset replaces the current preview.
*/
import { useEffect, useCallback } from "react";
import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";

function basename(path: string): string {
return path.split("/").pop() ?? path;
}

type AssetKind = "image" | "video" | "audio";

function resolveAssetKind(path: string): AssetKind {
if (VIDEO_EXT.test(path)) return "video";
if (IMAGE_EXT.test(path)) return "image";
return "audio";
}

/** The media element for a previewed asset, chosen by kind. */
function AssetPreviewMedia({
kind,
serveUrl,
name,
}: {
kind: AssetKind;
serveUrl: string;
name: string;
}) {
if (kind === "image") {
return (
<img
src={serveUrl}
alt={name}
className="max-w-full max-h-[70vh] rounded-md object-contain shadow-2xl"
/>
);
}
if (kind === "video") {
return (
<video
src={serveUrl}
controls
autoPlay
muted
playsInline
className="max-w-full max-h-[70vh] rounded-md shadow-2xl"
/>
);
}
return (
<div className="flex flex-col items-center gap-4 px-6 py-8 rounded-xl bg-neutral-900 border border-neutral-800">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-neutral-500"
>
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
<audio src={serveUrl} controls className="w-64" />
</div>
);
}

export function AssetPreviewOverlay() {
const previewAsset = useAssetPreviewStore((s) => s.previewAsset);
const previewProjectId = useAssetPreviewStore((s) => s.previewProjectId);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);

const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") clearPreviewAsset();
},
[clearPreviewAsset],
);

useEffect(() => {
if (!previewAsset) return;
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previewAsset, handleKeyDown]);

if (!previewAsset || !previewProjectId) return null;

const encodedAsset = previewAsset.split("/").map(encodeURIComponent).join("/");
const serveUrl = `/api/projects/${previewProjectId}/preview/${encodedAsset}`;
const name = basename(previewAsset);

return (
<div
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80"
onClick={clearPreviewAsset}
role="dialog"
aria-label={`Preview: ${name}`}
aria-modal="true"
>
{/* Close button */}
<button
className="absolute top-3 right-3 w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
onClick={(e) => {
e.stopPropagation();
clearPreviewAsset();
}}
aria-label="Close preview"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>

{/* Media */}
<div
className="flex flex-col items-center gap-3 max-w-[90%] max-h-[85%]"
onClick={(e) => e.stopPropagation()}
>
<AssetPreviewMedia kind={resolveAssetKind(previewAsset)} serveUrl={serveUrl} name={name} />

{/* Filename label */}
<span className="text-[12px] text-neutral-400 truncate max-w-full px-2 text-center">
{name}
</span>
</div>
</div>
);
}
Loading
Loading