0.15.0: stage context menu + drag-to-resize stage

- Right-clicking the stage opens a small menu with Document settings
  (pops the document drawer) and Resize stage. Closes on outside
  click and ESC.
- Resize stage drops eight accent-coloured drag handles around the
  canvas rect (N/NE/E/SE/S/SW/W/NW). East/south handles just grow or
  shrink the canvas; west/north (and their corners) also shift every
  scene-world coordinate in lock-step so content stays pinned to its
  visible position while the canvas re-aligns around it.
- Enter forces stage-crop on so the user can see where the new edge
  lands; exit restores the previous crop setting. Exit paths: Done
  button in the floating banner, ESC, or the restored crop toggle.
- New store action shiftSceneWorldCoords(dx, dy) covers layer base +
  main/position keyframes + bezier control points + clipInstance x/y
  across every scene page. Clip-library layers, button assets, hit
  shapes and polygon vertex clouds stay put — those live in local
  coord systems, not scene-world space.
- Tests: store coverage (shift across pages, positionKeyframes,
  ctrlX/Y, clipInstance, clip-library untouched, (0,0) no-op) plus UI
  coverage (context-menu items, Resize stage enters mode + renders 8
  handles, ESC and Done both exit, crop forced on and restored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 09:10:54 +02:00
1 parent 9fe86b62b9
commit 827d80bfd0
6 files changed
+847 -3

No files matched your search

+354
View File
@@ -46,6 +46,7 @@ import DocumentDrawer from '@/components/DocumentDrawer.vue'
import ActionEditor from '@/components/ActionEditor.vue'
import Timeline from '@/components/Timeline.vue'
import AssetSidebar from '@/components/AssetSidebar.vue'
import CanvasStage from '@/components/CanvasStage.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue'
import HelpDialog from '@/components/HelpDialog.vue'
import { createAudioRuntime } from '@/composables/useAudioRuntime'
@@ -3669,6 +3670,105 @@ export function registerAllTests(): void {
})
})
suite('scene store — shiftSceneWorldCoords (resize)', () => {
test('shifts base + keyframe x/y across every scene page', () => {
const s = makeFreshStore()
// Second page so we can check the shift touches pages other
// than the active one too.
s.addScene('Second')
s.switchScene(s.project.scenes[0].id)
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const l1 = s.addLayer(a.id)!
s.upsertKeyframe(l1.id, 0, { x: 100, y: 50 })
s.switchScene(s.project.scenes[1].id)
const l2 = s.addLayer(a.id)!
s.upsertKeyframe(l2.id, 0, { x: 200, y: 80 })
const beforeBase1 = { ...l1.base }
const beforeBase2 = { ...l2.base }
s.shiftSceneWorldCoords(30, -10)
expect(l1.base.x).toBe(beforeBase1.x + 30)
expect(l1.base.y).toBe(beforeBase1.y - 10)
expect(l2.base.x).toBe(beforeBase2.x + 30)
expect(l2.base.y).toBe(beforeBase2.y - 10)
const k1 = l1.keyframes.find((k) => k.time === 0)!
const k2 = l2.keyframes.find((k) => k.time === 0)!
expect(k1.props.x).toBe(130)
expect(k1.props.y).toBe(40)
expect(k2.props.x).toBe(230)
expect(k2.props.y).toBe(70)
})
test('shifts positionKeyframes on separated layers', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.setLayerSeparated(l.id, true)
s.upsertKeyframe(l.id, 1, { x: 10, y: 20 })
const pk = (l.positionKeyframes ?? []).find((k) => k.time === 1)!
s.shiftSceneWorldCoords(5, 7)
expect(pk.props.x).toBe(15)
expect(pk.props.y).toBe(27)
})
test('shifts keyframe bezier control points (ctrlX / ctrlY)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
const kf = l.keyframes.find((k) => k.time === 1)!
kf.ctrlX = 80
kf.ctrlY = 30
s.shiftSceneWorldCoords(20, 15)
expect(kf.ctrlX).toBe(100)
expect(kf.ctrlY).toBe(45)
})
test('shifts clipInstance x/y but leaves clip-library layer keyframes alone', () => {
const s = makeFreshStore()
const c = s.addClip('Lib')
c.duration = 2
// Clip-library layer: should NOT be shifted (local to clip origin)
c.layers = [
{
id: 'lib-layer',
name: 'L',
assetId: 'none',
visible: true,
base: { x: 10, y: 10, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{
id: 'k1',
time: 0,
props: { x: 10, y: 10 },
easing: 'linear',
},
],
},
]
const t = s.scene.clipTracks![0]
const ci = s.addClipInstance(c.id, 0, t.id)!
ci.x = 40
ci.y = 60
s.shiftSceneWorldCoords(7, -3)
// Clip instance's static offset shifted with the world.
expect(ci.x).toBe(47)
expect(ci.y).toBe(57)
// Clip library's inner layer untouched.
expect(c.layers[0].base.x).toBe(10)
expect(c.layers[0].keyframes[0].props.x).toBe(10)
})
test('(0, 0) delta is a no-op', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const l = s.addLayer(a.id)!
const before = { ...l.base }
s.shiftSceneWorldCoords(0, 0)
expect(l.base.x).toBe(before.x)
expect(l.base.y).toBe(before.y)
})
})
suite('polygon — store actions', () => {
function makePolygonLayer() {
const s = makeFreshStore()
@@ -5802,6 +5902,260 @@ export function registerAllTests(): void {
})
})
suite('ui — CanvasStage context menu', () => {
// mountComponent doesn't install VueKonva, so the `<v-stage>` tree
// renders as unknown elements. That's fine for these tests — we
// only care about the host `.stage-container`, the contextmenu
// handler on it, and the menu overlay. Konva canvas rendering is
// out of scope here.
function dispatchContextMenu(el: Element, x = 50, y = 50) {
el.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
}),
)
}
test('right-click opens a menu with two actions', async () => {
makeFreshStore()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().closeDrawer()
const handle = mountComponent(CanvasStage, {})
await nextTick()
const container = handle.host.querySelector(
'.stage-container',
) as HTMLElement
expect(container).toBeTruthy()
expect(handle.host.querySelector('.stage-menu')).toBeNull()
dispatchContextMenu(container, 120, 80)
await nextTick()
const menu = handle.host.querySelector('.stage-menu') as HTMLElement
expect(menu).toBeTruthy()
const items = menu.querySelectorAll('.stage-menu-item')
expect(items.length).toBe(2)
// Positioned at the cursor via fixed coordinates.
expect(menu.style.left).toBe('120px')
expect(menu.style.top).toBe('80px')
handle.unmount()
})
test('clicking Document settings opens the document drawer', async () => {
makeFreshStore()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
const dd = useDocumentDrawer()
dd.closeDrawer()
expect(dd.open.value).toBe(false)
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
const firstItem = handle.host.querySelector(
'.stage-menu .stage-menu-item',
) as HTMLButtonElement
expect(firstItem.textContent?.trim()).toBe(
i18nT('canvas.menu.documentSettings'),
)
firstItem.click()
await nextTick()
expect(dd.open.value).toBe(true)
// Menu dismissed on click.
expect(handle.host.querySelector('.stage-menu')).toBeNull()
dd.closeDrawer()
handle.unmount()
})
test('clicking Resize stage closes the menu and switches to resize mode', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
const items = handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
) as NodeListOf<HTMLButtonElement>
const resize = items[1]
expect(resize.textContent?.trim()).toBe(
i18nT('canvas.menu.stageResize'),
)
resize.click()
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeNull()
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(8)
// Clean up so resize mode doesn't leak into following tests.
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await nextTick()
handle.unmount()
})
test('ESC dismisses the menu', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeTruthy()
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeNull()
handle.unmount()
})
test('pointerdown outside the menu dismisses it', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeTruthy()
// Click somewhere outside the menu — `document.body` will do.
document.body.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true }),
)
await nextTick()
expect(handle.host.querySelector('.stage-menu')).toBeNull()
handle.unmount()
})
test('clicking Resize stage enters resize mode and renders 8 handles', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
const items = handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
) as NodeListOf<HTMLButtonElement>
items[1].click()
await nextTick()
// Menu is gone, handles appear.
expect(handle.host.querySelector('.stage-menu')).toBeNull()
const handles = handle.host.querySelectorAll('.stage-resize-handle')
expect(handles.length).toBe(8)
const dirs = Array.from(handles).map((h) =>
Array.from(h.classList).find((c) => c.startsWith('dir-')),
)
// Every compass direction is present once.
for (const d of ['dir-n', 'dir-ne', 'dir-e', 'dir-se', 'dir-s', 'dir-sw', 'dir-w', 'dir-nw']) {
expect(dirs.includes(d)).toBe(true)
}
// Clean up — ESC exits so the next test doesn't inherit the mode.
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await nextTick()
handle.unmount()
})
test('ESC exits resize mode and hides the handles', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
const resize = handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
)[1] as HTMLButtonElement
resize.click()
await nextTick()
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(8)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await nextTick()
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(0)
handle.unmount()
})
test('Done button in the resize banner exits resize mode', async () => {
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
dispatchContextMenu(
handle.host.querySelector('.stage-container') as HTMLElement,
)
await nextTick()
;(handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
)[1] as HTMLButtonElement).click()
await nextTick()
const done = handle.host.querySelector(
'.stage-resize-done',
) as HTMLButtonElement
expect(done).toBeTruthy()
done.click()
await nextTick()
expect(handle.host.querySelectorAll('.stage-resize-handle').length).toBe(0)
expect(handle.host.querySelector('.stage-resize-banner')).toBeNull()
handle.unmount()
})
test('resize mode forces crop ON and restores the prior setting on exit', async () => {
// Crop obscures the effect of the drag — new canvas edges only
// read as new when everything outside is clipped. Enter forces
// crop on; exit restores whatever the user had configured before.
makeFreshStore()
const handle = mountComponent(CanvasStage, {})
await nextTick()
const stage = handle.host.querySelector('.stage-container') as HTMLElement
// Starts with the crop button inactive (default false).
const cropBtn = stage.querySelector('.zoom-chip:has(svg), .zoom-btn') // best-effort
// Easier: check the active class on the crop control via its
// tooltip-carrying chip — the aria-label flips between on/off.
function isCroppedActive() {
const btn = stage.querySelector(
`[aria-label="${i18nT('canvas.cropOn')}"]`,
) ||
stage.querySelector(
`[aria-label="${i18nT('canvas.cropOff')}"]`,
)
return btn?.getAttribute('aria-label') === i18nT('canvas.cropOff')
}
const initiallyCropped = isCroppedActive()
// Enter resize mode via context menu.
dispatchContextMenu(stage)
await nextTick()
;(handle.host.querySelectorAll(
'.stage-menu .stage-menu-item',
)[1] as HTMLButtonElement).click()
await nextTick()
// While in resize mode, crop is forced on.
expect(isCroppedActive()).toBe(true)
// Exit via Done.
;(handle.host.querySelector(
'.stage-resize-done',
) as HTMLButtonElement).click()
await nextTick()
// Crop restored to whatever it was before.
expect(isCroppedActive()).toBe(initiallyCropped)
void cropBtn
handle.unmount()
})
})
suite('ui — AssetSidebar audio section', () => {
test('lists audio assets with a duration label under the Audio heading', async () => {
const s = makeFreshStore()