From 951fda3f10a44844710fdcb7b5a0debec1a36c92 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:13 -0700 Subject: [PATCH] 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); +}