feat: 5-frame button assets + timeline/sidebar polish
Button assets
- Re-introduce button-asset type with 4 content states (normal/hover/
pressed/disabled) stored as keyframes at BUTTON_STATE_TIMES, plus a
separate hit-area editor mode.
- useButtonRuntime composable for per-instance state/disabled flags on
the main stage.
- Sidebar library lists button assets with live 80×80 thumbnails;
renderButtonThumbnail now accepts an images map so image-kind layers
inside a button render instead of producing a fully-transparent PNG.
- Compact (no-thumbnail) mode aligns button rows to the library-row
height.
Scope footer
- Button-edit mode now uses the same sidebar scope-footer pattern as
clip-edit (back button to "Main timeline" + active scope chip);
removed the inline exit button from the timeline scrub-bar.
Timeline
- Track-name column default bumped to 260px to match the assets sidebar
above it (storage key bumped to :v2 so stale values reset).
- scrub-bar z-index raised above sticky track-name columns so rows
scroll under it instead of over.
- scrub-handle flips z-index based on horizontal scroll: above
name-spacer when the playhead is in view (no half-clip at t=0),
below when the playhead has scrolled off the left edge (so the
toggle row cleanly covers it).
- name-spacer extends ±3/1px to fully cover the obscured handle.
- scrub-handle head pokes 3px above the ruler with rounded top corners
via clip-path path().
Track context menu
- Added missing i18n keys (renameScriptTrack / deleteScriptTrack /
renameScriptTrackDialog.{title,confirm}).
- Rename items show a visible pencil icon; empty .check slot removed
so items line up flush-left; toggle items place their active ✓ on
the right (kf-menu-trailing-check).
Dev HUD
- Test progress bar flips to --danger as soon as the first failure is
recorded, instead of only at the end of the run.
Regression tests
- renderButtonThumbnail: null on empty button, renders shape layers,
accepts custom images map.
- i18n: renameScriptTrack / deleteScriptTrack / dialog keys resolve.
- scrub-handle-obscured logic (visible at t=0, hidden when playhead is
scrolled off-screen, still visible when playhead is far to the
right of the scroll offset).
- Updated button state-scrubber expectations to match
BUTTON_STATE_TIMES (normal=1, hover=2, pressed=3, disabled=4) and
that hit-mode parks at normal=1 rather than 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
31604052ca
commit
c3764201b8
16 files changed
+2560
-349
No files matched your search
+722
-12
@@ -1,19 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type Konva from 'konva'
|
||||
import { PhCornersIn, PhCrop, PhMaskHappy, PhPencilSimple } from '@phosphor-icons/vue'
|
||||
import {
|
||||
PhCircle,
|
||||
PhCornersIn,
|
||||
PhCrop,
|
||||
PhCursorClick,
|
||||
PhMaskHappy,
|
||||
PhPencilSimple,
|
||||
PhPolygon,
|
||||
PhSquare,
|
||||
PhTrash,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { isLayerActiveAt, sampleLayer } from '@/lib/interpolate'
|
||||
import { importImageFile, loadImage } from '@/lib/fileLoader'
|
||||
import { useClipartStore } from '@/stores/clipart'
|
||||
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||
import type {
|
||||
Action,
|
||||
AnimatableProps,
|
||||
Asset,
|
||||
ButtonAsset,
|
||||
ButtonState,
|
||||
HitShape,
|
||||
Keyframe,
|
||||
Layer,
|
||||
Point,
|
||||
} from '@/types/scene'
|
||||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
||||
import { evalAction } from '@/lib/actions'
|
||||
import { t } from '@/i18n'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
import { showLocked } from '@/composables/useShowLocked'
|
||||
@@ -34,6 +51,10 @@ const viewX = ref(0)
|
||||
const viewY = ref(0)
|
||||
const cropObjects = ref(false)
|
||||
const maskPreview = ref(false)
|
||||
// Stage-preview mode: when false (default) buttons behave like normal
|
||||
// assets — draggable, no hover/click interaction. When true the canvas
|
||||
// turns into a live preview with buttons responding to input.
|
||||
const stagePreview = ref(false)
|
||||
const previewCanvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const previewImages = ref<Record<string, HTMLImageElement>>({})
|
||||
let didInitialFit = false
|
||||
@@ -48,8 +69,9 @@ function clamp(v: number, lo: number, hi: number) {
|
||||
function fitToContainer() {
|
||||
const cw = containerSize.value.width
|
||||
const ch = containerSize.value.height
|
||||
const lw = store.scene.canvas.width
|
||||
const lh = store.scene.canvas.height
|
||||
const ec = store.activeButtonAsset ? store.activeButtonAsset.canvas : store.scene.canvas
|
||||
const lw = ec.width
|
||||
const lh = ec.height
|
||||
if (!cw || !ch) return
|
||||
const z = Math.min(cw / lw, ch / lh) * 0.92
|
||||
zoom.value = z
|
||||
@@ -60,14 +82,475 @@ function fitToContainer() {
|
||||
function zoomToOne() {
|
||||
const cw = containerSize.value.width
|
||||
const ch = containerSize.value.height
|
||||
const lw = store.scene.canvas.width
|
||||
const lh = store.scene.canvas.height
|
||||
const ec = store.activeButtonAsset ? store.activeButtonAsset.canvas : store.scene.canvas
|
||||
const lw = ec.width
|
||||
const lh = ec.height
|
||||
if (!cw || !ch) return
|
||||
zoom.value = 1
|
||||
viewX.value = (cw - lw) / 2
|
||||
viewY.value = (ch - lh) / 2
|
||||
}
|
||||
|
||||
const effectiveCanvas = computed(() => {
|
||||
const ba = store.activeButtonAsset
|
||||
if (ba) return ba.canvas
|
||||
return store.scene.canvas
|
||||
})
|
||||
|
||||
// -------- Button hit-shape editing --------------------------------
|
||||
|
||||
const activeHitShapes = computed(() => store.activeButtonAsset?.hitArea ?? [])
|
||||
const selectedHitShapeIndex = ref<number | null>(null)
|
||||
|
||||
function hitShapeConfig(shape: HitShape, index: number) {
|
||||
const selected = selectedHitShapeIndex.value === index
|
||||
const stroke = selected ? '#ffffff' : '#f59e0b'
|
||||
const strokeWidth = (selected ? 2 : 1.5) / zoom.value
|
||||
const base = {
|
||||
id: `hit-shape:${index}`,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
strokeScaleEnabled: false,
|
||||
dash: [6 / zoom.value, 4 / zoom.value],
|
||||
fill: 'rgba(245, 158, 11, 0.08)',
|
||||
draggable: true,
|
||||
}
|
||||
if (shape.kind === 'rect') {
|
||||
return {
|
||||
...base,
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
width: shape.width,
|
||||
height: shape.height,
|
||||
}
|
||||
}
|
||||
if (shape.kind === 'circle') {
|
||||
return {
|
||||
...base,
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
radius: shape.radius,
|
||||
}
|
||||
}
|
||||
// Polygon — Konva Line renders using a flat [x,y,x,y,...] array and
|
||||
// `closed`. We draw it at (0,0); each vertex carries its own position.
|
||||
const flat: number[] = []
|
||||
for (const p of shape.points) flat.push(p.x, p.y)
|
||||
return {
|
||||
...base,
|
||||
x: 0,
|
||||
y: 0,
|
||||
points: flat,
|
||||
closed: true,
|
||||
lineJoin: 'round',
|
||||
}
|
||||
}
|
||||
|
||||
function onHitShapeDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
|
||||
const ba = store.activeButtonAsset
|
||||
if (!ba) return
|
||||
const shape = ba.hitArea[index]
|
||||
if (!shape) return
|
||||
const node = e.target
|
||||
if (shape.kind === 'polygon') {
|
||||
// Whole-polygon drag: node (0,0) moved to (dx, dy); bake that offset
|
||||
// into every vertex and reset the node back to (0,0).
|
||||
const dx = node.x()
|
||||
const dy = node.y()
|
||||
if (dx === 0 && dy === 0) return
|
||||
const moved = shape.points.map((p) => ({ x: p.x + dx, y: p.y + dy }))
|
||||
store.updateButtonHitShape(ba.id, index, { points: moved } as Partial<HitShape>)
|
||||
node.position({ x: 0, y: 0 })
|
||||
return
|
||||
}
|
||||
store.updateButtonHitShape(ba.id, index, { x: node.x(), y: node.y() })
|
||||
}
|
||||
|
||||
function onHitShapeClick(_e: Konva.KonvaEventObject<MouseEvent>, index: number) {
|
||||
selectedHitShapeIndex.value = index
|
||||
}
|
||||
|
||||
function onHitRectTransformEnd(e: Konva.KonvaEventObject<Event>, index: number) {
|
||||
const ba = store.activeButtonAsset
|
||||
if (!ba) return
|
||||
const node = e.target
|
||||
// Konva Transformer applies scale deltas; bake those into width/height
|
||||
// and reset the node scale so further transforms compose cleanly.
|
||||
const sx = node.scaleX()
|
||||
const sy = node.scaleY()
|
||||
const newW = Math.max(4, Math.round(node.width() * sx))
|
||||
const newH = Math.max(4, Math.round(node.height() * sy))
|
||||
node.scaleX(1)
|
||||
node.scaleY(1)
|
||||
node.width(newW)
|
||||
node.height(newH)
|
||||
store.updateButtonHitShape(ba.id, index, {
|
||||
x: Math.round(node.x()),
|
||||
y: Math.round(node.y()),
|
||||
width: newW,
|
||||
height: newH,
|
||||
} as Partial<HitShape>)
|
||||
}
|
||||
|
||||
function onHitCircleTransformEnd(e: Konva.KonvaEventObject<Event>, index: number) {
|
||||
const ba = store.activeButtonAsset
|
||||
if (!ba) return
|
||||
const node = e.target
|
||||
const sx = node.scaleX()
|
||||
const sy = node.scaleY()
|
||||
const shape = ba.hitArea[index]
|
||||
if (!shape || shape.kind !== 'circle') return
|
||||
const scaleAvg = (Math.abs(sx) + Math.abs(sy)) / 2
|
||||
const newR = Math.max(2, Math.round(shape.radius * scaleAvg))
|
||||
node.scaleX(1)
|
||||
node.scaleY(1)
|
||||
store.updateButtonHitShape(ba.id, index, {
|
||||
x: Math.round(node.x()),
|
||||
y: Math.round(node.y()),
|
||||
radius: newR,
|
||||
} as Partial<HitShape>)
|
||||
}
|
||||
|
||||
function onHitPolygonVertexDrag(
|
||||
e: Konva.KonvaEventObject<DragEvent>,
|
||||
shapeIdx: number,
|
||||
vertexIdx: number,
|
||||
) {
|
||||
const ba = store.activeButtonAsset
|
||||
if (!ba) return
|
||||
const shape = ba.hitArea[shapeIdx]
|
||||
if (!shape || shape.kind !== 'polygon') return
|
||||
const node = e.target
|
||||
const next = shape.points.map((p, i) =>
|
||||
i === vertexIdx ? { x: node.x(), y: node.y() } : { ...p },
|
||||
)
|
||||
store.updateButtonHitShape(ba.id, shapeIdx, { points: next } as Partial<HitShape>)
|
||||
}
|
||||
|
||||
function addHitRect() {
|
||||
const ba = store.activeButtonAsset
|
||||
if (!ba) return
|
||||
const w = ba.canvas.width
|
||||
const h = ba.canvas.height
|
||||
const rw = Math.max(20, Math.round(w * 0.6))
|
||||
const rh = Math.max(20, Math.round(h * 0.6))
|
||||
store.addButtonHitShape(ba.id, {
|
||||
kind: 'rect',
|
||||
x: Math.round((w - rw) / 2),
|
||||
y: Math.round((h - rh) / 2),
|
||||
width: rw,
|
||||
height: rh,
|
||||
})
|
||||
selectedHitShapeIndex.value = (ba.hitArea?.length ?? 1) - 1
|
||||
}
|
||||
|
||||
function addHitCircle() {
|
||||
const ba = store.activeButtonAsset
|
||||
if (!ba) return
|
||||
const w = ba.canvas.width
|
||||
const h = ba.canvas.height
|
||||
const r = Math.max(10, Math.round(Math.min(w, h) * 0.3))
|
||||
store.addButtonHitShape(ba.id, {
|
||||
kind: 'circle',
|
||||
x: Math.round(w / 2),
|
||||
y: Math.round(h / 2),
|
||||
radius: r,
|
||||
})
|
||||
selectedHitShapeIndex.value = (ba.hitArea?.length ?? 1) - 1
|
||||
}
|
||||
|
||||
function addHitPolygon() {
|
||||
const ba = store.activeButtonAsset
|
||||
if (!ba) return
|
||||
const w = ba.canvas.width
|
||||
const h = ba.canvas.height
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
const r = Math.max(10, Math.min(w, h) * 0.35)
|
||||
const sides = 6
|
||||
const pts: { x: number; y: number }[] = []
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
|
||||
pts.push({ x: Math.round(cx + Math.cos(a) * r), y: Math.round(cy + Math.sin(a) * r) })
|
||||
}
|
||||
store.addButtonHitShape(ba.id, { kind: 'polygon', points: pts })
|
||||
selectedHitShapeIndex.value = (ba.hitArea?.length ?? 1) - 1
|
||||
}
|
||||
|
||||
function removeSelectedHitShape() {
|
||||
const ba = store.activeButtonAsset
|
||||
const idx = selectedHitShapeIndex.value
|
||||
if (!ba || idx === null) return
|
||||
store.removeButtonHitShape(ba.id, idx)
|
||||
selectedHitShapeIndex.value = null
|
||||
}
|
||||
|
||||
const selectedHitShape = computed(() => {
|
||||
const idx = selectedHitShapeIndex.value
|
||||
if (idx === null) return null
|
||||
const list = store.activeButtonAsset?.hitArea
|
||||
if (!list) return null
|
||||
return list[idx] ?? null
|
||||
})
|
||||
|
||||
const hitShapeTransformerConfig = computed(() => ({
|
||||
rotateEnabled: false,
|
||||
keepRatio: selectedHitShape.value?.kind === 'circle',
|
||||
anchorSize: 8,
|
||||
borderStrokeWidth: 0,
|
||||
anchorStroke: '#f59e0b',
|
||||
anchorFill: '#ffffff',
|
||||
ignoreStroke: true,
|
||||
flipEnabled: false,
|
||||
}))
|
||||
|
||||
const hitTransformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
|
||||
|
||||
// Keep the transformer bound to whichever hit shape is currently selected.
|
||||
watch(
|
||||
() => [selectedHitShapeIndex.value, activeHitShapes.value.length] as const,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
const tr = hitTransformerRef.value?.getNode()
|
||||
if (!tr) return
|
||||
const idx = selectedHitShapeIndex.value
|
||||
const shape = selectedHitShape.value
|
||||
if (idx === null || !shape || shape.kind === 'polygon') {
|
||||
tr.nodes([])
|
||||
tr.getLayer()?.batchDraw()
|
||||
return
|
||||
}
|
||||
const stage = tr.getStage()
|
||||
const node = stage?.findOne(`#hit-shape:${idx}`)
|
||||
if (node) tr.nodes([node])
|
||||
else tr.nodes([])
|
||||
tr.getLayer()?.batchDraw()
|
||||
})
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
function hitVertexConfig(shapeIdx: number, vertexIdx: number, pt: { x: number; y: number }) {
|
||||
const selected = selectedHitShapeIndex.value === shapeIdx
|
||||
return {
|
||||
x: pt.x,
|
||||
y: pt.y,
|
||||
radius: (selected ? 5 : 3.5) / zoom.value,
|
||||
fill: '#ffffff',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 1 / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
draggable: selected,
|
||||
visible: selected,
|
||||
listening: selected,
|
||||
name: `hit-vertex:${shapeIdx}:${vertexIdx}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear hit selection whenever the edit context changes.
|
||||
watch(
|
||||
() => [store.activeButtonAssetId, store.activeButtonEditMode, store.activeButtonState] as const,
|
||||
() => {
|
||||
selectedHitShapeIndex.value = null
|
||||
},
|
||||
)
|
||||
|
||||
// -------- Button asset canvas-resize handles ---------------------
|
||||
// Three drag handles (right edge, bottom edge, bottom-right corner) that
|
||||
// resize the button's internal canvas while in edit mode. Keeps (0,0)
|
||||
// pinned; content layers inside the button are unaffected.
|
||||
|
||||
type CanvasResizeEdge = 'right' | 'bottom' | 'corner'
|
||||
|
||||
interface CanvasResizeHandle {
|
||||
edge: CanvasResizeEdge
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
}
|
||||
|
||||
const canvasResizeHandles = computed<CanvasResizeHandle[]>(() => {
|
||||
if (!store.activeButtonAssetId) return []
|
||||
const w = effectiveCanvas.value.width
|
||||
const h = effectiveCanvas.value.height
|
||||
const s = 10 / zoom.value
|
||||
return [
|
||||
{ edge: 'right', x: w - s / 2, y: h / 2 - s / 2, size: s },
|
||||
{ edge: 'bottom', x: w / 2 - s / 2, y: h - s / 2, size: s },
|
||||
{ edge: 'corner', x: w - s / 2, y: h - s / 2, size: s },
|
||||
]
|
||||
})
|
||||
|
||||
function canvasResizeHandleConfig(h: CanvasResizeHandle) {
|
||||
return {
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
width: h.size,
|
||||
height: h.size,
|
||||
fill: '#ffffff',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 1 / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
draggable: true,
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasResizeDrag(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
|
||||
const ba = store.activeButtonAsset
|
||||
if (!ba) return
|
||||
const node = e.target
|
||||
const s = 10 / zoom.value
|
||||
let w = ba.canvas.width
|
||||
let h = ba.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)
|
||||
}
|
||||
|
||||
function onCanvasResizeDragEnd(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
|
||||
onCanvasResizeDrag(e, edge)
|
||||
}
|
||||
|
||||
// -------- Button instance runtime (main stage) --------------------
|
||||
|
||||
const buttonRt = useButtonRuntime()
|
||||
|
||||
function buttonLayerGroupConfig(layer: Layer, props: AnimatableProps) {
|
||||
const locked = isLayerEffectivelyLocked(layer)
|
||||
return {
|
||||
id: layer.id,
|
||||
x: props.x,
|
||||
y: props.y,
|
||||
scaleX: props.scaleX,
|
||||
scaleY: props.scaleY,
|
||||
rotation: props.rotation,
|
||||
opacity: props.opacity,
|
||||
offsetX: layer.offsetX ?? 0,
|
||||
offsetY: layer.offsetY ?? 0,
|
||||
visible: layer.visible,
|
||||
listening: !locked,
|
||||
// In edit mode buttons behave like any other asset: draggable as a
|
||||
// single group, transformer can attach. Preview mode locks position
|
||||
// so the button stays put while receiving pointer events.
|
||||
draggable: !locked && !stagePreview.value,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible Konva shape that covers the button's canvas rect but only
|
||||
* responds to pointer events inside the union of hit shapes. Pointer
|
||||
* events on this catcher drive hover/press/click.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type KonvaHitContext = any
|
||||
function buttonHitCatcherConfig(asset: ButtonAsset, layer: Layer) {
|
||||
const shapes = asset.hitArea
|
||||
const hitFunc = (ctx: KonvaHitContext, s: Konva.Shape) => {
|
||||
if (!shapes.length) {
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, asset.canvas.width, asset.canvas.height)
|
||||
ctx.closePath()
|
||||
ctx.fillStrokeShape(s)
|
||||
return
|
||||
}
|
||||
ctx.beginPath()
|
||||
for (const h of shapes) {
|
||||
if (h.kind === 'rect') {
|
||||
ctx.rect(h.x, h.y, h.width, h.height)
|
||||
} else if (h.kind === 'circle') {
|
||||
ctx.moveTo(h.x + h.radius, h.y)
|
||||
ctx.arc(h.x, h.y, h.radius, 0, Math.PI * 2)
|
||||
} else if (h.kind === 'polygon' && h.points && h.points.length >= 3) {
|
||||
ctx.moveTo(h.points[0].x, h.points[0].y)
|
||||
for (let pi = 1; pi < h.points.length; pi++) ctx.lineTo(h.points[pi].x, h.points[pi].y)
|
||||
ctx.closePath()
|
||||
}
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fillStrokeShape(s)
|
||||
}
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: asset.canvas.width,
|
||||
height: asset.canvas.height,
|
||||
fill: 'rgba(0,0,0,0.001)',
|
||||
// Always listen so the button group can be dragged/selected in edit
|
||||
// mode. In preview mode, disabled buttons stop listening so they
|
||||
// don't respond to hover/click.
|
||||
listening: !isLayerEffectivelyLocked(layer) && (!stagePreview.value || !layer.buttonDisabled),
|
||||
hitFunc,
|
||||
name: `button-hit:${layer.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
|
||||
return { ...cfg, draggable: false, listening: false }
|
||||
}
|
||||
|
||||
function buttonStackForState(
|
||||
asset: ButtonAsset,
|
||||
state: ButtonState,
|
||||
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
|
||||
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
|
||||
const stateTime = BUTTON_STATE_TIMES[state]
|
||||
const layers = asset.layers.slice().reverse()
|
||||
for (const l of layers) {
|
||||
if (!l.visible) continue
|
||||
const a = assetMap.value.get(l.assetId)
|
||||
if (!a) continue
|
||||
out.push({ layer: l, asset: a, props: sampleLayer(l, stateTime) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function onButtonEnter(layer: Layer) {
|
||||
if (!stagePreview.value) return
|
||||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||||
if (cur === 'normal') buttonRt.setState(layer.id, 'hover')
|
||||
}
|
||||
|
||||
function onButtonLeave(layer: Layer) {
|
||||
if (!stagePreview.value) return
|
||||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||||
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(layer.id, 'normal')
|
||||
}
|
||||
|
||||
function onButtonDown(layer: Layer) {
|
||||
if (!stagePreview.value) return
|
||||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||||
if (cur === 'hover') buttonRt.setState(layer.id, 'pressed')
|
||||
}
|
||||
|
||||
function onButtonUp(layer: Layer) {
|
||||
if (!stagePreview.value) return
|
||||
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||||
if (cur !== 'pressed') return
|
||||
buttonRt.setState(layer.id, 'hover')
|
||||
const actions = layer.onClick
|
||||
if (actions && actions.length) {
|
||||
const rt = store.actionRuntime()
|
||||
for (const a of actions) evalAction(a, rt)
|
||||
}
|
||||
}
|
||||
|
||||
// ButtonAsset lookup helper for template
|
||||
function buttonAssetFor(asset: Asset): ButtonAsset | null {
|
||||
return asset.kind === 'button' ? asset : null
|
||||
}
|
||||
|
||||
function buttonStateFor(layer: Layer): ButtonState {
|
||||
// In edit mode the button is a static asset — always show its normal
|
||||
// state regardless of leftover runtime state (e.g. from a previous
|
||||
// preview session).
|
||||
if (!stagePreview.value) return 'normal'
|
||||
return buttonRt.getEffective(layer.id, layer.buttonDisabled)
|
||||
}
|
||||
|
||||
const stageConfig = computed(() => ({
|
||||
width: containerSize.value.width,
|
||||
height: containerSize.value.height,
|
||||
@@ -81,8 +564,8 @@ const viewConfig = computed(() => ({
|
||||
}))
|
||||
|
||||
const clipGroupConfig = computed(() => {
|
||||
const w = store.scene.canvas.width
|
||||
const h = store.scene.canvas.height
|
||||
const w = effectiveCanvas.value.width
|
||||
const h = effectiveCanvas.value.height
|
||||
const crop = cropObjects.value
|
||||
return {
|
||||
clipFunc: (ctx: CanvasRenderingContext2D) => {
|
||||
@@ -93,6 +576,18 @@ const clipGroupConfig = computed(() => {
|
||||
})
|
||||
|
||||
const backgroundConfig = computed(() => {
|
||||
// In button-asset edit: checkerboard-ish transparent background so the
|
||||
// user sees where the button's canvas ends.
|
||||
if (store.activeButtonAssetId) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: effectiveCanvas.value.width,
|
||||
height: effectiveCanvas.value.height,
|
||||
listening: false,
|
||||
fill: '#1a1a1f',
|
||||
}
|
||||
}
|
||||
// Always set `fill` — toggling it in/out of the config across scene
|
||||
// reloads can leave Konva's Rect with a stale attribute. Use a
|
||||
// transparent fill when a background video is present so the DOM
|
||||
@@ -178,8 +673,8 @@ watch(
|
||||
const canvasBorderConfig = computed(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: store.scene.canvas.width,
|
||||
height: store.scene.canvas.height,
|
||||
width: effectiveCanvas.value.width,
|
||||
height: effectiveCanvas.value.height,
|
||||
stroke: '#4a4a56',
|
||||
strokeWidth: 1 / zoom.value,
|
||||
listening: false,
|
||||
@@ -241,11 +736,31 @@ function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps
|
||||
}
|
||||
|
||||
const stackItems = computed<StackItem[]>(() => {
|
||||
// Button-asset edit: show only the active state's layer stack. In the
|
||||
// 'hit' cell show the normal state's layers as an onion-skin
|
||||
// underneath the hit-area gizmos.
|
||||
if (store.activeButtonAssetId) {
|
||||
const out: StackItem[] = []
|
||||
const ba = store.activeButtonAsset
|
||||
if (!ba) return out
|
||||
const onionSkin = store.activeButtonEditMode === 'hit'
|
||||
const layers = ba.layers.slice().reverse()
|
||||
for (const layer of layers) {
|
||||
if (!layer.visible) continue
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (!asset) continue
|
||||
const props = applyLivePose(sampleLayer(layer, store.playhead), layer.id)
|
||||
if (onionSkin) props.opacity = (props.opacity ?? 1) * 0.35
|
||||
out.push({ kind: 'layer', item: { layer, asset, props } })
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
|
||||
if (store.activeClipId) {
|
||||
const out: StackItem[] = []
|
||||
const layers = store.activeLayers.slice().reverse()
|
||||
for (const layer of layers) {
|
||||
if (!isLayerActiveAt(layer, store.playhead)) continue
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (!asset) continue
|
||||
out.push({
|
||||
@@ -279,6 +794,7 @@ const stackItems = computed<StackItem[]>(() => {
|
||||
const id = order[i]
|
||||
const layer = layerById.get(id)
|
||||
if (layer) {
|
||||
if (!isLayerActiveAt(layer, store.playhead)) continue
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (!asset) continue
|
||||
out.push({
|
||||
@@ -305,6 +821,7 @@ const stackItems = computed<StackItem[]>(() => {
|
||||
for (let k = clip.layers.length - 1; k >= 0; k--) {
|
||||
const cl = clip.layers[k]
|
||||
if (!cl.visible) continue
|
||||
if (!isLayerActiveAt(cl, localTime)) continue
|
||||
const ca = assetMap.value.get(cl.assetId)
|
||||
if (!ca) continue
|
||||
out.push({
|
||||
@@ -344,6 +861,9 @@ function isLayerEffectivelyLocked(layer: Layer): boolean {
|
||||
for (const g of store.scene.groups ?? []) {
|
||||
if (g.locked && g.childIds.includes(layer.id)) return true
|
||||
}
|
||||
// In hit-area edit the user's focus is on hit geometry; layers are onion-
|
||||
// skinned and must not steal pointer events from the hit-shape handles.
|
||||
if (store.activeButtonEditMode === 'hit') return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1689,6 +2209,14 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
}
|
||||
const id = target.id?.()
|
||||
if (!id) {
|
||||
const name = target.name?.() ?? ''
|
||||
if (name.startsWith('button-hit:')) {
|
||||
if (!stagePreview.value) {
|
||||
const layerId = name.slice('button-hit:'.length)
|
||||
store.selectLayer(store.activeLayers.find((l) => l.id === layerId) ? layerId : null)
|
||||
}
|
||||
return
|
||||
}
|
||||
store.selectLayer(null)
|
||||
return
|
||||
}
|
||||
@@ -2202,6 +2730,15 @@ 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.
|
||||
watch(
|
||||
() => store.activeButtonAssetId,
|
||||
() => {
|
||||
nextTick(() => fitToContainer())
|
||||
},
|
||||
)
|
||||
|
||||
function zoomBy(factor: number) {
|
||||
const cw = containerSize.value.width
|
||||
const ch = containerSize.value.height
|
||||
@@ -2288,8 +2825,49 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
>
|
||||
<template v-if="entry.kind === 'layer'">
|
||||
<v-group
|
||||
v-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)"
|
||||
@mousedown="onButtonDown(entry.item.layer)"
|
||||
@mouseup="onButtonUp(entry.item.layer)"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@transformend="handleTransformEnd"
|
||||
>
|
||||
<v-rect
|
||||
:config="buttonHitCatcherConfig(buttonAssetFor(entry.item.asset)!, entry.item.layer)"
|
||||
/>
|
||||
<template
|
||||
v-for="(sub, bli) in buttonStackForState(buttonAssetFor(entry.item.asset)!, buttonStateFor(entry.item.layer))"
|
||||
:key="`${entry.item.layer.id}:${sub.layer.id}:${bli}`"
|
||||
>
|
||||
<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-rect
|
||||
v-if="entry.item.asset.kind === 'rect'"
|
||||
v-else-if="entry.item.asset.kind === 'rect'"
|
||||
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@@ -2352,8 +2930,62 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template
|
||||
v-if="store.activeButtonAssetId && store.activeButtonEditMode === 'hit'"
|
||||
>
|
||||
<template
|
||||
v-for="(shape, idx) in activeHitShapes"
|
||||
:key="`hit-${idx}`"
|
||||
>
|
||||
<v-rect
|
||||
v-if="shape.kind === 'rect'"
|
||||
:config="hitShapeConfig(shape, idx)"
|
||||
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onHitShapeClick(e, idx)"
|
||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitShapeDragEnd(e, idx)"
|
||||
@transformend="(e: Konva.KonvaEventObject<Event>) => onHitRectTransformEnd(e, idx)"
|
||||
/>
|
||||
<v-circle
|
||||
v-else-if="shape.kind === 'circle'"
|
||||
:config="hitShapeConfig(shape, idx)"
|
||||
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onHitShapeClick(e, idx)"
|
||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitShapeDragEnd(e, idx)"
|
||||
@transformend="(e: Konva.KonvaEventObject<Event>) => onHitCircleTransformEnd(e, idx)"
|
||||
/>
|
||||
<v-line
|
||||
v-else-if="shape.kind === 'polygon'"
|
||||
:config="hitShapeConfig(shape, idx)"
|
||||
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onHitShapeClick(e, idx)"
|
||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitShapeDragEnd(e, idx)"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-for="(shape, idx) in activeHitShapes"
|
||||
:key="`hit-verts-${idx}`"
|
||||
>
|
||||
<template v-if="shape.kind === 'polygon'">
|
||||
<v-circle
|
||||
v-for="(pt, vi) in shape.points"
|
||||
:key="`hv-${idx}-${vi}`"
|
||||
:config="hitVertexConfig(idx, vi, pt)"
|
||||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onHitPolygonVertexDrag(e, idx, vi)"
|
||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onHitPolygonVertexDrag(e, idx, vi)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<v-transformer
|
||||
ref="hitTransformerRef"
|
||||
:config="hitShapeTransformerConfig"
|
||||
/>
|
||||
</template>
|
||||
</v-group>
|
||||
<v-rect :config="canvasBorderConfig" />
|
||||
<v-rect
|
||||
v-for="h in canvasResizeHandles"
|
||||
:key="`canvas-resize-${h.edge}`"
|
||||
:config="canvasResizeHandleConfig(h)"
|
||||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onCanvasResizeDrag(e, h.edge)"
|
||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onCanvasResizeDragEnd(e, h.edge)"
|
||||
/>
|
||||
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
|
||||
<v-group
|
||||
v-if="pivotHandleConfig"
|
||||
@@ -2439,6 +3071,33 @@ onBeforeUnmount(() => {
|
||||
}"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="store.activeButtonAssetId && store.activeButtonEditMode === 'hit'"
|
||||
class="hit-tools"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<button class="hit-btn" :title="t('canvas.hit.addRect')" @click="addHitRect">
|
||||
<PhSquare :size="13" />
|
||||
<span>rect</span>
|
||||
</button>
|
||||
<button class="hit-btn" :title="t('canvas.hit.addCircle')" @click="addHitCircle">
|
||||
<PhCircle :size="13" />
|
||||
<span>circle</span>
|
||||
</button>
|
||||
<button class="hit-btn" :title="t('canvas.hit.addPolygon')" @click="addHitPolygon">
|
||||
<PhPolygon :size="13" />
|
||||
<span>polygon</span>
|
||||
</button>
|
||||
<button
|
||||
class="hit-btn danger"
|
||||
:disabled="selectedHitShapeIndex === null"
|
||||
:title="t('canvas.hit.remove')"
|
||||
@click="removeSelectedHitShape"
|
||||
>
|
||||
<PhTrash :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="zoom-controls" @pointerdown.stop>
|
||||
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
|
||||
<button class="zc-label" :title="t('canvas.fitToView')" @click="fitToContainer">
|
||||
@@ -2468,9 +3127,17 @@ onBeforeUnmount(() => {
|
||||
<PhMaskHappy v-if="!maskPreview" :size="13" />
|
||||
<PhPencilSimple v-else :size="13" />
|
||||
</button>
|
||||
<button
|
||||
class="zc-btn stage-preview-btn"
|
||||
:class="{ active: stagePreview }"
|
||||
:title="stagePreview ? t('canvas.stagePreviewOff') : t('canvas.stagePreviewOn')"
|
||||
@click="stagePreview = !stagePreview"
|
||||
>
|
||||
<PhCursorClick :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
|
||||
<div class="dims">{{ effectiveCanvas.width }} × {{ effectiveCanvas.height }}</div>
|
||||
|
||||
<div
|
||||
v-if="vertexMenu"
|
||||
@@ -2581,6 +3248,49 @@ onBeforeUnmount(() => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hit-tools {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 9px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hit-btn:hover:not(:disabled) {
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
|
||||
.hit-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.hit-btn.danger:hover:not(:disabled) {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
|
||||
Reference in new issue
Block a user