diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 7fbf76be91..ba82d3950b 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -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": [ @@ -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", @@ -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", diff --git a/packages/studio/src/components/editor/domEditNudge.test.ts b/packages/studio/src/components/editor/domEditNudge.test.ts new file mode 100644 index 0000000000..4a90b1c22c --- /dev/null +++ b/packages/studio/src/components/editor/domEditNudge.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/studio/src/components/editor/domEditNudge.ts b/packages/studio/src/components/editor/domEditNudge.ts new file mode 100644 index 0000000000..b551061425 --- /dev/null +++ b/packages/studio/src/components/editor/domEditNudge.ts @@ -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): boolean { + return targets.length > 0 && targets.every((t) => t.capabilities.canApplyManualOffset); +} diff --git a/packages/studio/src/components/nle/AssetPreviewOverlay.tsx b/packages/studio/src/components/nle/AssetPreviewOverlay.tsx new file mode 100644 index 0000000000..5364289f15 --- /dev/null +++ b/packages/studio/src/components/nle/AssetPreviewOverlay.tsx @@ -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 ( + {name} + ); + } + if (kind === "video") { + return ( +