Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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);
}
Loading