1.2.0: SVG asset editor + end keyframe type + UX bundle

This release bundles three independently-useful pieces of work that
overlap heavily in shared files (types, store, i18n, App, CanvasStage,
tests), so they ship together.

SVG asset editor
- New asset kind 'svg' (SvgAsset { canvas, layers }), authored from
  inside the existing shape primitives (rect / circle / text / polygon /
  freeform). "+" button under the Images sidebar header; opens a
  sub-editor that swaps the library sidebar for a dedicated layer
  panel, hides the timeline, and uses the same canvas-resize flow
  ("Edit canvas size" via right-click → no Document settings entry
  while in scope). Children carry a single t=0 keyframe each.
- Multi-select rotation in SVG scope only: a temporary group pivot
  appears at the selection centroid (draggable), rotation around it
  shifts each layer's x/y/rotation. Pivot follows the centroid drift
  on Konva-transformer scale/move. Outside SVG, transformer
  rotateEnabled stays off (group-rotate write-back is unreliable
  per prior feedback).
- Authored SVGs are placed as layers in the main scene and rendered
  via drawMaskedGroup at t=0; thumbnails surface in the sidebar via
  renderSvgThumbnail. Drop / click / drag / dbl-click-to-edit work
  via a transparent svg-hit catcher rect routed in the stage handler.
