From 951fda3f10a44844710fdcb7b5a0debec1a36c92 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:13 -0700 Subject: [PATCH 1/2] feat(studio): arrow-key nudge step math (unwired) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: domEditNudge — pure nudge-step math (direction × modifier → delta, commit debounce constant) with tests. Shipped unwired. Why: small standalone concept consumed by the canvas nudge hook in the chrome-components PR. How: new files; one TEMP(studio-dnd) ignoreExports entry until the consumer lands (removed by the app-shell swap PR). Test plan: bunx vitest run domEditNudge.test.ts; tsc --noEmit; fallow audit clean. --- .fallowrc.jsonc | 5 ++ .../components/editor/domEditNudge.test.ts | 80 +++++++++++++++++++ .../src/components/editor/domEditNudge.ts | 44 ++++++++++ 3 files changed, 129 insertions(+) create mode 100644 packages/studio/src/components/editor/domEditNudge.test.ts create mode 100644 packages/studio/src/components/editor/domEditNudge.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 7fbf76be91..ccb30a1f32 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -93,6 +93,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", 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); +} From 6a03b3ca7c530a84e23894770beadd7b1844e2e3 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:14 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(studio):=20NLE=20shell=20components=20?= =?UTF-8?q?=E2=80=94=20context=20provider,=20panes,=20overlays=20(unwired)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: the NLE shell layer, unwired: NLEContext (provider extracted from NLELayout), PreviewPane, AssetPreviewOverlay + assetPreviewStore, TimelineOverlays (menus/modal/hint extracted from Timeline), timelineClipChildren, timelineClipDragTypes, useTimelinePlayerLoop (playback loop extracted from useTimelinePlayer). Why: the extracted replacements for NLELayout/StudioPreviewArea and the oversized Timeline/useTimelinePlayer internals, reviewable standalone. How: new files only, tsc-clean against main. They intentionally duplicate blocks of their still-alive originals (duplication is warn-level in fallow; the swap PRs delete the originals). Dead-file findings covered by TEMP(studio-dnd) entry registrations, removed at the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean. --- .fallowrc.jsonc | 13 + .../components/nle/AssetPreviewOverlay.tsx | 147 +++++++++ .../src/components/nle/NLEContext.test.ts | 88 +++++ .../studio/src/components/nle/NLEContext.tsx | 312 ++++++++++++++++++ .../studio/src/components/nle/PreviewPane.tsx | 163 +++++++++ .../player/components/TimelineOverlays.tsx | 122 +++++++ .../components/timelineClipChildren.tsx | 39 +++ .../components/timelineClipDragTypes.ts | 65 ++++ .../src/player/hooks/useTimelinePlayerLoop.ts | 85 +++++ .../studio/src/utils/assetPreviewStore.ts | 33 ++ 10 files changed, 1067 insertions(+) create mode 100644 packages/studio/src/components/nle/AssetPreviewOverlay.tsx create mode 100644 packages/studio/src/components/nle/NLEContext.test.ts create mode 100644 packages/studio/src/components/nle/NLEContext.tsx create mode 100644 packages/studio/src/components/nle/PreviewPane.tsx create mode 100644 packages/studio/src/player/components/TimelineOverlays.tsx create mode 100644 packages/studio/src/player/components/timelineClipChildren.tsx create mode 100644 packages/studio/src/player/components/timelineClipDragTypes.ts create mode 100644 packages/studio/src/player/hooks/useTimelinePlayerLoop.ts create mode 100644 packages/studio/src/utils/assetPreviewStore.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index ccb30a1f32..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": [ @@ -446,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/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 ( +