From 289fa770b0767980f5d0e76cf65aa8f594658e6d Mon Sep 17 00:00:00 2001 From: th Date: Sat, 25 Apr 2026 01:07:48 +0200 Subject: [PATCH] 1.1.2: arrow-key nudge for the selected stage object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrow keys now move the currently-selected object by 1 scene unit; Shift bumps the step to 10. Works for both regular layers (upsertKeyframe at the playhead — same write path as a stage drag) and clip instances (setClipInstanceTransform). Locked targets and events originating in text inputs are passed through untouched, so typing in the document drawer / rename dialogs isn't stolen. Also drops the flaky show-locked persistence test. It assumed the in-memory default (true) differed from the stored value; on a fresh dev-server restart with empty localStorage the first toggle was a no-op, the watch never fired, and the assertion failed. Test now flips the ref away first so every subsequent transition is guaranteed to write. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/App.vue | 60 +++++++++++++++++++++++ src/devtools/tests.ts | 107 ++++++++++++++++++++++++++++++++++++++++++ src/version.ts | 2 +- 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4171770..d133569 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "1.1.0", + "version": "1.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 300f516..2b9e408 100644 --- a/src/App.vue +++ b/src/App.vue @@ -51,6 +51,7 @@ import { type StoredProject, } from './lib/projectStore' import { importImageFile } from './lib/fileLoader' +import { sampleLayer } from './lib/interpolate' import { useClipartStore } from './stores/clipart' import { createBlankProject, useSceneStore } from './stores/scene' @@ -297,12 +298,71 @@ function onDocumentContextMenu(e: MouseEvent) { e.preventDefault() } +/** 1-pixel (10 with shift) arrow-key nudge for the currently-selected + * stage object — layer OR clip-instance. Steps are in scene units + * (not screen pixels) so the nudge feels consistent across zoom + * levels. A locked layer is silently ignored so the keyboard path + * matches the drag path, which already refuses to move it. */ +function nudgeSelection(dx: number, dy: number): boolean { + const ciId = store.selectedClipInstanceId + if (ciId) { + const inst = store.scene.clipInstances?.find((c) => c.id === ciId) + if (!inst) return false + const track = store.scene.clipTracks?.find((t) => t.id === inst.clipTrackId) + if (track?.locked) return false + for (const g of store.scene.groups ?? []) { + if (g.locked && g.childIds.includes(inst.clipTrackId)) return false + } + store.setClipInstanceTransform(ciId, { + x: (inst.x ?? 0) + dx, + y: (inst.y ?? 0) + dy, + }) + return true + } + const layerId = store.selectedLayerId + if (!layerId) return false + const layer = store.activeLayers.find((l) => l.id === layerId) + if (!layer) return false + if (layer.locked) return false + for (const g of store.scene.groups ?? []) { + if (g.locked && g.childIds.includes(layerId)) return false + } + const samp = sampleLayer(layer, store.playhead) + store.upsertKeyframe(layerId, store.playhead, { + x: samp.x + dx, + y: samp.y + dy, + }) + return true +} + function onKeyDown(e: KeyboardEvent) { if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) { e.preventDefault() testsOpen.value = true return } + // Arrow-key nudge: 1px step, 10px with Shift. Intercepted before + // any other key handler so the stage steals the event from browser + // scroll / selectable regions. Skipped when the user is typing in + // an input — arrow keys are needed for cursor navigation there. + if ( + !e.ctrlKey && + !e.metaKey && + !e.altKey && + !isEditable(e.target) && + (e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown') + ) { + const step = e.shiftKey ? 10 : 1 + const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0 + const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0 + if (nudgeSelection(dx, dy)) { + e.preventDefault() + return + } + } const mod = e.ctrlKey || e.metaKey if (mod && !isEditable(e.target)) { const key = e.key.toLowerCase() diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index b13c410..245460b 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -4387,6 +4387,105 @@ export function registerAllTests(): void { }) }) + suite('app — arrow-key nudge', () => { + // 1px arrow nudge (+10 with Shift) on whichever selection is + // active: regular layer via upsertKeyframe at playhead, clip + // instance via setClipInstanceTransform. Locked targets + inputs + // are skipped so typing in the document drawer isn't stolen. + async function clearKeys() { + // Same escape-press routine the layer-delete tests use, so + // kf / marker selections from earlier tests don't interfere. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await nextTick() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await nextTick() + } + + function arrow(key: string, shift = false) { + document.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, shiftKey: shift }), + ) + } + + test('ArrowRight nudges the selected layer by +1 at the current playhead', async () => { + const s = makeFreshStore() + await clearKeys() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + const before = sampleLayer(l, s.playhead).x + arrow('ArrowRight') + await nextTick() + expect(sampleLayer(l, s.playhead).x).toBe(before + 1) + }) + + test('Shift+ArrowDown nudges by +10 on the y axis', async () => { + const s = makeFreshStore() + await clearKeys() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + const beforeY = sampleLayer(l, s.playhead).y + arrow('ArrowDown', true) + await nextTick() + expect(sampleLayer(l, s.playhead).y).toBe(beforeY + 10) + }) + + test('ArrowLeft on a selected clip-instance shifts its x by -1', async () => { + const s = makeFreshStore() + await clearKeys() + const clip = s.addClip('C') + clip.duration = 2 + const track = s.scene.clipTracks![0] + const ci = s.addClipInstance(clip.id, 0, track.id)! + s.selectClipInstance(ci.id) + const before = ci.x ?? 0 + arrow('ArrowLeft') + await nextTick() + expect(ci.x).toBe(before - 1) + }) + + test('nudge is skipped for a locked layer', async () => { + const s = makeFreshStore() + await clearKeys() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + s.setTrackLocked(l.id, true) + // Locked → selection cleared by deselectIfLocked. Re-select + // through the store directly so we can prove the arrow key + // doesn't still move the layer. + s.selectedLayerId = l.id + s.selectedLayerIds = [l.id] + const before = sampleLayer(l, s.playhead).x + arrow('ArrowRight') + await nextTick() + expect(sampleLayer(l, s.playhead).x).toBe(before) + }) + + test('nudge is skipped when the event target is an input', async () => { + const s = makeFreshStore() + await clearKeys() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + const before = sampleLayer(l, s.playhead).x + // Dispatch ON an input so isEditable() sees a text input target. + const input = document.createElement('input') + input.type = 'text' + document.body.appendChild(input) + try { + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ) + await nextTick() + expect(sampleLayer(l, s.playhead).x).toBe(before) + } finally { + document.body.removeChild(input) + } + }) + }) + suite('timeline — backspace deletes selected layer', () => { // Regression: Backspace / Delete used to only remove selected // keyframes and markers. The selected layer track had no shortcut @@ -9317,6 +9416,14 @@ export function registerAllTests(): void { test('toggling showLocked persists to localStorage', async () => { const { showLocked } = await import('@/composables/useShowLocked') const KEY = 'animatr:showLockedTracks' + // Flip away first so each of the assertion transitions below + // actually changes the ref — otherwise the first + // `showLocked.value = true` no-ops when the default was + // already true, the watch never fires, and localStorage + // stays at whatever leaked in from the previous test run + // (or stays null on a fresh dev-server restart). + showLocked.value = false + await tick() showLocked.value = true await tick() expect(localStorage.getItem(KEY)).toBe('1') diff --git a/src/version.ts b/src/version.ts index fdf097b..3279715 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '1.1.0' +export const APP_VERSION = '1.1.2' export const APP_NAME = 'RUWIO'