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

+442 -1
View File
@@ -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;