1.1.3: multi-select on the stage — marquee, group transform, panel
- Click-and-drag on the empty stage starts a layer marquee with touch (not enclose) semantics — every unlocked layer whose bbox overlaps the drag rect joins the selection. Tiny clicks still deselect; shift adds to the existing selection on both clicks and marquees. Polygon-vertex lasso stays available when a single polygon is the active selection. - Konva transformer now wraps every selected layer in one shared bounding box (polygons fold in for mixed selections; vertex handles + the vertex lasso suppress to avoid fighting the box). rotateEnabled flips off on multi to skip the ambiguous shared- pivot rotation. Drag of any selected node mirrors the delta onto every sibling Konva node so the whole group travels together; transformend persists each layer's final pose, not just the anchor's. - liveLayerPose became a per-id map (liveLayerPoses) so the reactive shape configs don't snap non-focused siblings back to their sampled pre-transform pose mid-gesture. - Plain click on a member of an existing multi-selection no longer collapses to single-select before the drag begins. - Arrow-key nudge + Timeline + keyframe button + Properties panel all fan out across the multi-selection: every editable layer gets the value, kind-specific panel sections (text, polygon, fill, stroke, blur, shadow, mask, button) collapse to leave just Transform + Opacity, and the trash button labels with the selection count. - Click on empty stage with a polygon selected now deselects the layer too instead of leaving the polygon stuck in the panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
289fa770b0
commit
1f2812c64d
8 files changed
+1168
-157
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+23
-12
@@ -319,20 +319,31 @@ function nudgeSelection(dx: number, dy: number): boolean {
|
|||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const layerId = store.selectedLayerId
|
// Layer selection: nudge every selected layer. Multi-select
|
||||||
if (!layerId) return false
|
// arrow-nudge mirrors the canvas's multi-drag — the whole group
|
||||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
// travels together.
|
||||||
if (!layer) return false
|
const layerIds = store.selectedLayerIds.length
|
||||||
if (layer.locked) return false
|
? store.selectedLayerIds
|
||||||
|
: store.selectedLayerId
|
||||||
|
? [store.selectedLayerId]
|
||||||
|
: []
|
||||||
|
if (!layerIds.length) return false
|
||||||
|
const lockedGroupChildIds = new Set<string>()
|
||||||
for (const g of store.scene.groups ?? []) {
|
for (const g of store.scene.groups ?? []) {
|
||||||
if (g.locked && g.childIds.includes(layerId)) return false
|
if (g.locked) for (const cid of g.childIds) lockedGroupChildIds.add(cid)
|
||||||
}
|
}
|
||||||
const samp = sampleLayer(layer, store.playhead)
|
let moved = false
|
||||||
store.upsertKeyframe(layerId, store.playhead, {
|
for (const lid of layerIds) {
|
||||||
x: samp.x + dx,
|
const layer = store.activeLayers.find((l) => l.id === lid)
|
||||||
y: samp.y + dy,
|
if (!layer || layer.locked || lockedGroupChildIds.has(lid)) continue
|
||||||
})
|
const samp = sampleLayer(layer, store.playhead)
|
||||||
return true
|
store.upsertKeyframe(lid, store.playhead, {
|
||||||
|
x: samp.x + dx,
|
||||||
|
y: samp.y + dy,
|
||||||
|
})
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
return moved
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
|||||||
+322
-100
@@ -816,8 +816,8 @@ type StackItem =
|
|||||||
| { kind: 'clipInstance'; bundle: RenderedClipInstance }
|
| { kind: 'clipInstance'; bundle: RenderedClipInstance }
|
||||||
|
|
||||||
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
|
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
|
||||||
const live = liveLayerPose.value
|
const live = liveLayerPoses.value[layerId]
|
||||||
if (!live || live.layerId !== layerId) return props
|
if (!live) return props
|
||||||
return {
|
return {
|
||||||
...props,
|
...props,
|
||||||
x: live.x,
|
x: live.x,
|
||||||
@@ -1544,7 +1544,6 @@ interface PivotInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LiveLayerPose {
|
interface LiveLayerPose {
|
||||||
layerId: string
|
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
scaleX: number
|
scaleX: number
|
||||||
@@ -1552,7 +1551,17 @@ interface LiveLayerPose {
|
|||||||
rotation: number
|
rotation: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveLayerPose = ref<LiveLayerPose | null>(null)
|
/** Per-layer live pose keyed by layer id. Multiple entries exist
|
||||||
|
* during a multi-select drag or transform — Konva mutates each
|
||||||
|
* node in place and fires @transform / @dragmove per node, and
|
||||||
|
* we record each one so the reactive v-rect / v-line / v-image
|
||||||
|
* configs don't snap non-focused nodes back to their sampled
|
||||||
|
* pose while Konva is mid-way through rotating the group. */
|
||||||
|
const liveLayerPoses = ref<Record<string, LiveLayerPose>>({})
|
||||||
|
|
||||||
|
function clearLivePoses() {
|
||||||
|
liveLayerPoses.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
const pivotInfo = computed<PivotInfo | null>(() => {
|
const pivotInfo = computed<PivotInfo | null>(() => {
|
||||||
const id = store.selectedLayerId
|
const id = store.selectedLayerId
|
||||||
@@ -1562,8 +1571,8 @@ const pivotInfo = computed<PivotInfo | null>(() => {
|
|||||||
const asset = assetMap.value.get(layer.assetId)
|
const asset = assetMap.value.get(layer.assetId)
|
||||||
if (!asset) return null
|
if (!asset) return null
|
||||||
const sampled = sampleLayer(layer, store.playhead)
|
const sampled = sampleLayer(layer, store.playhead)
|
||||||
const live = liveLayerPose.value
|
const live = liveLayerPoses.value[id]
|
||||||
if (live && live.layerId === id) {
|
if (live) {
|
||||||
return {
|
return {
|
||||||
layerId: layer.id,
|
layerId: layer.id,
|
||||||
sampled: {
|
sampled: {
|
||||||
@@ -1611,6 +1620,10 @@ interface VertexHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const polygonVertexHandles = computed<VertexHandle[]>(() => {
|
const polygonVertexHandles = computed<VertexHandle[]>(() => {
|
||||||
|
// Vertex handles only apply to a SOLO polygon selection — in a
|
||||||
|
// mixed multi-selection the shared transformer bounding box owns
|
||||||
|
// scale / rotate, and per-vertex handles would visually fight it.
|
||||||
|
if (store.selectedLayerIds.length > 1) return []
|
||||||
const id = store.selectedLayerId
|
const id = store.selectedLayerId
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
const layer = store.activeLayers.find((l) => l.id === id)
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
@@ -1618,12 +1631,12 @@ const polygonVertexHandles = computed<VertexHandle[]>(() => {
|
|||||||
const asset = assetMap.value.get(layer.assetId)
|
const asset = assetMap.value.get(layer.assetId)
|
||||||
if (!asset || asset.kind !== 'polygon') return []
|
if (!asset || asset.kind !== 'polygon') return []
|
||||||
const sampled = sampleLayer(layer, store.playhead)
|
const sampled = sampleLayer(layer, store.playhead)
|
||||||
const live = liveLayerPose.value
|
const live = liveLayerPoses.value[id]
|
||||||
const cx = live && live.layerId === id ? live.x : sampled.x
|
const cx = live ? live.x : sampled.x
|
||||||
const cy = live && live.layerId === id ? live.y : sampled.y
|
const cy = live ? live.y : sampled.y
|
||||||
const rot = live && live.layerId === id ? live.rotation : sampled.rotation
|
const rot = live ? live.rotation : sampled.rotation
|
||||||
const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX
|
const scX = live ? live.scaleX : sampled.scaleX
|
||||||
const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY
|
const scY = live ? live.scaleY : sampled.scaleY
|
||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
const pts = sampled.points ?? asset.points
|
const pts = sampled.points ?? asset.points
|
||||||
@@ -1823,12 +1836,12 @@ const polygonTangentHandles = computed<TangentHandle[]>(() => {
|
|||||||
if (vsel.size !== 1) return []
|
if (vsel.size !== 1) return []
|
||||||
const onlyIndex = vsel.values().next().value!
|
const onlyIndex = vsel.values().next().value!
|
||||||
const sampled = sampleLayer(layer, store.playhead)
|
const sampled = sampleLayer(layer, store.playhead)
|
||||||
const live = liveLayerPose.value
|
const live = liveLayerPoses.value[id]
|
||||||
const cx = live && live.layerId === id ? live.x : sampled.x
|
const cx = live ? live.x : sampled.x
|
||||||
const cy = live && live.layerId === id ? live.y : sampled.y
|
const cy = live ? live.y : sampled.y
|
||||||
const rot = live && live.layerId === id ? live.rotation : sampled.rotation
|
const rot = live ? live.rotation : sampled.rotation
|
||||||
const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX
|
const scX = live ? live.scaleX : sampled.scaleX
|
||||||
const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY
|
const scY = live ? live.scaleY : sampled.scaleY
|
||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
const pts = sampled.points ?? asset.points
|
const pts = sampled.points ?? asset.points
|
||||||
@@ -1943,6 +1956,14 @@ const boxSelect = ref<{
|
|||||||
startY: number
|
startY: number
|
||||||
curX: number
|
curX: number
|
||||||
curY: number
|
curY: number
|
||||||
|
/** 'vertex' reuses the existing polygon-vertex lasso; 'layer'
|
||||||
|
* intersects every unlocked layer's bbox with the drag rect to
|
||||||
|
* build a scene-level multi-selection. */
|
||||||
|
mode: 'vertex' | 'layer'
|
||||||
|
/** Shift-held at drag start → add the marquee's hits to the
|
||||||
|
* existing selection rather than replacing it. A tiny shift
|
||||||
|
* click also leaves the current selection untouched. */
|
||||||
|
additive: boolean
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const boxSelectRectPx = computed(() => {
|
const boxSelectRectPx = computed(() => {
|
||||||
@@ -1969,23 +1990,100 @@ function finishBoxSelect() {
|
|||||||
const b = boxSelect.value
|
const b = boxSelect.value
|
||||||
boxSelect.value = null
|
boxSelect.value = null
|
||||||
if (!b) return
|
if (!b) return
|
||||||
const sel = selectedPolygonLayer.value
|
|
||||||
if (!sel) return
|
|
||||||
const { layer, asset } = sel
|
|
||||||
const sampled = sampleLayer(layer, store.playhead)
|
|
||||||
const pts = sampled.points ?? asset.points
|
|
||||||
const minX = Math.min(b.startX, b.curX)
|
const minX = Math.min(b.startX, b.curX)
|
||||||
const maxX = Math.max(b.startX, b.curX)
|
const maxX = Math.max(b.startX, b.curX)
|
||||||
const minY = Math.min(b.startY, b.curY)
|
const minY = Math.min(b.startY, b.curY)
|
||||||
const maxY = Math.max(b.startY, b.curY)
|
const maxY = Math.max(b.startY, b.curY)
|
||||||
const indices = new Set<number>()
|
|
||||||
for (let i = 0; i < pts.length; i++) {
|
if (b.mode === 'vertex') {
|
||||||
const world = transformLocalToWorld(layer, sampled, pts[i])
|
const sel = selectedPolygonLayer.value
|
||||||
if (world.x >= minX && world.x <= maxX && world.y >= minY && world.y <= maxY) {
|
if (!sel) return
|
||||||
indices.add(i)
|
const { layer, asset } = sel
|
||||||
|
const sampled = sampleLayer(layer, store.playhead)
|
||||||
|
const pts = sampled.points ?? asset.points
|
||||||
|
const indices = new Set<number>()
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const world = transformLocalToWorld(layer, sampled, pts[i])
|
||||||
|
if (world.x >= minX && world.x <= maxX && world.y >= minY && world.y <= maxY) {
|
||||||
|
indices.add(i)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
selectedVertexIndices.value = indices
|
||||||
|
// Tiny click with no vertex hits → treat as an empty-stage
|
||||||
|
// click and deselect the polygon layer entirely, matching how
|
||||||
|
// layer-mode handles the same gesture. Without this a polygon
|
||||||
|
// (or freshly-drawn freeform) stays selected forever.
|
||||||
|
const dPxX = Math.abs(b.curX - b.startX) * zoom.value
|
||||||
|
const dPxY = Math.abs(b.curY - b.startY) * zoom.value
|
||||||
|
if (indices.size === 0 && dPxX < 3 && dPxY < 3) {
|
||||||
|
store.selectLayer(null)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
selectedVertexIndices.value = indices
|
|
||||||
|
// Layer mode — touch to select, not "fully enclosed". Clicks
|
||||||
|
// without drag (≤3 screen-px wiggle) fall through to the
|
||||||
|
// "click-empty-stage → deselect" behaviour so the handler still
|
||||||
|
// covers both interactions from a single pointerdown on the
|
||||||
|
// empty stage. Shift on a tiny click is a no-op (keeps the
|
||||||
|
// existing selection intact).
|
||||||
|
const draggedX = Math.abs(b.curX - b.startX) * zoom.value
|
||||||
|
const draggedY = Math.abs(b.curY - b.startY) * zoom.value
|
||||||
|
if (draggedX < 3 && draggedY < 3) {
|
||||||
|
if (!b.additive) store.selectLayer(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const stage = stageRef.value?.getStage()
|
||||||
|
if (!stage) {
|
||||||
|
if (!b.additive) store.selectLayer(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hits: string[] = []
|
||||||
|
for (const layer of store.activeLayers) {
|
||||||
|
if (isLayerEffectivelyLocked(layer)) continue
|
||||||
|
if (layer.editorHidden) continue
|
||||||
|
if (!isLayerActiveAt(layer, store.playhead)) continue
|
||||||
|
const node = stage.findOne<Konva.Node>(`#${layer.id}`)
|
||||||
|
if (!node) continue
|
||||||
|
// getClientRect returns bbox in stage (absolute) coords —
|
||||||
|
// convert to world coords by undoing the view zoom/pan so we
|
||||||
|
// can intersect against the world-space drag rect.
|
||||||
|
const rect = node.getClientRect({ skipTransform: false })
|
||||||
|
const z = zoom.value || 1
|
||||||
|
const rx0 = (rect.x - viewX.value) / z
|
||||||
|
const ry0 = (rect.y - viewY.value) / z
|
||||||
|
const rx1 = rx0 + rect.width / z
|
||||||
|
const ry1 = ry0 + rect.height / z
|
||||||
|
if (rx1 < minX || rx0 > maxX || ry1 < minY || ry0 > maxY) continue
|
||||||
|
hits.push(layer.id)
|
||||||
|
}
|
||||||
|
// Additive: merge hits with the existing selection, keeping the
|
||||||
|
// click order so the anchor lands on whatever the user targeted
|
||||||
|
// last. Replace mode discards the old selection.
|
||||||
|
const merged = b.additive
|
||||||
|
? (() => {
|
||||||
|
const seen = new Set(store.selectedLayerIds)
|
||||||
|
const out = [...store.selectedLayerIds]
|
||||||
|
for (const h of hits) {
|
||||||
|
if (!seen.has(h)) {
|
||||||
|
out.push(h)
|
||||||
|
seen.add(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})()
|
||||||
|
: hits
|
||||||
|
if (merged.length === 0) {
|
||||||
|
store.selectLayer(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (merged.length === 1) {
|
||||||
|
store.selectLayer(merged[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.selectedLayerIds = merged
|
||||||
|
store.selectedLayerId = merged[merged.length - 1]
|
||||||
|
store.selectedClipInstanceId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const draggingPivot = ref(false)
|
const draggingPivot = ref(false)
|
||||||
@@ -2014,27 +2112,36 @@ function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
|||||||
store.shiftLayerPivot(info.layerId, dx, dy, localDx, localDy)
|
store.shiftLayerPivot(info.layerId, dx, dy, localDx, localDy)
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformerConfig = {
|
const transformerConfig = computed(() => {
|
||||||
rotateEnabled: true,
|
// Rotation on a multi-selection would pin every node to a single
|
||||||
rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
|
// shared pivot (the transformer bbox centre), which isn't what
|
||||||
rotationSnapTolerance: 4,
|
// users expect — each layer has its own offset / pivot and should
|
||||||
enabledAnchors: [
|
// only rotate around its own origin. Disable rotation whenever
|
||||||
'top-left',
|
// more than one node is wrapped so the handle disappears and no
|
||||||
'top-center',
|
// ambiguous combined rotate can happen.
|
||||||
'top-right',
|
const multi = store.selectedLayerIds.length > 1
|
||||||
'middle-right',
|
return {
|
||||||
'middle-left',
|
rotateEnabled: !multi,
|
||||||
'bottom-left',
|
rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
|
||||||
'bottom-center',
|
rotationSnapTolerance: 4,
|
||||||
'bottom-right',
|
enabledAnchors: [
|
||||||
],
|
'top-left',
|
||||||
anchorSize: 9,
|
'top-center',
|
||||||
borderStroke: '#4f8cff',
|
'top-right',
|
||||||
anchorStroke: '#4f8cff',
|
'middle-right',
|
||||||
anchorFill: '#ffffff',
|
'middle-left',
|
||||||
padding: 2,
|
'bottom-left',
|
||||||
ignoreStroke: true,
|
'bottom-center',
|
||||||
}
|
'bottom-right',
|
||||||
|
],
|
||||||
|
anchorSize: 9,
|
||||||
|
borderStroke: '#4f8cff',
|
||||||
|
anchorStroke: '#4f8cff',
|
||||||
|
anchorFill: '#ffffff',
|
||||||
|
padding: 2,
|
||||||
|
ignoreStroke: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let attachRetryTimer: ReturnType<typeof setTimeout> | null = null
|
let attachRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
@@ -2062,30 +2169,43 @@ function attachTransformer(retriesLeft = 3) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = store.selectedLayerId
|
// Multi-select: wrap every selected layer node in one transformer
|
||||||
if (!id) {
|
// so the bounding box + scale / rotate handles span the group.
|
||||||
|
// A SOLE polygon selection still bypasses the transformer because
|
||||||
|
// a single polygon is edited via vertex handles; mixed selections
|
||||||
|
// that happen to include a polygon fold it into the shared bbox.
|
||||||
|
const ids = store.selectedLayerIds.length
|
||||||
|
? store.selectedLayerIds
|
||||||
|
: store.selectedLayerId
|
||||||
|
? [store.selectedLayerId]
|
||||||
|
: []
|
||||||
|
if (!ids.length) {
|
||||||
tr.nodes([])
|
tr.nodes([])
|
||||||
tr.getLayer()?.batchDraw()
|
tr.getLayer()?.batchDraw()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Polygons are edited via vertex handles — no bulk transformer
|
const single = ids.length === 1
|
||||||
const layer = store.activeLayers.find((l) => l.id === id)
|
const nodes: Konva.Node[] = []
|
||||||
if (layer) {
|
let anyMissing = false
|
||||||
|
for (const id of ids) {
|
||||||
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
|
if (!layer) continue
|
||||||
const asset = assetMap.value.get(layer.assetId)
|
const asset = assetMap.value.get(layer.assetId)
|
||||||
if (asset?.kind === 'polygon') {
|
if (asset?.kind === 'polygon' && single) continue
|
||||||
tr.nodes([])
|
const node = stage.findOne<Konva.Node>(`#${id}`)
|
||||||
tr.getLayer()?.batchDraw()
|
if (!node) {
|
||||||
return
|
anyMissing = true
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
nodes.push(node)
|
||||||
}
|
}
|
||||||
const node = stage.findOne<Konva.Node>(`#${id}`)
|
if (anyMissing && retriesLeft > 0) {
|
||||||
if (!node && retriesLeft > 0) {
|
// Freshly-rendered layers haven't attached yet; retry on the
|
||||||
// The Konva node for a freshly-rendered layer may not be attached yet.
|
// next animation frame before giving up on the ids we missed.
|
||||||
// Retry on the next animation frame a few times before giving up.
|
|
||||||
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
|
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tr.nodes(node ? [node] : [])
|
tr.nodes(nodes)
|
||||||
tr.getLayer()?.batchDraw()
|
tr.getLayer()?.batchDraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2093,6 +2213,7 @@ watch(
|
|||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
store.selectedLayerId,
|
store.selectedLayerId,
|
||||||
|
store.selectedLayerIds,
|
||||||
store.selectedClipInstanceId,
|
store.selectedClipInstanceId,
|
||||||
stackItems.value,
|
stackItems.value,
|
||||||
store.playhead,
|
store.playhead,
|
||||||
@@ -2105,21 +2226,50 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
function updateLivePose(node: Konva.Node, layerId: string) {
|
function updateLivePose(node: Konva.Node, layerId: string) {
|
||||||
liveLayerPose.value = {
|
liveLayerPoses.value = {
|
||||||
layerId,
|
...liveLayerPoses.value,
|
||||||
x: node.x(),
|
[layerId]: {
|
||||||
y: node.y(),
|
x: node.x(),
|
||||||
scaleX: node.scaleX(),
|
y: node.y(),
|
||||||
scaleY: node.scaleY(),
|
scaleX: node.scaleX(),
|
||||||
rotation: node.rotation(),
|
scaleY: node.scaleY(),
|
||||||
|
rotation: node.rotation(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
|
function handleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
|
||||||
const node = e.target
|
const node = e.target
|
||||||
const id = node.id()
|
const id = node.id()
|
||||||
if (!store.activeLayers.some((l) => l.id === id)) return
|
const draggedLayer = store.activeLayers.find((l) => l.id === id)
|
||||||
|
if (!draggedLayer) return
|
||||||
updateLivePose(node, id)
|
updateLivePose(node, id)
|
||||||
|
// Multi-select drag: compute the dragged node's delta vs its
|
||||||
|
// sampled pose at the current playhead, then mirror it onto every
|
||||||
|
// sibling Konva node so the whole selection tracks together. Each
|
||||||
|
// sibling's baseline is also its sampled pose (its Konva node
|
||||||
|
// hasn't moved yet — only the dragged one has).
|
||||||
|
if (
|
||||||
|
store.selectedLayerIds.length > 1 &&
|
||||||
|
store.selectedLayerIds.includes(id)
|
||||||
|
) {
|
||||||
|
const stage = stageRef.value?.getStage()
|
||||||
|
if (!stage) return
|
||||||
|
const baseSamp = sampleLayer(draggedLayer, store.playhead)
|
||||||
|
const dx = node.x() - baseSamp.x
|
||||||
|
const dy = node.y() - baseSamp.y
|
||||||
|
for (const lid of store.selectedLayerIds) {
|
||||||
|
if (lid === id) continue
|
||||||
|
const other = stage.findOne<Konva.Node>(`#${lid}`)
|
||||||
|
if (!other) continue
|
||||||
|
const otherLayer = store.activeLayers.find((l) => l.id === lid)
|
||||||
|
if (!otherLayer) continue
|
||||||
|
const s = sampleLayer(otherLayer, store.playhead)
|
||||||
|
other.x(s.x + dx)
|
||||||
|
other.y(s.y + dy)
|
||||||
|
}
|
||||||
|
stage.batchDraw()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTransform(e: Konva.KonvaEventObject<Event>) {
|
function handleTransform(e: Konva.KonvaEventObject<Event>) {
|
||||||
@@ -2178,10 +2328,10 @@ const animationPath = computed<PathOverlay | null>(() => {
|
|||||||
// path reflects the live position without mutating the store. If a kf
|
// path reflects the live position without mutating the store. If a kf
|
||||||
// at the playhead already exists, override its x/y; otherwise splice
|
// at the playhead already exists, override its x/y; otherwise splice
|
||||||
// in a synthetic one so the path gets a live endpoint.
|
// in a synthetic one so the path gets a live endpoint.
|
||||||
const live = liveLayerPose.value
|
const live = liveLayerPoses.value[origLayer.id]
|
||||||
let layer = origLayer
|
let layer = origLayer
|
||||||
let workingArr = arr
|
let workingArr = arr
|
||||||
if (live && live.layerId === origLayer.id) {
|
if (live) {
|
||||||
const cloned: Keyframe[] = arr.map((k) => ({ ...k, props: { ...k.props } }))
|
const cloned: Keyframe[] = arr.map((k) => ({ ...k, props: { ...k.props } }))
|
||||||
const existing = cloned.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
|
const existing = cloned.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -2395,19 +2545,29 @@ function pointerToWorld(e: Konva.KonvaEventObject<MouseEvent>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
// Box-select for vertices when a polygon layer is selected and the
|
// Marquee on the empty stage. Two flavours share the same
|
||||||
// click lands on the empty stage (not a shape/handle).
|
// drag machinery: vertex-lasso when a polygon is being edited,
|
||||||
if (
|
// layer multi-select otherwise. pointerup fires finishBoxSelect
|
||||||
!store.drawingMode &&
|
// which routes by `mode`.
|
||||||
selectedPolygonLayer.value &&
|
if (!store.drawingMode && e.target === e.target.getStage()) {
|
||||||
e.target === e.target.getStage()
|
|
||||||
) {
|
|
||||||
const native = e.evt as MouseEvent
|
const native = e.evt as MouseEvent
|
||||||
if (native.button === 0 && !native.ctrlKey) {
|
if (native.button === 0 && !native.ctrlKey) {
|
||||||
const wp = pointerToWorld(e)
|
const wp = pointerToWorld(e)
|
||||||
if (wp) {
|
if (wp) {
|
||||||
boxSelect.value = { startX: wp.x, startY: wp.y, curX: wp.x, curY: wp.y }
|
// Vertex-lasso mode only applies to a SOLO polygon
|
||||||
if (!native.shiftKey) selectedVertexIndices.value = new Set()
|
// selection — inside a multi-select the box always targets
|
||||||
|
// layers so new picks add to the existing group.
|
||||||
|
const mode: 'vertex' | 'layer' =
|
||||||
|
selectedPolygonLayer.value && store.selectedLayerIds.length <= 1
|
||||||
|
? 'vertex'
|
||||||
|
: 'layer'
|
||||||
|
boxSelect.value = {
|
||||||
|
startX: wp.x, startY: wp.y, curX: wp.x, curY: wp.y, mode,
|
||||||
|
additive: native.shiftKey,
|
||||||
|
}
|
||||||
|
if (mode === 'vertex' && !native.shiftKey) {
|
||||||
|
selectedVertexIndices.value = new Set()
|
||||||
|
}
|
||||||
closeVertexMenu()
|
closeVertexMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2472,7 +2632,33 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const match = store.activeLayers.find((l) => l.id === id)
|
const match = store.activeLayers.find((l) => l.id === id)
|
||||||
store.selectLayer(match ? match.id : null)
|
if (!match) {
|
||||||
|
store.selectLayer(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Shift toggles the clicked layer into the existing selection.
|
||||||
|
// Matches the timeline's shift-click-on-track-name behaviour so
|
||||||
|
// both surfaces build multi-selections the same way. Anchor moves
|
||||||
|
// to the most-recently-clicked id for a predictable Properties
|
||||||
|
// panel focus.
|
||||||
|
if ((e.evt as MouseEvent).shiftKey) {
|
||||||
|
store.toggleLayerSelection(match.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Pressed on a layer that's already part of the multi-selection
|
||||||
|
// → don't collapse to a single-layer select. Konva's drag
|
||||||
|
// machinery is already arming on this node, and handleDragMove
|
||||||
|
// expects `selectedLayerIds.length > 1` to mirror the delta onto
|
||||||
|
// the siblings. Leaving the selection alone keeps the group drag
|
||||||
|
// working. Without this, clicking any one of the selected shapes
|
||||||
|
// would wipe the others and only drag the clicked one.
|
||||||
|
if (
|
||||||
|
store.selectedLayerIds.length > 1 &&
|
||||||
|
store.selectedLayerIds.includes(match.id)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.selectLayer(match.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStageMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
|
function handleStageMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
@@ -2665,6 +2851,25 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
|||||||
if (!layer) return
|
if (!layer) return
|
||||||
const newX = node.x()
|
const newX = node.x()
|
||||||
const newY = node.y()
|
const newY = node.y()
|
||||||
|
// Multi-layer drag: persist every selected layer's new position.
|
||||||
|
// handleDragMove already mirrored the delta onto each sibling's
|
||||||
|
// Konva node, so each node's current x/y is already correct —
|
||||||
|
// just upsert from the live Konva state.
|
||||||
|
if (
|
||||||
|
store.selectedLayerIds.length > 1 &&
|
||||||
|
store.selectedLayerIds.includes(id)
|
||||||
|
) {
|
||||||
|
const stage = stageRef.value?.getStage()
|
||||||
|
if (stage) {
|
||||||
|
for (const lid of store.selectedLayerIds) {
|
||||||
|
const n = stage.findOne<Konva.Node>(`#${lid}`)
|
||||||
|
if (!n) continue
|
||||||
|
store.upsertKeyframe(lid, store.playhead, { x: n.x(), y: n.y() })
|
||||||
|
}
|
||||||
|
clearLivePoses()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// Multi-keyframe shift applies only when the layer being dragged is
|
// Multi-keyframe shift applies only when the layer being dragged is
|
||||||
// itself part of the current selection. Otherwise a stale selection on
|
// itself part of the current selection. Otherwise a stale selection on
|
||||||
// some other layer would steal the drag delta, leaving the dragged
|
// some other layer would steal the drag delta, leaving the dragged
|
||||||
@@ -2681,7 +2886,7 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
|||||||
store.upsertKeyframe(id, store.playhead, { x: newX, y: newY })
|
store.upsertKeyframe(id, store.playhead, { x: newX, y: newY })
|
||||||
selectKfAtPlayhead(layer, layer.separated ? 'position' : 'main')
|
selectKfAtPlayhead(layer, layer.separated ? 'position' : 'main')
|
||||||
}
|
}
|
||||||
liveLayerPose.value = null
|
clearLivePoses()
|
||||||
}
|
}
|
||||||
|
|
||||||
function anySelectedKfAtPlayhead(): boolean {
|
function anySelectedKfAtPlayhead(): boolean {
|
||||||
@@ -2717,14 +2922,28 @@ function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
|
|||||||
const node = e.target
|
const node = e.target
|
||||||
const id = node.id()
|
const id = node.id()
|
||||||
if (!store.activeLayers.some((l) => l.id === id)) return
|
if (!store.activeLayers.some((l) => l.id === id)) return
|
||||||
store.upsertKeyframe(id, store.playhead, {
|
// Multi-select transform: each node's @transformend fires
|
||||||
x: node.x(),
|
// separately, but Konva only guarantees the event on a subset
|
||||||
y: node.y(),
|
// (the dragged anchor mostly). To be robust, persist every
|
||||||
scaleX: node.scaleX(),
|
// selected layer's live Konva pose here and clear at the end.
|
||||||
scaleY: node.scaleY(),
|
// Single-select collapses to the same loop with one id.
|
||||||
rotation: node.rotation(),
|
const ids =
|
||||||
})
|
store.selectedLayerIds.length > 1 && store.selectedLayerIds.includes(id)
|
||||||
liveLayerPose.value = null
|
? store.selectedLayerIds
|
||||||
|
: [id]
|
||||||
|
const stage = stageRef.value?.getStage()
|
||||||
|
for (const lid of ids) {
|
||||||
|
const n = lid === id ? node : stage?.findOne<Konva.Node>(`#${lid}`)
|
||||||
|
if (!n) continue
|
||||||
|
store.upsertKeyframe(lid, store.playhead, {
|
||||||
|
x: n.x(),
|
||||||
|
y: n.y(),
|
||||||
|
scaleX: n.scaleX(),
|
||||||
|
scaleY: n.scaleY(),
|
||||||
|
rotation: n.rotation(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
clearLivePoses()
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROT_ARM_PX = 44
|
const ROT_ARM_PX = 44
|
||||||
@@ -2794,30 +3013,33 @@ function onRotationHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
|
|||||||
const raw = (Math.atan2(dx, -dy) * 180) / Math.PI
|
const raw = (Math.atan2(dx, -dy) * 180) / Math.PI
|
||||||
// Snap to the 360°-equivalent nearest to the previous rotation so small
|
// Snap to the 360°-equivalent nearest to the previous rotation so small
|
||||||
// motions don't flip a full turn across the ±180° boundary.
|
// motions don't flip a full turn across the ±180° boundary.
|
||||||
const prev = liveLayerPose.value?.rotation ?? info.sampled.rotation
|
const prevLive = liveLayerPoses.value[info.layerId]
|
||||||
|
const prev = prevLive?.rotation ?? info.sampled.rotation
|
||||||
let deg = raw
|
let deg = raw
|
||||||
while (deg - prev > 180) deg -= 360
|
while (deg - prev > 180) deg -= 360
|
||||||
while (deg - prev < -180) deg += 360
|
while (deg - prev < -180) deg += 360
|
||||||
const base = liveLayerPose.value ?? {
|
const base = prevLive ?? {
|
||||||
layerId: info.layerId,
|
|
||||||
x: info.sampled.x,
|
x: info.sampled.x,
|
||||||
y: info.sampled.y,
|
y: info.sampled.y,
|
||||||
scaleX: info.sampled.scaleX,
|
scaleX: info.sampled.scaleX,
|
||||||
scaleY: info.sampled.scaleY,
|
scaleY: info.sampled.scaleY,
|
||||||
rotation: info.sampled.rotation,
|
rotation: info.sampled.rotation,
|
||||||
}
|
}
|
||||||
liveLayerPose.value = { ...base, layerId: info.layerId, rotation: deg }
|
liveLayerPoses.value = {
|
||||||
|
...liveLayerPoses.value,
|
||||||
|
[info.layerId]: { ...base, rotation: deg },
|
||||||
|
}
|
||||||
rotationHandlePos.value = { x: node.x(), y: node.y() }
|
rotationHandlePos.value = { x: node.x(), y: node.y() }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRotationHandleDragEnd() {
|
function onRotationHandleDragEnd() {
|
||||||
const info = pivotInfo.value
|
const info = pivotInfo.value
|
||||||
const live = liveLayerPose.value
|
const live = info ? liveLayerPoses.value[info.layerId] : null
|
||||||
if (info && live && live.layerId === info.layerId) {
|
if (info && live) {
|
||||||
store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation })
|
store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation })
|
||||||
}
|
}
|
||||||
rotationHandlePos.value = null
|
rotationHandlePos.value = null
|
||||||
liveLayerPose.value = null
|
clearLivePoses()
|
||||||
}
|
}
|
||||||
|
|
||||||
let ro: ResizeObserver | null = null
|
let ro: ResizeObserver | null = null
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import type { Action, AnimatableProps } from '@/types/scene'
|
import type { Action, AnimatableProps, Layer } from '@/types/scene'
|
||||||
import RangeSlider from './RangeSlider.vue'
|
import RangeSlider from './RangeSlider.vue'
|
||||||
import ActionEditor from './ActionEditor.vue'
|
import ActionEditor from './ActionEditor.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
@@ -74,6 +74,41 @@ const sampled = computed<AnimatableProps | null>(() =>
|
|||||||
selected.value ? sampleLayer(selected.value, store.playhead) : null,
|
selected.value ? sampleLayer(selected.value, store.playhead) : null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** True when the user has more than one layer in the selection.
|
||||||
|
* Drives the multi-edit flow: writers fan out across every
|
||||||
|
* editable selected layer, kind-specific sections collapse, and
|
||||||
|
* the shared-common values come from the anchor (selected). */
|
||||||
|
const multiSelected = computed(() => store.selectedLayerIds.length > 1)
|
||||||
|
|
||||||
|
const lockedGroupChildIds = computed(() => {
|
||||||
|
const out = new Set<string>()
|
||||||
|
for (const g of store.scene.groups ?? []) {
|
||||||
|
if (g.locked) for (const c of g.childIds) out.add(c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Layers that should receive multi-edit writes — every selection
|
||||||
|
* member that isn't directly locked nor inside a locked group.
|
||||||
|
* Falls back to the single anchor for non-multi selections so the
|
||||||
|
* bind helpers below have a uniform code path. */
|
||||||
|
const editableLayers = computed<Layer[]>(() => {
|
||||||
|
const ids = store.selectedLayerIds.length
|
||||||
|
? store.selectedLayerIds
|
||||||
|
: store.selectedLayerId
|
||||||
|
? [store.selectedLayerId]
|
||||||
|
: []
|
||||||
|
const out: Layer[] = []
|
||||||
|
for (const id of ids) {
|
||||||
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
|
if (!layer) continue
|
||||||
|
if (layer.locked) continue
|
||||||
|
if (lockedGroupChildIds.value.has(layer.id)) continue
|
||||||
|
out.push(layer)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
/** True when the selected layer is locked directly or reached through
|
/** True when the selected layer is locked directly or reached through
|
||||||
* a locked parent group. Suppresses the edit UI — the user must
|
* a locked parent group. Suppresses the edit UI — the user must
|
||||||
* unlock the layer (or the group) before changing properties. */
|
* unlock the layer (or the group) before changing properties. */
|
||||||
@@ -361,14 +396,25 @@ const strokeTransparent = computed<boolean>({
|
|||||||
|
|
||||||
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
|
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
|
||||||
|
|
||||||
|
/** Patch one key on every editable selected layer, snapshotting
|
||||||
|
* each layer's other props at the playhead so the new keyframe
|
||||||
|
* doesn't accidentally collapse to defaults. Used by the bind
|
||||||
|
* helpers + the shadow / fill / stroke writers below. */
|
||||||
|
function writeKeyToSelection(patch: Partial<AnimatableProps>) {
|
||||||
|
const layers = editableLayers.value
|
||||||
|
if (!layers.length) return
|
||||||
|
for (const l of layers) {
|
||||||
|
store.upsertKeyframe(l.id, store.playhead, patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bind(key: NumericKey, decimals = 0) {
|
function bind(key: NumericKey, decimals = 0) {
|
||||||
const factor = 10 ** decimals
|
const factor = 10 ** decimals
|
||||||
return computed({
|
return computed({
|
||||||
|
// Reading sticks with the anchor (sampled) so the input shows a
|
||||||
|
// single concrete value. Writes fan out to every editable layer.
|
||||||
get: () => (sampled.value ? Math.round(sampled.value[key] * factor) / factor : 0),
|
get: () => (sampled.value ? Math.round(sampled.value[key] * factor) / factor : 0),
|
||||||
set: (v: number) => {
|
set: (v: number) => writeKeyToSelection({ [key]: v }),
|
||||||
if (!selected.value) return
|
|
||||||
store.upsertKeyframe(selected.value.id, store.playhead, { [key]: v })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,16 +426,28 @@ const rotation = bind('rotation')
|
|||||||
const opacity = bind('opacity', 2)
|
const opacity = bind('opacity', 2)
|
||||||
|
|
||||||
function removeKeyframeHere() {
|
function removeKeyframeHere() {
|
||||||
if (!selected.value) return
|
for (const l of editableLayers.value) {
|
||||||
store.removeKeyframeAt(selected.value.id, store.playhead)
|
store.removeKeyframeAt(l.id, store.playhead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trash button — removes the selection. Single-select drops one
|
||||||
|
* layer; multi-select fans out across every editable id. */
|
||||||
|
function deleteSelection() {
|
||||||
|
for (const l of editableLayers.value) {
|
||||||
|
store.removeLayer(l.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>Properties</h3>
|
<h3>Properties</h3>
|
||||||
<template v-if="selected && sampled && !selectedIsLocked">
|
<template v-if="selected && sampled && editableLayers.length > 0">
|
||||||
<label>Name<input v-model="selected.name" /></label>
|
<div v-if="multiSelected" class="multi-banner">
|
||||||
|
{{ t('properties.multiSelected', { n: editableLayers.length }) }}
|
||||||
|
</div>
|
||||||
|
<label v-if="!multiSelected">Name<input v-model="selected.name" /></label>
|
||||||
|
|
||||||
<div class="kf-row" :class="{ on: hasKeyframeHere }">
|
<div class="kf-row" :class="{ on: hasKeyframeHere }">
|
||||||
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
||||||
@@ -420,7 +478,7 @@ function removeKeyframeHere() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="textAsset" class="prop-group">
|
<section v-if="textAsset && !multiSelected" class="prop-group">
|
||||||
<button class="group-header" @click="toggleSection('text')">
|
<button class="group-header" @click="toggleSection('text')">
|
||||||
<component :is="sectionOpen.text ? PhCaretDown : PhCaretRight" :size="11" />
|
<component :is="sectionOpen.text ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
<span>{{ t('properties.sections.text') }}</span>
|
<span>{{ t('properties.sections.text') }}</span>
|
||||||
@@ -431,7 +489,7 @@ function removeKeyframeHere() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="polygonAsset" class="prop-group">
|
<section v-if="polygonAsset && !multiSelected" class="prop-group">
|
||||||
<button class="group-header" @click="toggleSection('polygon')">
|
<button class="group-header" @click="toggleSection('polygon')">
|
||||||
<component :is="sectionOpen.polygon ? PhCaretDown : PhCaretRight" :size="11" />
|
<component :is="sectionOpen.polygon ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
<span>{{ t('properties.sections.polygon') }}</span>
|
<span>{{ t('properties.sections.polygon') }}</span>
|
||||||
@@ -463,7 +521,7 @@ function removeKeyframeHere() {
|
|||||||
<span>{{ t('properties.sections.appearance') }}</span>
|
<span>{{ t('properties.sections.appearance') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="sectionOpen.appearance" class="group-body">
|
<div v-if="sectionOpen.appearance" class="group-body">
|
||||||
<label v-if="canFill" class="color-label">
|
<label v-if="canFill && !multiSelected" class="color-label">
|
||||||
{{ t('properties.fill') }}
|
{{ t('properties.fill') }}
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
<input
|
<input
|
||||||
@@ -485,7 +543,7 @@ function removeKeyframeHere() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="canStroke" class="color-label">
|
<label v-if="canStroke && !multiSelected" class="color-label">
|
||||||
{{ t('properties.stroke') }}
|
{{ t('properties.stroke') }}
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
<input
|
<input
|
||||||
@@ -507,7 +565,7 @@ function removeKeyframeHere() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="canStroke" class="slider-label">
|
<label v-if="canStroke && !multiSelected" class="slider-label">
|
||||||
{{ t('properties.strokeWidth') }}
|
{{ t('properties.strokeWidth') }}
|
||||||
<RangeSlider
|
<RangeSlider
|
||||||
v-model="strokeWidth"
|
v-model="strokeWidth"
|
||||||
@@ -521,7 +579,7 @@ function removeKeyframeHere() {
|
|||||||
{{ t('properties.opacity') }}
|
{{ t('properties.opacity') }}
|
||||||
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
||||||
</label>
|
</label>
|
||||||
<label v-if="blurAsset" class="slider-label">
|
<label v-if="blurAsset && !multiSelected" class="slider-label">
|
||||||
{{ t('properties.edgeBlur') }}
|
{{ t('properties.edgeBlur') }}
|
||||||
<RangeSlider
|
<RangeSlider
|
||||||
v-model="blurValue"
|
v-model="blurValue"
|
||||||
@@ -534,7 +592,7 @@ function removeKeyframeHere() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="prop-group">
|
<section v-if="!multiSelected" class="prop-group">
|
||||||
<button class="group-header" @click="toggleSection('shadow')">
|
<button class="group-header" @click="toggleSection('shadow')">
|
||||||
<component :is="sectionOpen.shadow ? PhCaretDown : PhCaretRight" :size="11" />
|
<component :is="sectionOpen.shadow ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
<span>{{ t('properties.shadow') }}</span>
|
<span>{{ t('properties.shadow') }}</span>
|
||||||
@@ -589,7 +647,7 @@ function removeKeyframeHere() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="canBeMask" class="prop-group">
|
<section v-if="canBeMask && !multiSelected" class="prop-group">
|
||||||
<button class="group-header" @click="toggleSection('mask')">
|
<button class="group-header" @click="toggleSection('mask')">
|
||||||
<component :is="sectionOpen.mask ? PhCaretDown : PhCaretRight" :size="11" />
|
<component :is="sectionOpen.mask ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
<span>{{ t('properties.mask') }}</span>
|
<span>{{ t('properties.mask') }}</span>
|
||||||
@@ -640,7 +698,7 @@ function removeKeyframeHere() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="buttonAsset" class="prop-group">
|
<section v-if="buttonAsset && !multiSelected" class="prop-group">
|
||||||
<button class="group-header" @click="toggleSection('button')">
|
<button class="group-header" @click="toggleSection('button')">
|
||||||
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
|
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
<span>{{ t('properties.sections.button') }}</span>
|
<span>{{ t('properties.sections.button') }}</span>
|
||||||
@@ -653,12 +711,16 @@ function removeKeyframeHere() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
<button class="danger-btn" @click="deleteSelection">
|
||||||
<PhTrash :size="13" />
|
<PhTrash :size="13" />
|
||||||
{{ t('properties.deleteLayer') }}
|
{{
|
||||||
|
multiSelected
|
||||||
|
? t('properties.deleteLayers', { n: editableLayers.length })
|
||||||
|
: t('properties.deleteLayer')
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<p v-else-if="selected && selectedIsLocked" class="hint">
|
<p v-else-if="selected && editableLayers.length === 0" class="hint">
|
||||||
{{ t('properties.lockedLayer') }}
|
{{ t('properties.lockedLayer') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
|
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
|
||||||
@@ -971,4 +1033,13 @@ input {
|
|||||||
background: rgba(255, 122, 122, 0.18);
|
background: rgba(255, 122, 122, 0.18);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.multi-banner {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+44
-22
@@ -617,36 +617,58 @@ function onLaneClick(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canCreateKf = computed(() => {
|
const canCreateKf = computed(() => {
|
||||||
const id = store.selectedLayerId
|
// Button is available as long as at least one selected layer is
|
||||||
if (!id) return false
|
// unlocked — the multi-select handler skips the locked ones per
|
||||||
const layer = store.activeLayers.find((l) => l.id === id)
|
// id, so a partially-locked selection still writes the usable
|
||||||
if (!layer) return false
|
// frames.
|
||||||
if (layer.locked) return false
|
const ids = store.selectedLayerIds.length
|
||||||
return !lockedGroupChildIds.value.has(id)
|
? store.selectedLayerIds
|
||||||
|
: store.selectedLayerId
|
||||||
|
? [store.selectedLayerId]
|
||||||
|
: []
|
||||||
|
if (!ids.length) return false
|
||||||
|
for (const id of ids) {
|
||||||
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
|
if (!layer) continue
|
||||||
|
if (layer.locked) continue
|
||||||
|
if (lockedGroupChildIds.value.has(id)) continue
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
function addKeyframeForSelected() {
|
function addKeyframeForSelected() {
|
||||||
const id = store.selectedLayerId
|
// Fan out across the whole multi-selection so the user can pin a
|
||||||
if (!id) return
|
// frame on every grouped object at once. Each layer gets its own
|
||||||
const layer = store.activeLayers.find((l) => l.id === id)
|
// sampled snapshot so the keyframe matches the current pose.
|
||||||
if (!layer || isLayerIdLocked(id)) return
|
// Falls back to the single-selection anchor when no multi is set.
|
||||||
|
const ids = store.selectedLayerIds.length
|
||||||
|
? store.selectedLayerIds
|
||||||
|
: store.selectedLayerId
|
||||||
|
? [store.selectedLayerId]
|
||||||
|
: []
|
||||||
|
if (!ids.length) return
|
||||||
// Snap the playhead itself to the nearest keyframe before adding —
|
// Snap the playhead itself to the nearest keyframe before adding —
|
||||||
// the current playhead position is NOT a candidate (it's the value
|
// the current playhead position is NOT a candidate (it's the value
|
||||||
// we're rounding).
|
// we're rounding).
|
||||||
const time = snapTime(store.playhead, { ignorePlayhead: true })
|
const time = snapTime(store.playhead, { ignorePlayhead: true })
|
||||||
const sampled = sampleLayer(layer, time)
|
for (const id of ids) {
|
||||||
const props: Partial<AnimatableProps> = {
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
scaleX: sampled.scaleX,
|
if (!layer || isLayerIdLocked(id)) continue
|
||||||
scaleY: sampled.scaleY,
|
const sampled = sampleLayer(layer, time)
|
||||||
opacity: sampled.opacity,
|
const props: Partial<AnimatableProps> = {
|
||||||
|
scaleX: sampled.scaleX,
|
||||||
|
scaleY: sampled.scaleY,
|
||||||
|
opacity: sampled.opacity,
|
||||||
|
}
|
||||||
|
if (sampled.fill) props.fill = sampled.fill
|
||||||
|
if (!layer.separated) {
|
||||||
|
props.x = sampled.x
|
||||||
|
props.y = sampled.y
|
||||||
|
props.rotation = sampled.rotation
|
||||||
|
}
|
||||||
|
store.upsertKeyframe(id, time, props)
|
||||||
}
|
}
|
||||||
if (sampled.fill) props.fill = sampled.fill
|
|
||||||
if (!layer.separated) {
|
|
||||||
props.x = sampled.x
|
|
||||||
props.y = sampled.y
|
|
||||||
props.rotation = sampled.rotation
|
|
||||||
}
|
|
||||||
store.upsertKeyframe(id, time, props)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
|
function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
|
||||||
|
|||||||
@@ -4445,6 +4445,22 @@ export function registerAllTests(): void {
|
|||||||
expect(ci.x).toBe(before - 1)
|
expect(ci.x).toBe(before - 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('ArrowRight nudges every layer in a multi-selection', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
await clearKeys()
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id
|
||||||
|
const beforeA = sampleLayer(lA, s.playhead).x
|
||||||
|
const beforeB = sampleLayer(lB, s.playhead).x
|
||||||
|
arrow('ArrowRight')
|
||||||
|
await nextTick()
|
||||||
|
expect(sampleLayer(lA, s.playhead).x).toBe(beforeA + 1)
|
||||||
|
expect(sampleLayer(lB, s.playhead).x).toBe(beforeB + 1)
|
||||||
|
})
|
||||||
|
|
||||||
test('nudge is skipped for a locked layer', async () => {
|
test('nudge is skipped for a locked layer', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
await clearKeys()
|
await clearKeys()
|
||||||
@@ -4486,6 +4502,63 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('timeline — add-keyframe button fans across multi-selection', () => {
|
||||||
|
// + keyframe used to write only to the anchor layer. With a
|
||||||
|
// multi-selection the user expects every member to pick up the
|
||||||
|
// frame so grouped motion stays in sync.
|
||||||
|
test('clicking + keyframe with two layers selected gives both one', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.setPlayhead(1.5)
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id
|
||||||
|
const handle = mountComponent(Timeline, {})
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
const btn = handle.host.querySelector('.add-kf-btn') as HTMLButtonElement
|
||||||
|
expect(btn).toBeTruthy()
|
||||||
|
expect(btn.disabled).toBe(false)
|
||||||
|
const beforeA = lA.keyframes.length
|
||||||
|
const beforeB = lB.keyframes.length
|
||||||
|
btn.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(lA.keyframes.length).toBe(beforeA + 1)
|
||||||
|
expect(lB.keyframes.length).toBe(beforeB + 1)
|
||||||
|
// Both land on the (snap-resolved) playhead.
|
||||||
|
expect(lA.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-3)).toBe(true)
|
||||||
|
expect(lB.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-3)).toBe(true)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canCreateKf stays truthy when only some layers in the selection are unlocked', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.setTrackLocked(lA.id, true)
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lA.id // anchor is the locked one
|
||||||
|
const handle = mountComponent(Timeline, {})
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
const btn = handle.host.querySelector('.add-kf-btn') as HTMLButtonElement
|
||||||
|
// Enabled because lB is still unlocked — the handler will
|
||||||
|
// skip the locked lA silently.
|
||||||
|
expect(btn.disabled).toBe(false)
|
||||||
|
btn.click()
|
||||||
|
await nextTick()
|
||||||
|
// Only the unlocked one got a new keyframe.
|
||||||
|
expect(lB.keyframes.length).toBeGreaterThan(0)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('timeline — backspace deletes selected layer', () => {
|
suite('timeline — backspace deletes selected layer', () => {
|
||||||
// Regression: Backspace / Delete used to only remove selected
|
// Regression: Backspace / Delete used to only remove selected
|
||||||
// keyframes and markers. The selected layer track had no shortcut
|
// keyframes and markers. The selected layer track had no shortcut
|
||||||
@@ -9011,6 +9084,524 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('ui — CanvasStage layer box-select', () => {
|
||||||
|
// Drag on empty stage to marquee-select layers. Touch (not
|
||||||
|
// enclose) semantics: a layer whose bbox overlaps the drag rect
|
||||||
|
// at all is added to the selection. Reuses the same boxSelect
|
||||||
|
// state as the polygon-vertex lasso; handleStageMouseDown
|
||||||
|
// picks the mode per selection context.
|
||||||
|
|
||||||
|
async function mountCanvasWithKonva() {
|
||||||
|
const VueKonva = (await import('vue-konva')).default
|
||||||
|
const host = document.createElement('div')
|
||||||
|
host.setAttribute('data-test-host', '')
|
||||||
|
host.style.width = '800px'
|
||||||
|
host.style.height = '600px'
|
||||||
|
document.body.appendChild(host)
|
||||||
|
const app = createApp({ render() { return h(CanvasStage, {}) } })
|
||||||
|
app.use(VueKonva)
|
||||||
|
app.directive('tooltip', vTooltip)
|
||||||
|
app.mount(host)
|
||||||
|
return {
|
||||||
|
host, app,
|
||||||
|
unmount() { app.unmount(); host.remove() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function getKonvaStage() {
|
||||||
|
const Konva = (await import('konva')).default
|
||||||
|
return Konva.stages[Konva.stages.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch a synthesised Konva pointerdown → mousemove →
|
||||||
|
* mouseup on the stage at world coords (x0,y0) → (x1,y1).
|
||||||
|
* Fires on the stage itself so the mousedown handler's
|
||||||
|
* `target === stage` branch kicks in. */
|
||||||
|
function simulateBoxDrag(
|
||||||
|
stage: { fire: (type: string, evt: unknown, bubble: boolean) => void; getPointerPosition: () => unknown; width: () => number; height: () => number },
|
||||||
|
from: { x: number; y: number },
|
||||||
|
to: { x: number; y: number },
|
||||||
|
) {
|
||||||
|
// pointerToWorld uses stage.getPointerPosition(). We overwrite
|
||||||
|
// it for the duration of the drag so the handler sees the
|
||||||
|
// coords we want regardless of DOM layout.
|
||||||
|
let current = { ...from }
|
||||||
|
const stageObj = stage as unknown as {
|
||||||
|
getPointerPosition: () => { x: number; y: number }
|
||||||
|
}
|
||||||
|
const orig = stageObj.getPointerPosition
|
||||||
|
stageObj.getPointerPosition = () => current
|
||||||
|
const fire = stage.fire.bind(stage)
|
||||||
|
try {
|
||||||
|
fire('mousedown', {
|
||||||
|
target: stage, type: 'mousedown',
|
||||||
|
evt: { button: 0, ctrlKey: false, shiftKey: false },
|
||||||
|
}, true)
|
||||||
|
current = { ...to }
|
||||||
|
fire('mousemove', {
|
||||||
|
target: stage, type: 'mousemove',
|
||||||
|
evt: { button: 0, ctrlKey: false, shiftKey: false },
|
||||||
|
}, true)
|
||||||
|
fire('mouseup', {
|
||||||
|
target: stage, type: 'mouseup',
|
||||||
|
evt: { button: 0, ctrlKey: false, shiftKey: false },
|
||||||
|
}, true)
|
||||||
|
} finally {
|
||||||
|
stageObj.getPointerPosition = orig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World → stage-pointer conversion. After mount the auto-fit
|
||||||
|
* resizes zoom/pan; handing the simulator world coords directly
|
||||||
|
* would miss because pointerToWorld divides by zoom. Read the
|
||||||
|
* current view transform from the store and invert. */
|
||||||
|
function worldToStage(s: ReturnType<typeof useSceneStore>, x: number, y: number) {
|
||||||
|
const z = s.editor.canvasZoom || 1
|
||||||
|
return { x: x * z + s.editor.canvasViewX, y: y * z + s.editor.canvasViewY }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dragging a box across two layers selects both', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const b = s.addAsset({ kind: 'rect', name: 'B', width: 40, height: 40, fill: '#0f0' })
|
||||||
|
const lA = s.addLayer(a.id)!
|
||||||
|
const lB = s.addLayer(b.id)!
|
||||||
|
// Place A around world (100, 100), B around (300, 100). A box
|
||||||
|
// from (50, 50) to (350, 150) touches both.
|
||||||
|
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60) // let auto-fit settle
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||||
|
getPointerPosition: () => unknown
|
||||||
|
width: () => number
|
||||||
|
height: () => number
|
||||||
|
}
|
||||||
|
simulateBoxDrag(
|
||||||
|
stage,
|
||||||
|
worldToStage(s, 50, 50),
|
||||||
|
worldToStage(s, 350, 150),
|
||||||
|
)
|
||||||
|
await nextTick()
|
||||||
|
expect(s.selectedLayerIds.length).toBe(2)
|
||||||
|
expect(s.selectedLayerIds).toContain(lA.id)
|
||||||
|
expect(s.selectedLayerIds).toContain(lB.id)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a tiny drag (click) on empty stage deselects instead of multi-selecting', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const l = s.addLayer(a.id)!
|
||||||
|
s.upsertKeyframe(l.id, 0, { x: 200, y: 200 })
|
||||||
|
s.selectLayer(l.id)
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||||
|
getPointerPosition: () => unknown
|
||||||
|
width: () => number
|
||||||
|
height: () => number
|
||||||
|
}
|
||||||
|
const p = worldToStage(s, 10, 10)
|
||||||
|
simulateBoxDrag(stage, p, p)
|
||||||
|
await nextTick()
|
||||||
|
expect(s.selectedLayerId).toBe(null)
|
||||||
|
expect(s.selectedLayerIds).toEqual([])
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plain click on a layer already in the multi-selection keeps the selection intact', async () => {
|
||||||
|
// Regression: clicking one of the shapes in a multi-select
|
||||||
|
// used to collapse the selection to that single layer before
|
||||||
|
// Konva's drag machinery could kick in — the would-be group
|
||||||
|
// drag ended up dragging only the clicked shape. Fix:
|
||||||
|
// handleStageMouseDown short-circuits when the clicked layer
|
||||||
|
// is already part of the existing multi-selection, leaving
|
||||||
|
// selectedLayerIds alone so the drag picks up every member.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
findOne: (sel: string) => unknown
|
||||||
|
}
|
||||||
|
const node = stage.findOne(`#${lA.id}`) as {
|
||||||
|
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||||
|
}
|
||||||
|
node.fire(
|
||||||
|
'mousedown',
|
||||||
|
{
|
||||||
|
target: node,
|
||||||
|
type: 'mousedown',
|
||||||
|
evt: { button: 0, ctrlKey: false, shiftKey: false },
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
await nextTick()
|
||||||
|
// Both still selected; single-layer replacement was the bug.
|
||||||
|
expect(s.selectedLayerIds.length).toBe(2)
|
||||||
|
expect(s.selectedLayerIds).toContain(lA.id)
|
||||||
|
expect(s.selectedLayerIds).toContain(lB.id)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shift+click on a second layer adds it to the existing selection', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||||
|
s.selectLayer(lA.id)
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
findOne: (sel: string) => unknown
|
||||||
|
}
|
||||||
|
const bNode = stage.findOne(`#${lB.id}`) as {
|
||||||
|
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||||
|
}
|
||||||
|
// Dispatch mousedown directly on B's node with shiftKey set.
|
||||||
|
// The stage mousedown handler receives the event target =
|
||||||
|
// bNode, the shift branch calls toggleLayerSelection, and
|
||||||
|
// both A and B end up selected.
|
||||||
|
bNode.fire(
|
||||||
|
'mousedown',
|
||||||
|
{
|
||||||
|
target: bNode,
|
||||||
|
type: 'mousedown',
|
||||||
|
evt: { button: 0, ctrlKey: false, shiftKey: true },
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
await nextTick()
|
||||||
|
expect(s.selectedLayerIds.length).toBe(2)
|
||||||
|
expect(s.selectedLayerIds).toContain(lA.id)
|
||||||
|
expect(s.selectedLayerIds).toContain(lB.id)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shift+click on empty stage keeps the current selection', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||||
|
getPointerPosition: () => unknown
|
||||||
|
width: () => number
|
||||||
|
height: () => number
|
||||||
|
}
|
||||||
|
// Shift + tiny drag on empty corner — must leave the
|
||||||
|
// existing multi-selection alone, not wipe it.
|
||||||
|
const p = worldToStage(s, 500, 500)
|
||||||
|
const stageObj = stage as unknown as {
|
||||||
|
getPointerPosition: () => { x: number; y: number }
|
||||||
|
}
|
||||||
|
const orig = stageObj.getPointerPosition
|
||||||
|
stageObj.getPointerPosition = () => p
|
||||||
|
try {
|
||||||
|
stage.fire('mousedown', {
|
||||||
|
target: stage, type: 'mousedown',
|
||||||
|
evt: { button: 0, ctrlKey: false, shiftKey: true },
|
||||||
|
}, true)
|
||||||
|
stage.fire('mouseup', {
|
||||||
|
target: stage, type: 'mouseup',
|
||||||
|
evt: { button: 0, ctrlKey: false, shiftKey: true },
|
||||||
|
}, true)
|
||||||
|
} finally {
|
||||||
|
stageObj.getPointerPosition = orig
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
expect(s.selectedLayerIds.length).toBe(2)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('transformend on one node in a multi-selection persists every node', async () => {
|
||||||
|
// Multi-select transform writes to every attached layer's
|
||||||
|
// keyframe, not just the anchor that fired the event. Covers
|
||||||
|
// scale — rotation is intentionally OFF for multi-select (see
|
||||||
|
// the rotateEnabled test below) but scale + translation still
|
||||||
|
// work as a group.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
findOne: (sel: string) => unknown
|
||||||
|
}
|
||||||
|
const nodeA = stage.findOne(`#${lA.id}`) as unknown as {
|
||||||
|
x: (v?: number) => number
|
||||||
|
y: (v?: number) => number
|
||||||
|
scaleX: (v?: number) => number
|
||||||
|
scaleY: (v?: number) => number
|
||||||
|
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||||
|
}
|
||||||
|
const nodeB = stage.findOne(`#${lB.id}`) as unknown as {
|
||||||
|
x: (v?: number) => number
|
||||||
|
y: (v?: number) => number
|
||||||
|
scaleX: (v?: number) => number
|
||||||
|
scaleY: (v?: number) => number
|
||||||
|
}
|
||||||
|
// 2x scale on both nodes — simulate what Konva's transformer
|
||||||
|
// would do when the user drags a corner anchor. Then fire
|
||||||
|
// transformend on one node; handleTransformEnd fans the new
|
||||||
|
// pose out across the entire selection.
|
||||||
|
nodeA.x(120)
|
||||||
|
nodeA.y(120)
|
||||||
|
nodeA.scaleX(2)
|
||||||
|
nodeA.scaleY(2)
|
||||||
|
nodeB.x(340)
|
||||||
|
nodeB.y(120)
|
||||||
|
nodeB.scaleX(2)
|
||||||
|
nodeB.scaleY(2)
|
||||||
|
nodeA.fire(
|
||||||
|
'transformend',
|
||||||
|
{ target: nodeA, type: 'transformend' },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
await nextTick()
|
||||||
|
const sampA = sampleLayer(lA, s.playhead)
|
||||||
|
const sampB = sampleLayer(lB, s.playhead)
|
||||||
|
expect(sampA.scaleX).toBeCloseTo(2, 4)
|
||||||
|
expect(sampB.scaleX).toBeCloseTo(2, 4)
|
||||||
|
expect(sampA.x).toBeCloseTo(120, 4)
|
||||||
|
expect(sampB.x).toBeCloseTo(340, 4)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multi-select disables rotateEnabled on the transformer config', async () => {
|
||||||
|
// Rotation on a group pins every node to the transformer
|
||||||
|
// bbox centre, which isn't what users want per-layer — we
|
||||||
|
// turn the rotate handle off whenever more than one node is
|
||||||
|
// wrapped so the gesture is simply unavailable.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
find: (sel: string) => unknown[]
|
||||||
|
}
|
||||||
|
const transformers = stage.find('Transformer') as Array<{
|
||||||
|
nodes: () => unknown[]
|
||||||
|
rotateEnabled: () => boolean
|
||||||
|
}>
|
||||||
|
const main = transformers.find((tr) => tr.nodes().length > 1)
|
||||||
|
expect(main).toBeTruthy()
|
||||||
|
expect(main!.rotateEnabled()).toBe(false)
|
||||||
|
// Drop back to a single-layer selection → rotate is back.
|
||||||
|
s.selectLayer(lA.id)
|
||||||
|
await nextTick()
|
||||||
|
await tick(20)
|
||||||
|
const refreshed = (stage.find('Transformer') as Array<{
|
||||||
|
nodes: () => unknown[]
|
||||||
|
rotateEnabled: () => boolean
|
||||||
|
}>).find((tr) => tr.nodes().length === 1)
|
||||||
|
expect(refreshed).toBeTruthy()
|
||||||
|
expect(refreshed!.rotateEnabled()).toBe(true)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rect + polygon multi-selection still gets a shared bounding box', async () => {
|
||||||
|
// Regression: the transformer used to skip every polygon to
|
||||||
|
// keep the vertex-editing flow intact. That meant mixed
|
||||||
|
// selections showed a box around only the rect. Fix: skip
|
||||||
|
// polygons only when they're the SOLE selection.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const poly = s.addAsset({
|
||||||
|
kind: 'polygon', name: 'P',
|
||||||
|
points: [{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 40, y: 40 }],
|
||||||
|
closed: true,
|
||||||
|
})
|
||||||
|
const lR = s.addLayer(rect.id)!
|
||||||
|
const lP = s.addLayer(poly.id)!
|
||||||
|
s.upsertKeyframe(lR.id, 0, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(lP.id, 0, { x: 300, y: 100 })
|
||||||
|
s.selectedLayerIds = [lR.id, lP.id]
|
||||||
|
s.selectedLayerId = lP.id
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
find: (sel: string) => unknown[]
|
||||||
|
}
|
||||||
|
const transformers = stage.find('Transformer') as Array<{
|
||||||
|
nodes: () => Array<{ id: () => string }>
|
||||||
|
}>
|
||||||
|
const main = transformers.find((tr) => tr.nodes().length > 1)
|
||||||
|
expect(main).toBeTruthy()
|
||||||
|
const ids = main!.nodes().map((n) => n.id()).sort()
|
||||||
|
expect(ids).toEqual([lR.id, lP.id].sort())
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selecting multiple layers attaches the transformer to every node', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.upsertKeyframe(lA.id, 0, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(lB.id, 0, { x: 300, y: 100 })
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
find: (sel: string) => unknown[]
|
||||||
|
}
|
||||||
|
const transformers = stage.find('Transformer') as Array<{
|
||||||
|
nodes: () => Array<{ id: () => string }>
|
||||||
|
}>
|
||||||
|
// The hit-shape transformer (button edit) is always in the
|
||||||
|
// tree — pick whichever Transformer has >1 node attached.
|
||||||
|
const main = transformers.find((tr) => tr.nodes().length > 1)
|
||||||
|
expect(main).toBeTruthy()
|
||||||
|
const attachedIds = main!.nodes().map((n) => n.id()).sort()
|
||||||
|
expect(attachedIds).toEqual([lA.id, lB.id].sort())
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('click on empty stage deselects a currently-selected polygon', async () => {
|
||||||
|
// Regression: with a polygon (or freshly drawn freeform)
|
||||||
|
// selected, the empty-stage click kicked the vertex-lasso
|
||||||
|
// code path. That only cleared vertex indices — the polygon
|
||||||
|
// layer itself stayed selected and the properties panel
|
||||||
|
// kept pointing at it. Fix: a tiny click with no vertex hits
|
||||||
|
// falls through to selectLayer(null) like layer-mode does.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const poly = s.addAsset({
|
||||||
|
kind: 'polygon', name: 'P',
|
||||||
|
points: [{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 40, y: 40 }],
|
||||||
|
closed: true,
|
||||||
|
})
|
||||||
|
const layer = s.addLayer(poly.id)!
|
||||||
|
s.upsertKeyframe(layer.id, 0, { x: 100, y: 100 })
|
||||||
|
s.selectLayer(layer.id)
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||||
|
getPointerPosition: () => unknown
|
||||||
|
width: () => number
|
||||||
|
height: () => number
|
||||||
|
}
|
||||||
|
// Click on empty corner (far from polygon + vertices).
|
||||||
|
const p = worldToStage(s, 400, 400)
|
||||||
|
simulateBoxDrag(stage, p, p)
|
||||||
|
await nextTick()
|
||||||
|
expect(s.selectedLayerId).toBe(null)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('box-select skips locked layers', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
|
||||||
|
const unlocked = s.addLayer(rect.id)!
|
||||||
|
const locked = s.addLayer(rect.id)!
|
||||||
|
s.upsertKeyframe(unlocked.id, 0, { x: 100, y: 100 })
|
||||||
|
s.upsertKeyframe(locked.id, 0, { x: 300, y: 100 })
|
||||||
|
s.setTrackLocked(locked.id, true)
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(60)
|
||||||
|
const stage = await getKonvaStage() as unknown as {
|
||||||
|
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||||
|
getPointerPosition: () => unknown
|
||||||
|
width: () => number
|
||||||
|
height: () => number
|
||||||
|
}
|
||||||
|
simulateBoxDrag(
|
||||||
|
stage,
|
||||||
|
worldToStage(s, 50, 50),
|
||||||
|
worldToStage(s, 350, 150),
|
||||||
|
)
|
||||||
|
await nextTick()
|
||||||
|
// Only the unlocked one made it into the selection.
|
||||||
|
expect(s.selectedLayerIds.length).toBe(1)
|
||||||
|
expect(s.selectedLayerIds[0]).toBe(unlocked.id)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('ui — AssetSidebar audio section', () => {
|
suite('ui — AssetSidebar audio section', () => {
|
||||||
test('lists audio assets with a duration label under the Audio heading', async () => {
|
test('lists audio assets with a duration label under the Audio heading', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
@@ -9278,6 +9869,98 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('properties — multi-select fan-out + section gating', () => {
|
||||||
|
function resetSectionPrefs() {
|
||||||
|
try { localStorage.removeItem('animatr:propSections') } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('changing X with two layers selected writes to both', async () => {
|
||||||
|
resetSectionPrefs()
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.upsertKeyframe(lA.id, 0, { x: 100 })
|
||||||
|
s.upsertKeyframe(lB.id, 0, { x: 300 })
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id // anchor
|
||||||
|
const handle = mountComponent(PropertiesPanel, {})
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
const xInput = handle.host.querySelector(
|
||||||
|
'.prop-group input[type="number"]',
|
||||||
|
) as HTMLInputElement
|
||||||
|
expect(xInput).toBeTruthy()
|
||||||
|
// Anchor's x is 300 — that's what the input shows.
|
||||||
|
expect(Number(xInput.value)).toBe(300)
|
||||||
|
xInput.value = '500'
|
||||||
|
xInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
xInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
// Both layers' sampled x === 500 (write fanned out).
|
||||||
|
expect(sampleLayer(lA, s.playhead).x).toBe(500)
|
||||||
|
expect(sampleLayer(lB, s.playhead).x).toBe(500)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multi-select hides kind-specific sections (fill, stroke, polygon, text)', async () => {
|
||||||
|
resetSectionPrefs()
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const text = s.addAsset({
|
||||||
|
kind: 'text', name: 'T', text: 'hi', fontSize: 24, fill: '#000',
|
||||||
|
})
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(text.id)!
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id
|
||||||
|
const handle = mountComponent(PropertiesPanel, {})
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
// Kind-specific labels should NOT show on the multi panel.
|
||||||
|
const labels = Array.from(handle.host.querySelectorAll('label')).map(
|
||||||
|
(el) => el.textContent?.toLowerCase() ?? '',
|
||||||
|
)
|
||||||
|
expect(labels.some((l) => l.includes('text'))).toBe(false)
|
||||||
|
// Fill / stroke labels: hidden too.
|
||||||
|
expect(labels.some((l) => l.startsWith('fill'))).toBe(false)
|
||||||
|
expect(labels.some((l) => l.startsWith('stroke'))).toBe(false)
|
||||||
|
// Multi banner is up.
|
||||||
|
expect(handle.host.querySelector('.multi-banner')).toBeTruthy()
|
||||||
|
// Transform section still renders (X, Y, Scale X, Scale Y, Rotation).
|
||||||
|
const inputs = handle.host.querySelectorAll('.prop-group input[type="number"]')
|
||||||
|
expect(inputs.length).toBeGreaterThan(0)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multi-select trash button labels with the count and removes every layer', async () => {
|
||||||
|
resetSectionPrefs()
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const lA = s.addLayer(rect.id)!
|
||||||
|
const lB = s.addLayer(rect.id)!
|
||||||
|
s.selectedLayerIds = [lA.id, lB.id]
|
||||||
|
s.selectedLayerId = lB.id
|
||||||
|
const handle = mountComponent(PropertiesPanel, {})
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
const trash = handle.host.querySelector('.danger-btn') as HTMLButtonElement
|
||||||
|
expect(trash).toBeTruthy()
|
||||||
|
expect(trash.textContent).toContain('2')
|
||||||
|
trash.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(s.scene.layers.find((x) => x.id === lA.id)).toBe(undefined)
|
||||||
|
expect(s.scene.layers.find((x) => x.id === lB.id)).toBe(undefined)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('properties — fill/stroke transparent toggle', () => {
|
suite('properties — fill/stroke transparent toggle', () => {
|
||||||
function findAlphaToggles(host: HTMLElement) {
|
function findAlphaToggles(host: HTMLElement) {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ export const en = {
|
|||||||
interpolated: 'Interpolated',
|
interpolated: 'Interpolated',
|
||||||
removeKeyframe: 'Remove keyframe here',
|
removeKeyframe: 'Remove keyframe here',
|
||||||
deleteLayer: 'Delete layer',
|
deleteLayer: 'Delete layer',
|
||||||
|
deleteLayers: 'Delete {n} layers',
|
||||||
|
multiSelected: '{n} layers selected — edits apply to all',
|
||||||
noLayer: 'No layer selected.',
|
noLayer: 'No layer selected.',
|
||||||
lockedLayer:
|
lockedLayer:
|
||||||
'Selected layer is locked. Unlock the track (or its group) to edit properties.',
|
'Selected layer is locked. Unlock the track (or its group) to edit properties.',
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '1.1.2'
|
export const APP_VERSION = '1.1.3'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user