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:
1 parent
9fe86b62b9
commit
827d80bfd0
6 files changed
+847
-3
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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<StageMenuState | null>(null)
|
||||
const stageMenuRef = ref<HTMLDivElement | null>(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<ResizeDragState | null>(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<string, string> {
|
||||
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<ResizeHandleDir, { left: number; top: number }> = {
|
||||
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
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="stage-container" :class="{ drawing: store.drawingMode === 'freeform' }">
|
||||
<div
|
||||
ref="container"
|
||||
class="stage-container"
|
||||
:class="{ drawing: store.drawingMode === 'freeform' }"
|
||||
@contextmenu="onStageContextMenu"
|
||||
>
|
||||
<video
|
||||
v-if="store.scene.backgroundVideo"
|
||||
ref="bgVideoEl"
|
||||
@@ -3347,6 +3607,66 @@ onBeforeUnmount(() => {
|
||||
{{ t('canvas.vertexSplitCurve') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="stageResizeMode">
|
||||
<div
|
||||
v-for="dir in RESIZE_HANDLES"
|
||||
:key="dir"
|
||||
class="stage-resize-handle"
|
||||
:class="['dir-' + dir]"
|
||||
:style="handleStyle(dir)"
|
||||
@pointerdown="onResizeHandlePointerDown($event, dir)"
|
||||
@pointermove="onResizeHandlePointerMove"
|
||||
@pointerup="onResizeHandlePointerUp"
|
||||
@pointercancel="onResizeHandlePointerUp"
|
||||
></div>
|
||||
<div
|
||||
class="stage-resize-banner"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
>
|
||||
<span class="stage-resize-label">{{ t('canvas.resize.banner') }}</span>
|
||||
<span class="stage-resize-hint">{{ t('canvas.resize.hint') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="stage-resize-done"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
@click.stop="exitStageResizeMode"
|
||||
>
|
||||
{{ t('canvas.resize.done') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="stageMenu"
|
||||
ref="stageMenuRef"
|
||||
class="stage-menu"
|
||||
:style="{ left: stageMenu.x + 'px', top: stageMenu.y + 'px' }"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="stage-menu-item"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
@click.stop="onMenuDocumentSettings"
|
||||
>
|
||||
<PhSlidersHorizontal :size="13" class="stage-menu-icon" />
|
||||
{{ t('canvas.menu.documentSettings') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="stage-menu-item"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
@click.stop="onMenuStageResize"
|
||||
>
|
||||
<PhFrameCorners :size="13" class="stage-menu-icon" />
|
||||
{{ t('canvas.menu.stageResize') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<AnimatableProps> | 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
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.14.0'
|
||||
export const APP_VERSION = '0.15.0'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user