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
@@ -15,7 +15,7 @@ import {
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
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 ActionEditor from './ActionEditor.vue'
|
||||
import { t } from '@/i18n'
|
||||
@@ -74,6 +74,41 @@ const sampled = computed<AnimatableProps | 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
|
||||
* a locked parent group. Suppresses the edit UI — the user must
|
||||
* 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'
|
||||
|
||||
/** 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) {
|
||||
const factor = 10 ** decimals
|
||||
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),
|
||||
set: (v: number) => {
|
||||
if (!selected.value) return
|
||||
store.upsertKeyframe(selected.value.id, store.playhead, { [key]: v })
|
||||
},
|
||||
set: (v: number) => writeKeyToSelection({ [key]: v }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -380,16 +426,28 @@ const rotation = bind('rotation')
|
||||
const opacity = bind('opacity', 2)
|
||||
|
||||
function removeKeyframeHere() {
|
||||
if (!selected.value) return
|
||||
store.removeKeyframeAt(selected.value.id, store.playhead)
|
||||
for (const l of editableLayers.value) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<h3>Properties</h3>
|
||||
<template v-if="selected && sampled && !selectedIsLocked">
|
||||
<label>Name<input v-model="selected.name" /></label>
|
||||
<template v-if="selected && sampled && editableLayers.length > 0">
|
||||
<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 }">
|
||||
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
||||
@@ -420,7 +478,7 @@ function removeKeyframeHere() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="textAsset" class="prop-group">
|
||||
<section v-if="textAsset && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('text')">
|
||||
<component :is="sectionOpen.text ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.text') }}</span>
|
||||
@@ -431,7 +489,7 @@ function removeKeyframeHere() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="polygonAsset" class="prop-group">
|
||||
<section v-if="polygonAsset && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('polygon')">
|
||||
<component :is="sectionOpen.polygon ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.polygon') }}</span>
|
||||
@@ -463,7 +521,7 @@ function removeKeyframeHere() {
|
||||
<span>{{ t('properties.sections.appearance') }}</span>
|
||||
</button>
|
||||
<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') }}
|
||||
<div class="color-row">
|
||||
<input
|
||||
@@ -485,7 +543,7 @@ function removeKeyframeHere() {
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label v-if="canStroke" class="color-label">
|
||||
<label v-if="canStroke && !multiSelected" class="color-label">
|
||||
{{ t('properties.stroke') }}
|
||||
<div class="color-row">
|
||||
<input
|
||||
@@ -507,7 +565,7 @@ function removeKeyframeHere() {
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label v-if="canStroke" class="slider-label">
|
||||
<label v-if="canStroke && !multiSelected" class="slider-label">
|
||||
{{ t('properties.strokeWidth') }}
|
||||
<RangeSlider
|
||||
v-model="strokeWidth"
|
||||
@@ -521,7 +579,7 @@ function removeKeyframeHere() {
|
||||
{{ t('properties.opacity') }}
|
||||
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
||||
</label>
|
||||
<label v-if="blurAsset" class="slider-label">
|
||||
<label v-if="blurAsset && !multiSelected" class="slider-label">
|
||||
{{ t('properties.edgeBlur') }}
|
||||
<RangeSlider
|
||||
v-model="blurValue"
|
||||
@@ -534,7 +592,7 @@ function removeKeyframeHere() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prop-group">
|
||||
<section v-if="!multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('shadow')">
|
||||
<component :is="sectionOpen.shadow ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.shadow') }}</span>
|
||||
@@ -589,7 +647,7 @@ function removeKeyframeHere() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="canBeMask" class="prop-group">
|
||||
<section v-if="canBeMask && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('mask')">
|
||||
<component :is="sectionOpen.mask ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.mask') }}</span>
|
||||
@@ -640,7 +698,7 @@ function removeKeyframeHere() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="buttonAsset" class="prop-group">
|
||||
<section v-if="buttonAsset && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('button')">
|
||||
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.button') }}</span>
|
||||
@@ -653,12 +711,16 @@ function removeKeyframeHere() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
||||
<button class="danger-btn" @click="deleteSelection">
|
||||
<PhTrash :size="13" />
|
||||
{{ t('properties.deleteLayer') }}
|
||||
{{
|
||||
multiSelected
|
||||
? t('properties.deleteLayers', { n: editableLayers.length })
|
||||
: t('properties.deleteLayer')
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
<p v-else-if="selected && selectedIsLocked" class="hint">
|
||||
<p v-else-if="selected && editableLayers.length === 0" class="hint">
|
||||
{{ t('properties.lockedLayer') }}
|
||||
</p>
|
||||
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
|
||||
@@ -971,4 +1033,13 @@ input {
|
||||
background: rgba(255, 122, 122, 0.18);
|
||||
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>
|
||||
Reference in new issue
Block a user