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:
1 parent
01c13981c4
commit
289fa770b0
4 files changed
+169
-2
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+60
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.1.0'
|
||||
export const APP_VERSION = '1.1.2'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user