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",
|
"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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
Reference in new issue
Block a user