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'
|