diff --git a/package.json b/package.json index aeb293b..47cfa39 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "0.14.0", + "version": "0.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 461d8ae..c9b311d 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -6,9 +6,11 @@ import { PhCornersIn, PhCrop, PhCursorClick, + PhFrameCorners, PhMaskHappy, PhPencilSimple, PhPolygon, + PhSlidersHorizontal, PhSquare, PhTrash, } from '@phosphor-icons/vue' @@ -16,6 +18,7 @@ import { useSceneStore } from '@/stores/scene' import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from '@/lib/interpolate' import { importImageFile, loadImage } from '@/lib/fileLoader' import { useClipartStore } from '@/stores/clipart' +import { useDocumentDrawer } from '@/composables/useDocumentDrawer' import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render' import type { Action, @@ -2958,11 +2961,268 @@ onBeforeUnmount(() => { container.value?.removeEventListener('drop', onContainerDrop) container.value?.removeEventListener('dblclick', onContainerDblClick) window.removeEventListener('keydown', handleDrawingKey) + document.removeEventListener('pointerdown', onStageMenuPointerDown, true) + document.removeEventListener('keydown', onStageMenuKeyDown) }) + +// --- Stage context menu --------------------------------------------- +// Right-clicking the stage (outside of a more specific handler like +// a vertex context menu) opens a small action menu. Currently two +// items; `stageResize` is a placeholder for the upcoming resize flow. + +interface StageMenuState { + x: number + y: number +} + +const stageMenu = ref(null) +const stageMenuRef = ref(null) +const documentDrawer = useDocumentDrawer() + +function onStageContextMenu(e: MouseEvent) { + // Don't hijack native right-click on editable elements (e.g. the + // rare inline inputs the stage overlays render). The App-level + // `onDocumentContextMenu` already suppresses the browser menu + // elsewhere, so a non-editable target here is safe to intercept. + const target = e.target as HTMLElement | null + if (target && target.closest('input, textarea, [contenteditable="true"]')) return + e.preventDefault() + e.stopPropagation() + stageMenu.value = { x: e.clientX, y: e.clientY } +} + +function closeStageMenu() { + stageMenu.value = null +} + +function onStageMenuPointerDown(e: PointerEvent) { + if (!stageMenu.value) return + const root = stageMenuRef.value + if (root && root.contains(e.target as Node)) return + closeStageMenu() +} + +function onStageMenuKeyDown(e: KeyboardEvent) { + if (!stageMenu.value) return + if (e.key !== 'Escape') return + e.preventDefault() + closeStageMenu() +} + +watch(stageMenu, (v) => { + if (v) { + document.addEventListener('pointerdown', onStageMenuPointerDown, true) + document.addEventListener('keydown', onStageMenuKeyDown) + } else { + document.removeEventListener('pointerdown', onStageMenuPointerDown, true) + document.removeEventListener('keydown', onStageMenuKeyDown) + } +}) + +function onMenuDocumentSettings() { + closeStageMenu() + documentDrawer.openDrawer() +} + +function onMenuStageResize() { + closeStageMenu() + // Resizing without crop obscures the effect of the drag — elements + // outside the current canvas rect stay painted, so the user can't + // tell where the new edge actually lands. Force crop on for the + // duration of the mode; snapshot whatever the user had before so we + // can restore it on exit. + prevCropBeforeResize = cropObjects.value + cropObjects.value = true + stageResizeMode.value = true +} + +// --- Stage resize mode ---------------------------------------------- +// Eight drag handles overlay the stage rectangle. Dragging any handle +// resizes the canvas; handles that include a west or north component +// also shift every scene-world coordinate by the same delta so content +// stays pinned to its visible position while the canvas grows or +// shrinks around it. + +type ResizeHandleDir = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw' +const RESIZE_HANDLES: ResizeHandleDir[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] +const MIN_CANVAS_SIZE = 20 + +const stageResizeMode = ref(false) + +interface ResizeDragState { + dir: ResizeHandleDir + startClientX: number + startClientY: number + startWidth: number + startHeight: number + /** Cumulative world-space shift already applied during this drag. + * Each frame we compute the TARGET total shift and push only the + * delta relative to this — avoids double-applying as the pointer + * moves across multiple frames. */ + appliedShiftX: number + appliedShiftY: number + pointerId: number +} + +const resizeDrag = ref(null) + +// Snapshot of the stage-crop toggle from before resize mode kicked in, +// so the exit path can restore it. `null` means "not currently in +// resize mode" (nothing to restore). +let prevCropBeforeResize: boolean | null = null + +function exitStageResizeMode() { + stageResizeMode.value = false + resizeDrag.value = null + if (prevCropBeforeResize !== null) { + cropObjects.value = prevCropBeforeResize + prevCropBeforeResize = null + } +} + +function onResizeModeKeyDown(e: KeyboardEvent) { + if (!stageResizeMode.value) return + if (e.key !== 'Escape') return + e.preventDefault() + exitStageResizeMode() +} + +watch(stageResizeMode, (v) => { + if (v) document.addEventListener('keydown', onResizeModeKeyDown) + else document.removeEventListener('keydown', onResizeModeKeyDown) +}) + +/** Screen-space rect for the canvas — matches how Konva renders it + * (view offset + canvas * zoom). Handles are anchored to these + * positions. */ +const stageScreenRect = computed(() => { + const z = zoom.value + const w = store.project.canvas.width * z + const h = store.project.canvas.height * z + return { + left: viewX.value, + top: viewY.value, + width: w, + height: h, + } +}) + +function handleStyle(dir: ResizeHandleDir): Record { + const rect = stageScreenRect.value + const half = 6 // handle is 12×12, centre the chip on the edge + const cx = rect.left + rect.width / 2 - half + const cy = rect.top + rect.height / 2 - half + const left = rect.left - half + const right = rect.left + rect.width - half + const top = rect.top - half + const bottom = rect.top + rect.height - half + const pos: Record = { + nw: { left, top }, + n: { left: cx, top }, + ne: { left: right, top }, + e: { left: right, top: cy }, + se: { left: right, top: bottom }, + s: { left: cx, top: bottom }, + sw: { left, top: bottom }, + w: { left, top: cy }, + } + return { left: `${pos[dir].left}px`, top: `${pos[dir].top}px` } +} + +function resizeTargetsForDelta( + dir: ResizeHandleDir, + dxWorld: number, + dyWorld: number, + startW: number, + startH: number, +): { width: number; height: number; shiftX: number; shiftY: number } { + let newW = startW + let newH = startH + let shiftX = 0 + let shiftY = 0 + if (dir.includes('e')) newW = startW + dxWorld + if (dir.includes('w')) { + newW = startW - dxWorld + shiftX = -dxWorld + } + if (dir.includes('s')) newH = startH + dyWorld + if (dir.includes('n')) { + newH = startH - dyWorld + shiftY = -dyWorld + } + // Enforce a floor. If resizing would push below MIN_CANVAS_SIZE on + // a west/north drag, clamp both the size AND the shift so elements + // don't skate off while the canvas is pinned at the floor. + if (newW < MIN_CANVAS_SIZE) { + const clampDx = dxWorld + (MIN_CANVAS_SIZE - newW) + newW = MIN_CANVAS_SIZE + if (dir.includes('w')) shiftX = -clampDx + } + if (newH < MIN_CANVAS_SIZE) { + const clampDy = dyWorld + (MIN_CANVAS_SIZE - newH) + newH = MIN_CANVAS_SIZE + if (dir.includes('n')) shiftY = -clampDy + } + return { width: newW, height: newH, shiftX, shiftY } +} + +function onResizeHandlePointerDown(e: PointerEvent, dir: ResizeHandleDir) { + if (!stageResizeMode.value) return + e.preventDefault() + e.stopPropagation() + const el = e.currentTarget as HTMLElement + el.setPointerCapture(e.pointerId) + resizeDrag.value = { + dir, + startClientX: e.clientX, + startClientY: e.clientY, + startWidth: store.project.canvas.width, + startHeight: store.project.canvas.height, + appliedShiftX: 0, + appliedShiftY: 0, + pointerId: e.pointerId, + } +} + +function onResizeHandlePointerMove(e: PointerEvent) { + const drag = resizeDrag.value + if (!drag || drag.pointerId !== e.pointerId) return + const z = zoom.value || 1 + const dxWorld = (e.clientX - drag.startClientX) / z + const dyWorld = (e.clientY - drag.startClientY) / z + const { width, height, shiftX, shiftY } = resizeTargetsForDelta( + drag.dir, + dxWorld, + dyWorld, + drag.startWidth, + drag.startHeight, + ) + store.setCanvasSize(width, height) + const deltaShiftX = shiftX - drag.appliedShiftX + const deltaShiftY = shiftY - drag.appliedShiftY + if (deltaShiftX !== 0 || deltaShiftY !== 0) { + store.shiftSceneWorldCoords(deltaShiftX, deltaShiftY) + drag.appliedShiftX = shiftX + drag.appliedShiftY = shiftY + } +} + +function onResizeHandlePointerUp(e: PointerEvent) { + const drag = resizeDrag.value + if (!drag || drag.pointerId !== e.pointerId) return + const el = e.currentTarget as HTMLElement + if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) + resizeDrag.value = null +} @@ -3403,6 +3723,127 @@ onBeforeUnmount(() => { color: var(--accent); } +/* Stage-level right-click menu. Fixed-positioned so the cursor point + (clientX/clientY) maps 1:1 regardless of stage scrolling. */ +.stage-menu { + position: fixed; + display: flex; + flex-direction: column; + min-width: 180px; + background: var(--panel); + border: 1px solid var(--border-strong, var(--border)); + border-radius: var(--radius); + box-shadow: var(--shadow-md, 0 8px 24px rgba(0, 0, 0, 0.35)); + z-index: 1000; + padding: 5px; + font-size: 12px; +} + +.stage-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + background: transparent; + border: none; + color: var(--text); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm); + width: 100%; +} + +.stage-menu-item:hover:not(:disabled) { + background: var(--panel-alt); +} + +.stage-menu-icon { + flex-shrink: 0; + color: var(--text-dim); +} + +/* Resize-mode handles. Eight 12×12 accent-tinted chips anchored to + the canvas edges/corners. Cursors hint the axis so the user knows + the drag direction without reading docs. */ +.stage-resize-handle { + position: absolute; + width: 12px; + height: 12px; + background: var(--accent); + border: 2px solid #fff; + border-radius: 3px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + z-index: 15; + touch-action: none; + user-select: none; +} + +.stage-resize-handle.dir-n, +.stage-resize-handle.dir-s { + cursor: ns-resize; +} +.stage-resize-handle.dir-e, +.stage-resize-handle.dir-w { + cursor: ew-resize; +} +.stage-resize-handle.dir-ne, +.stage-resize-handle.dir-sw { + cursor: nesw-resize; +} +.stage-resize-handle.dir-nw, +.stage-resize-handle.dir-se { + cursor: nwse-resize; +} + +/* Floating exit affordance. Anchored to the top of the stage area so + the user has a visible "I'm done" target while the handles are up. + Esc still works as a shortcut. */ +.stage-resize-banner { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 20; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 6px 8px 6px 14px; + background: var(--panel); + border: 1px solid var(--border-strong, var(--border)); + border-radius: 999px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); + font-size: 12px; + color: var(--text); + pointer-events: auto; +} + +.stage-resize-label { + font-weight: 600; + color: var(--accent); +} + +.stage-resize-hint { + color: var(--text-dim); + font-size: 11px; +} + +.stage-resize-done { + padding: 4px 12px; + background: var(--accent); + border: 1px solid transparent; + color: var(--accent-text, #fff); + border-radius: 999px; + font-size: 11px; + font-weight: 500; + cursor: pointer; +} + +.stage-resize-done:hover { + filter: brightness(1.1); +} + .mask-preview-canvas { position: absolute; inset: 0; diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index e7d6550..a016a50 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -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 `` 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 + 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 + 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() diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 143413d..364a9bc 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -322,6 +322,15 @@ export const en = { addPolygon: 'Add polygon hit area', remove: 'Remove selected hit area', }, + menu: { + documentSettings: 'Document settings', + stageResize: 'Resize stage', + }, + resize: { + banner: 'Resizing stage', + hint: 'Drag a handle to resize — press Esc or click Done.', + done: 'Done', + }, }, settings: { diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 2cd6fea..ef57065 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -776,6 +776,46 @@ export const useSceneStore = defineStore('scene', { this.project.canvas.width = Math.max(1, Math.round(width)) this.project.canvas.height = Math.max(1, Math.round(height)) }, + + /** Shift every scene-world-space coordinate across the whole project + * by (dx, dy). Used by the stage resize drag: when grabbing the + * left/top edge the canvas origin effectively slides, and every + * element needs to slide the same amount to stay visually pinned. + * + * What shifts: + * - layer.base.{x,y} + * - each keyframe.props.{x,y} on the main track + * - each keyframe.props.{x,y} on separated position track + * - keyframe.ctrlX / ctrlY (bezier handles live in world space) + * - clipInstance.{x,y} (static per-instance offset) + * + * What stays put: clip-library layers (local to the clip instance + * origin), button-asset internals (their own canvas), polygon point + * clouds on keyframe.props.points (local to the layer origin). */ + shiftSceneWorldCoords(dx: number, dy: number) { + if (dx === 0 && dy === 0) return + const shiftProps = (props: Partial | undefined) => { + if (!props) return + if (typeof props.x === 'number') props.x += dx + if (typeof props.y === 'number') props.y += dy + } + const shiftKeyframe = (kf: Keyframe) => { + shiftProps(kf.props) + if (typeof kf.ctrlX === 'number') kf.ctrlX += dx + if (typeof kf.ctrlY === 'number') kf.ctrlY += dy + } + for (const page of this.project.scenes) { + for (const layer of page.layers) { + shiftProps(layer.base) + for (const kf of layer.keyframes ?? []) shiftKeyframe(kf) + for (const kf of layer.positionKeyframes ?? []) shiftKeyframe(kf) + } + for (const ci of page.clipInstances ?? []) { + if (typeof ci.x === 'number') ci.x += dx + if (typeof ci.y === 'number') ci.y += dy + } + } + }, setCanvasBackground(color: string) { this.project.canvas.background = color }, diff --git a/src/version.ts b/src/version.ts index 030e6b9..1bf2134 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.14.0' +export const APP_VERSION = '0.15.0' export const APP_NAME = 'RUWIO'