1.1.2: arrow-key nudge for the selected stage object

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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-25 01:07:48 +02:00
1 parent 01c13981c4
commit 289fa770b0
4 files changed
+169 -2

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ruwio", "name": "ruwio",
"private": true, "private": true,
"version": "1.1.0", "version": "1.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+60
View File
@@ -51,6 +51,7 @@ import {
type StoredProject, type StoredProject,
} from './lib/projectStore' } from './lib/projectStore'
import { importImageFile } from './lib/fileLoader' import { importImageFile } from './lib/fileLoader'
import { sampleLayer } from './lib/interpolate'
import { useClipartStore } from './stores/clipart' import { useClipartStore } from './stores/clipart'
import { createBlankProject, useSceneStore } from './stores/scene' import { createBlankProject, useSceneStore } from './stores/scene'
@@ -297,12 +298,71 @@ function onDocumentContextMenu(e: MouseEvent) {
e.preventDefault() 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) { function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) { if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault() e.preventDefault()
testsOpen.value = true testsOpen.value = true
return 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 const mod = e.ctrlKey || e.metaKey
if (mod && !isEditable(e.target)) { if (mod && !isEditable(e.target)) {
const key = e.key.toLowerCase() const key = e.key.toLowerCase()
+107
View File
@@ -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', () => { suite('timeline — backspace deletes selected layer', () => {
// Regression: Backspace / Delete used to only remove selected // Regression: Backspace / Delete used to only remove selected
// keyframes and markers. The selected layer track had no shortcut // 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 () => { test('toggling showLocked persists to localStorage', async () => {
const { showLocked } = await import('@/composables/useShowLocked') const { showLocked } = await import('@/composables/useShowLocked')
const KEY = 'animatr:showLockedTracks' 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 showLocked.value = true
await tick() await tick()
expect(localStorage.getItem(KEY)).toBe('1') expect(localStorage.getItem(KEY)).toBe('1')
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.1.0' export const APP_VERSION = '1.1.2'
export const APP_NAME = 'RUWIO' export const APP_NAME = 'RUWIO'