- Freeform default stroke is red (#e23b3b) so the open path is
  visible immediately. addDrawingPoint resolves the layer through
  activeLayers so freeform draws correctly inside SVG and clip
  scopes too.

End keyframe type
- Keyframe.kind: 'normal' | 'end'. End kfs carry no props, mark the
  visibility cap. layerActiveRange / isLayerActiveAt / sampleKeyframeSet
  ignore them for interpolation and use them only as the visible-from
  / visible-until bounds.
- One-time migration in normalizeProject synthesizes an end kf at the
  time of the last normal kf for layers that relied on the legacy
  "last kf = disappears" semantics; layers using hold:true to extend
  visibility past the end skip the migration. Idempotent.
- Timeline UI: new "+end" toolbar button (multi-select fan-out, toggle),
  "Convert to end" / "Back to normal" entries in the kf context menu
  (easing / still-frame / duplicate hidden for end kfs), end kfs
  render as a flat axis-aligned square (vs. the diamond-rotated normal
  kf). Render filter drops end kfs that collide with a normal kf at
  the same time so legacy migrated projects don't read as four-point
  stars.

UX polish
- TAB-collapse stage anchoring: the canvas content stays put when the
  asset / properties asides toggle (CanvasStage's ResizeObserver
  compensates viewX/Y by the container's bounding-rect drift).
- Topbar clipboard button copies the current frame to the system
  clipboard as a PNG via navigator.clipboard.write([ClipboardItem]).
- chromeClick predicate adds .timeline to the editing-panels bail so
  the synthetic click after a Timeline box-select drag no longer
  wipes the just-committed kf selection.
- Right-click on a polygon vertex stops propagation so the vertex
  context menu wins instead of the generic stage menu opening on top.
This commit is contained in:
th committed 2026-04-25 21:45:58 +02:00
1 parent 492f25f629
commit d03fbe4ecb
15 files changed
+2529 -222

No files matched your search

+521 -33
View File
@@ -32,6 +32,7 @@ import type {
Keyframe,
Layer,
Point,
SvgAsset,
} from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { useButtonRuntime } from '@/composables/useButtonRuntime'
@@ -107,7 +108,7 @@ function clamp(v: number, lo: number, hi: number) {
function fitToContainer() {
const cw = containerSize.value.width
const ch = containerSize.value.height
const ec = store.activeButtonAsset ? store.activeButtonAsset.canvas : store.scene.canvas
const ec = effectiveCanvas.value
const lw = ec.width
const lh = ec.height
if (!cw || !ch) return
@@ -120,7 +121,7 @@ function fitToContainer() {
function zoomToOne() {
const cw = containerSize.value.width
const ch = containerSize.value.height
const ec = store.activeButtonAsset ? store.activeButtonAsset.canvas : store.scene.canvas
const ec = effectiveCanvas.value
const lw = ec.width
const lh = ec.height
if (!cw || !ch) return
@@ -130,6 +131,8 @@ function zoomToOne() {
}
const effectiveCanvas = computed(() => {
const sa = store.activeSvgAsset
if (sa) return sa.canvas
const ba = store.activeButtonAsset
if (ba) return ba.canvas
return store.scene.canvas
@@ -408,6 +411,10 @@ interface CanvasResizeHandle {
}
const canvasResizeHandles = computed<CanvasResizeHandle[]>(() => {
// Inline handles are a button-asset only affordance. SVG-asset
// resize goes through the existing 8-handle stage-resize mode
// (right-click → "Stage resize") so the canvas stays visually
// clean instead of looking permanently in-resize.
if (!store.activeButtonAssetId) return []
const w = effectiveCanvas.value.width
const h = effectiveCanvas.value.height
@@ -434,19 +441,22 @@ function canvasResizeHandleConfig(h: CanvasResizeHandle) {
}
function onCanvasResizeDrag(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
const sa = store.activeSvgAsset
const ba = store.activeButtonAsset
if (!ba) return
const target = sa ?? ba
if (!target) return
const node = e.target
const s = 10 / zoom.value
let w = ba.canvas.width
let h = ba.canvas.height
let w = target.canvas.width
let h = target.canvas.height
if (edge === 'right' || edge === 'corner') {
w = Math.max(10, Math.round(node.x() + s / 2))
}
if (edge === 'bottom' || edge === 'corner') {
h = Math.max(10, Math.round(node.y() + s / 2))
}
store.setButtonAssetCanvas(ba.id, w, h)
if (sa) store.setSvgAssetCanvas(sa.id, w, h)
else if (ba) store.setButtonAssetCanvas(ba.id, w, h)
}
function onCanvasResizeDragEnd(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
@@ -530,6 +540,23 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
/** Invisible Konva rect that covers the SVG asset's canvas rect.
* Children are non-listening (buttonChildConfig) so without a hit
* surface the layer would fall through to the stage and stay
* unselectable. The catcher routes mousedown to the stage handler
* via its `svg-hit:${id}` name, mirroring the button-hit pattern. */
function svgHitCatcherConfig(asset: SvgAsset, layer: Layer) {
return {
x: 0,
y: 0,
width: asset.canvas.width,
height: asset.canvas.height,
fill: 'rgba(0,0,0,0.001)',
listening: !isLayerEffectivelyLocked(layer),
name: `svg-hit:${layer.id}`,
}
}
function buttonStackForTime(
asset: ButtonAsset,
stateTime: number,
@@ -548,6 +575,28 @@ function buttonStackForTime(
return out
}
/** Static render stack for an SVG asset placed in the main scene.
* All child keyframes live at t=0 by construction, so there's no
* per-state lookup — just sample each visible layer once. */
function svgStackForAsset(
asset: SvgAsset,
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
const layers = asset.layers.slice().reverse()
for (const l of layers) {
if (!l.visible) continue
if (l.editorHidden) continue
const a = assetMap.value.get(l.assetId)
if (!a) continue
out.push({ layer: l, asset: a, props: sampleLayer(l, 0) })
}
return out
}
function svgAssetFor(asset: Asset): SvgAsset | null {
return asset.kind === 'svg' ? asset : null
}
function setStageCursor(cursor: string) {
const el = stageRef.value?.getStage().container()
if (el) el.style.cursor = cursor
@@ -662,6 +711,20 @@ const centerCrosshairConfigs = computed(() => {
})
const backgroundConfig = computed(() => {
// SVG-asset edit: same dark "outside the document" fill as the
// button editor so the user sees where the asset's canvas ends.
// The asset itself composites with alpha; the dark backdrop is
// visual chrome only.
if (store.activeSvgAssetId) {
return {
x: 0,
y: 0,
width: effectiveCanvas.value.width,
height: effectiveCanvas.value.height,
listening: false,
fill: '#1a1a1f',
}
}
// In button-asset edit: checkerboard-ish transparent background so the
// user sees where the button's canvas ends.
if (store.activeButtonAssetId) {
@@ -854,6 +917,29 @@ const stackItems = computed<StackItem[]>(() => {
}
return out
}
// Svg-asset edit scope: render the asset's child layers at t=0.
// No time-axis filtering — every visible layer always shows.
if (store.activeSvgAssetId) {
const out: StackItem[] = []
const sa = store.activeSvgAsset
if (!sa) return out
const layers = sa.layers.slice().reverse()
for (const layer of layers) {
if (!layer.visible) continue
if (layer.editorHidden) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
out.push({
kind: 'layer',
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, 0), layer.id),
},
})
}
return out
}
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
if (store.activeClipId) {
const out: StackItem[] = []
@@ -1591,6 +1677,9 @@ const pivotInfo = computed<PivotInfo | null>(() => {
})
const pivotHandleConfig = computed(() => {
// Per-layer pivot only makes sense for a single selection. Multi
// selection has its own temp-pivot block below (SVG scope only).
if (store.selectedLayerIds.length > 1) return null
const info = pivotInfo.value
if (!info) return null
return {
@@ -1779,6 +1868,11 @@ const vertexMenu = ref<VertexMenuState | null>(null)
function onVertexContextMenu(e: Konva.KonvaEventObject<PointerEvent>, index: number) {
e.evt.preventDefault()
// Stop the DOM-level contextmenu from bubbling to the
// .stage-container handler, which would otherwise pop the
// generic stage menu (Document settings / Stage resize) on top
// of our vertex menu.
e.evt.stopPropagation()
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
@@ -2115,15 +2209,15 @@ function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
}
const transformerConfig = computed(() => {
// Rotation on a multi-selection would pin every node to a single
// shared pivot (the transformer bbox centre), which isn't what
// users expect — each layer has its own offset / pivot and should
// only rotate around its own origin. Disable rotation whenever
// more than one node is wrapped so the handle disappears and no
// ambiguous combined rotate can happen.
const multi = store.selectedLayerIds.length > 1
// Single selection → rotation is handled by the pivot-anchored
// custom handle below (the user-set pivot). The Konva transformer
// anchor stays off so we don't show two rotation knobs.
// Multi selection → group rotate has been tried and didn't work
// (write-back per node fights with the bbox-centred transform),
// so it stays disabled. The user explicitly preferred no
// rotation in multi-select over a half-broken group rotate.
return {
rotateEnabled: !multi,
rotateEnabled: false,
rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
rotationSnapTolerance: 4,
enabledAnchors: [
@@ -2612,6 +2706,14 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
}
if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
// SVG-scope multi-rotation handles. Their inner Konva nodes have
// no id and a custom name, so without these bails the fall-through
// below clears the multi-selection on mousedown — making multi-
// rotation / pivot-drag dissolve the selection they operate on.
if (target.findAncestor?.('.multi-pivot-handle')) return
if (target.findAncestor?.('.multi-rotation-handle')) return
if (target.name?.() === 'multi-pivot-handle') return
if (target.name?.() === 'multi-rotation-handle') return
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
if (target.name?.() === 'tangent-handle') return
if (target.name?.() === 'path-frame-dot') return
@@ -2630,6 +2732,23 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
}
return
}
if (name.startsWith('svg-hit:')) {
// Hit-catcher inside an SVG-asset layer's group. Route the
// mousedown to the wrapping layer the same way a regular
// shape click would — including shift-toggle for multi.
const layerId = name.slice('svg-hit:'.length)
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) {
store.selectLayer(null)
return
}
if ((e.evt as MouseEvent).shiftKey) {
store.toggleLayerSelection(layerId)
} else if (!store.selectedLayerIds.includes(layerId)) {
store.selectLayer(layerId)
}
return
}
// Bubbled mousedown from a Konva node we don't recognise as a
// layer. Use selectLayer(null) — NOT deselectAll — so that any
// clip-instance selection just set by an inner @mousedown
@@ -2714,13 +2833,21 @@ function onContainerDblClick(e: MouseEvent) {
return
}
// Not drawing: polygon-specific gestures on selected polygon
// Not drawing: scope-entry shortcut for stage-placed sub-asset
// layers. Double-click an SVG-asset layer → enter its sub-editor
// (mirroring the sidebar's double-click behaviour). Other asset
// kinds fall through to the polygon-specific gestures below.
const selId = store.selectedLayerId
if (!selId) return
const layer = store.activeLayers.find((l) => l.id === selId)
if (!layer || layer.locked) return
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return
if (!asset) return
if (asset.kind === 'svg') {
store.enterSvgAssetEdit(asset.id)
return
}
if (asset.kind !== 'polygon') return
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
if (pts.length < 1) return
@@ -2962,6 +3089,7 @@ const ROT_ARM_PX = 44
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
const rotationHandleConfig = computed(() => {
if (store.selectedLayerIds.length > 1) return null
const info = pivotInfo.value
if (!info) return null
if (draggingPivot.value) return null
@@ -2984,6 +3112,7 @@ const rotationHandleConfig = computed(() => {
})
const rotationArmConfig = computed(() => {
if (store.selectedLayerIds.length > 1) return null
const info = pivotInfo.value
if (!info) return null
if (draggingPivot.value) return null
@@ -3053,6 +3182,252 @@ function onRotationHandleDragEnd() {
clearLivePoses()
}
// -------- SVG-scope multi-select rotation -------------------------
// In SVG edit mode only: when the user has 2+ layers selected we
// expose a *temporary* group pivot — defaults to the centroid of the
// selection but draggable. The rotation handle anchored to it
// rotates every selected layer around that pivot. The temp pivot is
// scope-local: dropped on deselect / scope exit, re-init'd next time.
interface MultiRotState {
/** Per-selected-layer pose snapshotted at drag start. We rotate
* these around the pivot; the live pose is what Konva renders
* during drag. Persisting in a snapshot keeps the maths stable
* even when liveLayerPoses changes mid-drag. */
startPoses: {
layerId: string
x: number; y: number
scaleX: number; scaleY: number
rotation: number
}[]
/** Pivot anchor at drag start. Stays constant for the duration
* of the rotation gesture. */
pivotX: number
pivotY: number
/** Where the rotation handle started in world coords — used as
* the zero-angle reference. */
startHandleX: number
startHandleY: number
}
/** Active only in SVG scope with a multi-selection. `null` clears
* the pivot — auto-reseeded by the watcher below when the user
* multi-selects again. */
const multiRotPivot = ref<{ x: number; y: number } | null>(null)
const multiRotDrag = ref<MultiRotState | null>(null)
const multiRotDelta = ref(0)
const draggingMultiPivot = ref(false)
const isMultiSvg = computed(() =>
!!store.activeSvgAssetId && store.selectedLayerIds.length >= 2,
)
/** Sample-based positions for every currently-selected layer. Live
* poses (mid-rotation / mid-transform) are honoured so the centroid
* follows the rendered position during a drag. Includes scale so
* the multi-rotation drag preserves it instead of snapping the
* element to scale 1 mid-rotate. */
function selectedLayerPoses() {
const out: {
layerId: string
x: number; y: number
scaleX: number; scaleY: number
rotation: number
}[] = []
for (const id of store.selectedLayerIds) {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) continue
const live = liveLayerPoses.value[id]
const sampled = sampleLayer(layer, store.playhead)
out.push({
layerId: id,
x: live?.x ?? sampled.x,
y: live?.y ?? sampled.y,
scaleX: live?.scaleX ?? sampled.scaleX,
scaleY: live?.scaleY ?? sampled.scaleY,
rotation: live?.rotation ?? sampled.rotation,
})
}
return out
}
/** Centroid of the current selection. Used to seed the temp pivot
* when the user enters multi-select. */
function selectionCentroid(): { x: number; y: number } | null {
const poses = selectedLayerPoses()
if (!poses.length) return null
let sx = 0, sy = 0
for (const p of poses) { sx += p.x; sy += p.y }
return { x: sx / poses.length, y: sy / poses.length }
}
// Re-seed / drop the temp pivot when the selection changes.
watch(isMultiSvg, (active) => {
if (active) {
if (!multiRotPivot.value) multiRotPivot.value = selectionCentroid()
} else {
multiRotPivot.value = null
multiRotDrag.value = null
multiRotDelta.value = 0
}
})
// When the selection's centroid drifts (because the user is scaling
// or moving the multi-selection via the Konva transformer / drag),
// shift the pivot by the same delta so it stays attached to the
// selection. Skipped while:
// - the user is mid-rotation (rotation around a non-centroid pivot
// naturally moves the centroid; we don't want the pivot to chase
// its own rotation);
// - the user is dragging the pivot itself (it's their gesture, not
// a selection drift).
const liveSelectionCentroid = computed<{ x: number; y: number } | null>(() => {
if (!isMultiSvg.value) return null
return selectionCentroid()
})
watch(liveSelectionCentroid, (newC, oldC) => {
if (!newC || !oldC) return
if (!multiRotPivot.value) return
if (multiRotDrag.value) return
if (draggingMultiPivot.value) return
const dx = newC.x - oldC.x
const dy = newC.y - oldC.y
if (Math.abs(dx) < 1e-3 && Math.abs(dy) < 1e-3) return
multiRotPivot.value = {
x: multiRotPivot.value.x + dx,
y: multiRotPivot.value.y + dy,
}
})
const MULTI_ROT_ARM_PX = 56
const multiPivotHandleConfig = computed(() => {
if (!isMultiSvg.value) return null
const p = multiRotPivot.value
if (!p) return null
return { x: p.x, y: p.y, draggable: true, name: 'multi-pivot-handle' }
})
const multiRotArmConfig = computed(() => {
if (!isMultiSvg.value) return null
const p = multiRotPivot.value
if (!p) return null
if (draggingMultiPivot.value) return null
const arm = MULTI_ROT_ARM_PX / zoom.value
const ang = multiRotDelta.value
const rad = (ang * Math.PI) / 180
const endX = p.x + arm * Math.sin(rad)
const endY = p.y - arm * Math.cos(rad)
return {
points: [p.x, p.y, endX, endY],
stroke: '#f59e0b',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
listening: false,
}
})
const multiRotHandleConfig = computed(() => {
if (!isMultiSvg.value) return null
const p = multiRotPivot.value
if (!p) return null
if (draggingMultiPivot.value) return null
const arm = MULTI_ROT_ARM_PX / zoom.value
const ang = multiRotDelta.value
const rad = (ang * Math.PI) / 180
return {
x: p.x + arm * Math.sin(rad),
y: p.y - arm * Math.cos(rad),
draggable: true,
name: 'multi-rotation-handle',
}
})
const multiRotKnobConfig = computed(() => ({
radius: 6 / zoom.value,
fill: '#ffffff',
stroke: '#f59e0b',
strokeWidth: 2 / zoom.value,
}))
function onMultiPivotDragMove(e: Konva.KonvaEventObject<DragEvent>) {
draggingMultiPivot.value = true
multiRotPivot.value = { x: e.target.x(), y: e.target.y() }
}
function onMultiPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
draggingMultiPivot.value = false
multiRotPivot.value = { x: e.target.x(), y: e.target.y() }
}
function onMultiRotHandleDragStart(e: Konva.KonvaEventObject<DragEvent>) {
const p = multiRotPivot.value
if (!p) return
multiRotDrag.value = {
startPoses: selectedLayerPoses().map((q) => ({ ...q })),
pivotX: p.x,
pivotY: p.y,
startHandleX: e.target.x(),
startHandleY: e.target.y(),
}
multiRotDelta.value = 0
}
function onMultiRotHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const d = multiRotDrag.value
if (!d) return
const ang0 = Math.atan2(d.startHandleX - d.pivotX, -(d.startHandleY - d.pivotY))
const ang1 = Math.atan2(e.target.x() - d.pivotX, -(e.target.y() - d.pivotY))
let delta = ((ang1 - ang0) * 180) / Math.PI
// Wrap to nearest equivalent so a small drag across ±180° doesn't
// suddenly flip a full turn.
const prev = multiRotDelta.value
while (delta - prev > 180) delta -= 360
while (delta - prev < -180) delta += 360
multiRotDelta.value = delta
const rad = (delta * Math.PI) / 180
const c = Math.cos(rad)
const s = Math.sin(rad)
const next: Record<string, LiveLayerPose> = { ...liveLayerPoses.value }
for (const start of d.startPoses) {
const dx = start.x - d.pivotX
const dy = start.y - d.pivotY
next[start.layerId] = {
x: d.pivotX + dx * c - dy * s,
y: d.pivotY + dx * s + dy * c,
// Carry the layer's own scale through the rotation —
// rotation around a pivot doesn't change scale, but if we
// default to 1 here a scaled element snaps to "scale 1"
// mid-drag and only restores after dragend writes back.
scaleX: start.scaleX,
scaleY: start.scaleY,
rotation: start.rotation + delta,
}
}
liveLayerPoses.value = next
}
function onMultiRotHandleDragEnd() {
const d = multiRotDrag.value
if (!d) {
multiRotDelta.value = 0
return
}
for (const start of d.startPoses) {
const live = liveLayerPoses.value[start.layerId]
if (!live) continue
store.upsertKeyframe(start.layerId, store.playhead, {
x: live.x,
y: live.y,
rotation: live.rotation,
})
}
multiRotDrag.value = null
multiRotDelta.value = 0
clearLivePoses()
}
let ro: ResizeObserver | null = null
function onWheel(e: WheelEvent) {
@@ -3275,14 +3650,22 @@ watch(maskPreview, async (on) => {
drawPreview()
})
// Re-fit the stage whenever the user enters / exits a button-asset edit
// session — the button has its own canvas and the framing must update.
// Re-fit the stage whenever the user enters / exits a button- or
// svg-asset edit session — sub-assets have their own canvas, and
// the framing must update so the new canvas lands inside the
// viewport instead of leaving the user staring at an empty corner.
watch(
() => store.activeButtonAssetId,
() => {
nextTick(() => fitToContainer())
},
)
watch(
() => store.activeSvgAssetId,
() => {
nextTick(() => fitToContainer())
},
)
function zoomBy(factor: number) {
const cw = containerSize.value.width
@@ -3309,12 +3692,31 @@ if (
didInitialFit = true
}
// Last known viewport position of the stage container. Updated by
// the ResizeObserver below; used to compensate viewX/Y when the
// container's screen position shifts (e.g. when TAB collapses the
// asset / properties asides) so the canvas content stays anchored
// to the same viewport pixel instead of riding the layout shift.
let lastContainerRect: { left: number; top: number } | null = null
onMounted(() => {
if (!container.value) return
ro = new ResizeObserver((entries) => {
const rect = entries[0].contentRect
containerSize.value = { width: rect.width, height: rect.height }
if (!didInitialFit && rect.width > 0 && rect.height > 0) {
ro = new ResizeObserver(() => {
const el = container.value
if (!el) return
const r = el.getBoundingClientRect()
containerSize.value = { width: r.width, height: r.height }
if (lastContainerRect) {
const dx = r.left - lastContainerRect.left
const dy = r.top - lastContainerRect.top
// viewX/Y are container-local: shift them inversely so the
// rendered content keeps its viewport position when the
// container moves under it.
if (dx !== 0) viewX.value -= dx
if (dy !== 0) viewY.value -= dy
}
lastContainerRect = { left: r.left, top: r.top }
if (!didInitialFit && r.width > 0 && r.height > 0) {
fitToContainer()
didInitialFit = true
}
@@ -3479,19 +3881,36 @@ watch(stageResizeMode, (v) => {
/** Screen-space rect for the canvas — matches how Konva renders it
* (view offset + canvas * zoom). Handles are anchored to these
* positions. */
* positions. Tracks the *active* canvas: SVG-asset / button-asset /
* main scene, depending on edit scope. */
const stageScreenRect = computed(() => {
const z = zoom.value
const w = store.project.canvas.width * z
const h = store.project.canvas.height * z
const c = effectiveCanvas.value
return {
left: viewX.value,
top: viewY.value,
width: w,
height: h,
width: c.width * z,
height: c.height * z,
}
})
/** Apply a canvas-size change to whichever canvas the user is
* currently editing. SVG-asset edit and button-asset edit both
* carry their own internal canvas; otherwise it's the main scene. */
function setActiveCanvasSize(width: number, height: number) {
const sa = store.activeSvgAsset
if (sa) {
store.setSvgAssetCanvas(sa.id, width, height)
return
}
const ba = store.activeButtonAsset
if (ba) {
store.setButtonAssetCanvas(ba.id, width, height)
return
}
store.setCanvasSize(width, height)
}
function handleStyle(dir: ResizeHandleDir): Record<string, string> {
const rect = stageScreenRect.value
const half = 6 // handle is 12×12, centre the chip on the edge
@@ -3561,8 +3980,8 @@ function onResizeHandlePointerDown(e: PointerEvent, dir: ResizeHandleDir) {
dir,
startClientX: e.clientX,
startClientY: e.clientY,
startWidth: store.project.canvas.width,
startHeight: store.project.canvas.height,
startWidth: effectiveCanvas.value.width,
startHeight: effectiveCanvas.value.height,
appliedShiftX: 0,
appliedShiftY: 0,
pointerId: e.pointerId,
@@ -3582,10 +4001,18 @@ function onResizeHandlePointerMove(e: PointerEvent) {
drag.startWidth,
drag.startHeight,
)
store.setCanvasSize(width, height)
setActiveCanvasSize(width, height)
const deltaShiftX = shiftX - drag.appliedShiftX
const deltaShiftY = shiftY - drag.appliedShiftY
if (deltaShiftX !== 0 || deltaShiftY !== 0) {
// Shift only applies to the main-scene canvas — sub-asset edit
// scopes have their own coord system and don't need a global
// shift to keep content visible. North/west drags inside an SVG
// / button asset just resize without re-anchoring.
if (
!store.activeSvgAssetId &&
!store.activeButtonAssetId &&
(deltaShiftX !== 0 || deltaShiftY !== 0)
) {
store.shiftSceneWorldCoords(deltaShiftX, deltaShiftY)
drag.appliedShiftX = shiftX
drag.appliedShiftY = shiftY
@@ -3639,7 +4066,44 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
<template v-if="entry.kind === 'layer'">
<v-group
v-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
>
<v-rect
:config="svgHitCatcherConfig(svgAssetFor(entry.item.asset)!, entry.item.layer)"
/>
<template
v-for="(sub, sli) in svgStackForAsset(svgAssetFor(entry.item.asset)!)"
:key="`${entry.item.layer.id}:${sub.layer.id}:${sli}`"
>
<v-rect
v-if="sub.asset.kind === 'rect'"
:config="buttonChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
/>
<v-circle
v-else-if="sub.asset.kind === 'circle'"
:config="buttonChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
/>
<v-text
v-else-if="sub.asset.kind === 'text'"
:config="buttonChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
/>
<v-line
v-else-if="sub.asset.kind === 'polygon'"
:config="buttonChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
/>
<v-image
v-else-if="sub.asset.kind === 'image' && imageConfig(sub.layer, sub.asset, sub.props)"
:config="buttonChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
/>
</template>
</v-group>
<v-group
v-else-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
@mouseenter="onButtonEnter(entry.item.layer)"
@mouseleave="onButtonLeave(entry.item.layer)"
@@ -3839,6 +4303,29 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
<v-circle :config="rotationKnobConfig" />
</v-group>
<!-- SVG-scope multi-rotation: temp pivot + rotation arm.
Active only when isMultiSvg is true; the per-layer
handles above are gated off in that case. -->
<v-line v-if="multiRotArmConfig" :config="multiRotArmConfig" />
<v-group
v-if="multiPivotHandleConfig"
:config="multiPivotHandleConfig"
@dragmove="onMultiPivotDragMove"
@dragend="onMultiPivotDragEnd"
>
<v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" />
</v-group>
<v-group
v-if="multiRotHandleConfig"
:config="multiRotHandleConfig"
@dragstart="onMultiRotHandleDragStart"
@dragmove="onMultiRotHandleDragMove"
@dragend="onMultiRotHandleDragEnd"
>
<v-circle :config="multiRotKnobConfig" />
</v-group>
<v-circle
v-for="h in polygonVertexHandles"
:key="`vh-${h.layerId}-${h.index}`"
@@ -4050,6 +4537,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
@contextmenu.prevent.stop
>
<button
v-if="!store.activeSvgAssetId"
type="button"
class="stage-menu-item"
@pointerdown.stop
@@ -4067,7 +4555,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
@click.stop="onMenuStageResize"
>
<PhFrameCorners :size="13" class="stage-menu-icon" />
{{ t('canvas.menu.stageResize') }}
{{ store.activeSvgAssetId ? t('canvas.menu.editCanvasSize') : t('canvas.menu.stageResize') }}
</button>
</div>
</div>