0.5.6: show/hide locked tracks + group lock + crop/mask-preview split

- Timeline: new show/hide toggle next to snap; locked tracks (including
  layer, clip, and the new group lock) are filtered from the timeline
  when the toggle is off. Preference lives in localStorage and is
  shared across components via a module-level ref.
- Groups become lockable via the same inline lock toggle. A locked
  group propagates to its children: they behave as effectively locked
  in the timeline and on the stage. Collapse/expand still works on a
  locked group.
- Canvas: layers in locked+hidden tracks render as a 1px outline in
  the layer (or fill) color instead of their full fill, and are
  draggable:false / listening:false. Mask layers keep their dashed
  ghost styling; masks still apply in the preview overlay regardless
  of lock or show state.
- drawSceneFrame gains an optional `{ crop }` option; the mask-preview
  overlay passes the editor's crop toggle through, so the two controls
  are fully independent. Crop changes now also trigger a preview
  redraw via a watcher dep.
- Tests: new suites for show-locked persistence, group lock store
  action, crop option on drawSceneFrame (including mask+crop combos).
  Properties transparent-toggle tests now reset the section prefs
  localStorage so the Appearance section is guaranteed open when the
  panel mounts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 16:42:39 +02:00
1 parent 63042b284d
commit 69de48ea8d
10 files changed
+522 -35

No files matched your search

+115 -6
View File
@@ -10,6 +10,7 @@ import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type { AnimatableProps, Asset, Layer, Point } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
import { expandedTrackOrder } from '@/lib/sceneOrder'
const store = useSceneStore()
@@ -257,6 +258,24 @@ function shadowCfg(props: AnimatableProps): Record<string, unknown> {
}
}
function isLayerEffectivelyLocked(layer: Layer): boolean {
if (layer.locked) return true
for (const g of store.scene.groups ?? []) {
if (g.locked && g.childIds.includes(layer.id)) return true
}
return false
}
function isLayerGhostLocked(layer: Layer): boolean {
return isLayerEffectivelyLocked(layer) && !showLocked.value
}
function ghostLockColor(layer: Layer, fallback?: string | null): string {
if (layer.color) return layer.color
if (fallback && fallback !== 'transparent') return fallback
return '#6ea8ff'
}
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
const maskGhost = layer.isMask
? {
@@ -267,6 +286,10 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
strokeScaleEnabled: false,
}
: {}
// Any locked layer (direct or via a locked group) is inert on the stage.
// When the timeline also hides locked tracks the per-shape config switches
// to a thin outline — handled by isLayerGhostLocked() checks per shape.
const effectivelyLocked = isLayerEffectivelyLocked(layer)
return {
id: layer.id,
x: props.x,
@@ -276,7 +299,8 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
rotation: props.rotation,
opacity: props.opacity,
visible: layer.visible,
draggable: true,
draggable: !effectivelyLocked,
listening: !effectivelyLocked,
...shadowCfg(props),
...maskGhost,
}
@@ -343,8 +367,22 @@ function strokeCfg(
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const base = baseNodeConfig(layer, props)
if (isLayerGhostLocked(layer)) {
return {
...base,
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
fill: undefined,
stroke: ghostLockColor(layer, fill),
strokeWidth: 1,
strokeScaleEnabled: false,
}
}
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
...base,
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
@@ -363,8 +401,21 @@ function circleConfig(
) {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const base = baseNodeConfig(layer, props)
if (isLayerGhostLocked(layer)) {
return {
...base,
radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: undefined,
stroke: ghostLockColor(layer, fill),
strokeWidth: 1,
strokeScaleEnabled: false,
}
}
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
...base,
radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
@@ -377,8 +428,22 @@ function circleConfig(
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
const fill = props.fill ?? asset.fill
const base = baseNodeConfig(layer, props)
if (isLayerGhostLocked(layer)) {
return {
...base,
text: asset.text,
fontSize: asset.fontSize,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: undefined,
stroke: ghostLockColor(layer, fill),
strokeWidth: 1,
strokeScaleEnabled: false,
}
}
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
...base,
text: asset.text,
fontSize: asset.fontSize,
offsetX: layer.offsetX ?? 0,
@@ -451,8 +516,29 @@ function polygonConfig(
flat.push(p.x - offX, p.y - offY)
}
const hasCurves = asset.points.some((p) => p.type && p.type !== 'corner')
const base = baseNodeConfig(layer, props)
if (isLayerGhostLocked(layer)) {
const ghostCfg: Record<string, unknown> = {
...base,
points: flat,
closed,
lineJoin: 'round',
lineCap: 'round',
fill: undefined,
stroke: ghostLockColor(layer, fill),
strokeWidth: 1,
strokeScaleEnabled: false,
}
if (hasCurves) {
ghostCfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
traceKonvaPolyPath(ctx, pts, asset.points, offX, offY, closed)
ctx.fillStrokeShape(shape)
}
}
return ghostCfg
}
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
...base,
points: flat,
closed,
lineJoin: 'round',
@@ -487,6 +573,26 @@ function imageConfig(
) {
const img = imageCache.value[asset.id]
if (!img) return null
if (isLayerGhostLocked(layer)) {
// Show a 1px outline of the image's bounding box instead of the pixels.
return {
...baseNodeConfig(layer, props),
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
fill: undefined,
stroke: ghostLockColor(layer, null),
strokeWidth: 1,
strokeScaleEnabled: false,
sceneFunc: (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.beginPath()
ctx.rect(0, 0, asset.width, asset.height)
ctx.closePath()
ctx.fillStrokeShape(shape)
},
}
}
return {
...baseNodeConfig(layer, props),
image: img,
@@ -1891,7 +1997,9 @@ function drawPreview() {
ctx.save()
ctx.translate(viewX.value, viewY.value)
ctx.scale(zoom.value, zoom.value)
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value)
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value, {
crop: cropObjects.value,
})
ctx.restore()
}
@@ -1903,6 +2011,7 @@ watch(
zoom,
viewX,
viewY,
cropObjects,
],
async () => {
if (!maskPreview.value) return
+119 -24
View File
@@ -11,6 +11,8 @@ import {
PhFolderPlus,
PhLock,
PhLockOpen,
PhEye,
PhEyeSlash,
PhMagnet,
PhMaskHappy,
PhPauseCircle,
@@ -22,6 +24,7 @@ import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
import type {
AnimatableProps,
Clip,
@@ -62,6 +65,7 @@ interface TimelineGroupTrack {
name: string
collapsed: boolean
color: string | null
locked: boolean
orderIndex: number
childCount: number
}
@@ -187,6 +191,7 @@ type DisplayMode = 'seconds' | 'frames'
const displayMode = ref<DisplayMode>('seconds')
const snapEnabled = ref(true)
const SNAP_PX = 8
function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
@@ -387,6 +392,27 @@ function emitLayerTrack(
}
}
const lockedGroupChildIds = computed(() => {
const s = new Set<string>()
for (const g of store.scene.groups ?? []) {
if (g.locked) for (const cid of g.childIds) s.add(cid)
}
return s
})
function isTrackEffectivelyLocked(track: TimelineTrack): boolean {
if (track.type === 'layer') {
return !!track.layer.locked || lockedGroupChildIds.value.has(track.layerId)
}
if (track.type === 'clip') {
return (
!!clipTrackById.value.get(track.clipTrackId)?.locked ||
lockedGroupChildIds.value.has(track.clipTrackId)
)
}
return track.locked
}
const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = []
if (store.activeClipId) {
@@ -417,6 +443,7 @@ const tracks = computed<TimelineTrack[]>(() => {
name: grp.name,
collapsed: !!grp.collapsed,
color: grp.color ?? null,
locked: !!grp.locked,
orderIndex,
childCount: grp.childIds.length,
})
@@ -443,7 +470,29 @@ const tracks = computed<TimelineTrack[]>(() => {
}
}
})
return out
if (showLocked.value) return out
const lockedGroupChildIds = new Set<string>()
const lockedGroupIds = new Set<string>()
for (const g of store.scene.groups ?? []) {
if (g.locked) {
lockedGroupIds.add(g.id)
for (const cid of g.childIds) lockedGroupChildIds.add(cid)
}
}
return out.filter((tr) => {
if (tr.type === 'layer') {
if (tr.layer.locked) return false
if (lockedGroupChildIds.has(tr.layerId)) return false
return true
}
if (tr.type === 'clip') {
if (clipTrackById.value.get(tr.clipTrackId)?.locked) return false
if (lockedGroupChildIds.has(tr.clipTrackId)) return false
return true
}
// group: hide when locked
return !lockedGroupIds.has(tr.groupId)
})
})
function trackDndId(track: TimelineTrack): string {
@@ -1523,6 +1572,17 @@ onBeforeUnmount(() => {
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
</button>
<button
class="snap-btn"
:class="{ active: !showLocked }"
:title="showLocked ? t('timeline.hideLocked') : t('timeline.showLocked')"
@click="showLocked = !showLocked"
>
<component
:is="showLocked ? PhEye : PhEyeSlash"
:size="12"
/>
</button>
<button
v-if="!inClipEdit"
class="snap-btn"
@@ -1582,19 +1642,19 @@ onBeforeUnmount(() => {
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragTrackId === track.layerId,
colored: !!track.layer.color,
locked: !!track.layer.locked,
locked: isTrackEffectivelyLocked(track),
'in-group': track.inGroup,
}"
:style="trackColorStyle(track.layer.color)"
@dragover="
track.isParent
track.isParent && !isTrackEffectivelyLocked(track)
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex))
: null
"
@drop="
track.isParent
track.isParent && !isTrackEffectivelyLocked(track)
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
: null
"
@@ -1607,15 +1667,15 @@ onBeforeUnmount(() => {
parent: track.isParent,
sub: !track.isParent,
}"
:draggable="track.isParent && !track.layer.locked"
@click="!track.layer.locked ? store.selectLayer(track.layerId) : null"
:draggable="track.isParent && !isTrackEffectivelyLocked(track)"
@click="!isTrackEffectivelyLocked(track) ? store.selectLayer(track.layerId) : null"
@contextmenu="
track.isParent && !track.layer.locked
track.isParent && !isTrackEffectivelyLocked(track)
? onTrackContextMenu($event, track.layerId)
: null
"
@dragstart="
track.isParent && !track.layer.locked
track.isParent && !isTrackEffectivelyLocked(track)
? onTrackDragStart($event, track.layerId)
: null
"
@@ -1636,7 +1696,7 @@ onBeforeUnmount(() => {
<span v-if="!track.isParent" class="sub-indent">↳</span>
<span class="track-label">{{ track.label }}</span>
<button
v-if="track.isParent"
v-if="track.isParent && !lockedGroupChildIds.has(track.layerId)"
class="lock-btn"
:class="{ locked: track.layer.locked }"
:title="track.layer.locked ? t('timeline.unlock') : t('timeline.lock')"
@@ -1707,28 +1767,34 @@ onBeforeUnmount(() => {
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.clipTrackId,
colored: !!clipTrackById.get(track.clipTrackId)?.color,
locked: !!clipTrackById.get(track.clipTrackId)?.locked,
locked: isTrackEffectivelyLocked(track),
'in-group': track.inGroup,
}"
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
@dragover="
track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex)
!isTrackEffectivelyLocked(track)
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex))
: null
"
@drop="
!isTrackEffectivelyLocked(track)
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
: null
"
@drop="track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event)"
@dragleave="onTrackDragLeave"
>
<span
class="track-name clip-track-name"
:draggable="!clipTrackById.get(track.clipTrackId)?.locked"
:draggable="!isTrackEffectivelyLocked(track)"
@contextmenu="
!clipTrackById.get(track.clipTrackId)?.locked
!isTrackEffectivelyLocked(track)
? onClipTrackContextMenu($event, track.clipTrackId)
: null
"
@dragstart="
!clipTrackById.get(track.clipTrackId)?.locked
!isTrackEffectivelyLocked(track)
? onTrackDragStart($event, track.clipTrackId)
: null
"
@@ -1738,6 +1804,7 @@ onBeforeUnmount(() => {
<PhFilmStrip :size="11" class="name-badge" />
<span class="track-label">{{ track.name }}</span>
<button
v-if="!lockedGroupChildIds.has(track.clipTrackId)"
class="lock-btn"
:class="{ locked: !!clipTrackById.get(track.clipTrackId)?.locked }"
:title="clipTrackById.get(track.clipTrackId)?.locked ? t('timeline.unlock') : t('timeline.lock')"
@@ -1831,19 +1898,20 @@ onBeforeUnmount(() => {
colored: !!track.color,
'drop-into': dropGroupId === track.groupId,
collapsed: track.collapsed,
locked: track.locked,
}"
:style="trackColorStyle(track.color)"
@dragover="onTrackDragOver($event, track.orderIndex)"
@drop="onTrackDrop($event)"
@dragover="!track.locked ? onTrackDragOver($event, track.orderIndex) : null"
@drop="!track.locked ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
>
<span
class="track-name group-track-name"
draggable="true"
:draggable="!track.locked"
@click="toggleGroupCollapsed(track.groupId)"
@dblclick="renameGroupPrompt(track.groupId, track.name)"
@contextmenu="onGroupContextMenu($event, track.groupId)"
@dragstart="onTrackDragStart($event, track.groupId)"
@dblclick="!track.locked ? renameGroupPrompt(track.groupId, track.name) : null"
@contextmenu="!track.locked ? onGroupContextMenu($event, track.groupId) : null"
@dragstart="!track.locked ? onTrackDragStart($event, track.groupId) : null"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
@@ -1853,8 +1921,27 @@ onBeforeUnmount(() => {
class="group-caret"
/>
<PhFolder :size="12" class="name-badge" />
{{ track.name }}
<span class="track-label">{{ track.name }}</span>
<span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span>
<button
class="lock-btn"
:class="{ locked: track.locked }"
:title="track.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.groupId, !track.locked)"
@mousedown.stop
@dragstart.prevent.stop
>
<span class="lock-icon rest">
<component :is="track.locked ? PhLock : PhLockOpen" :size="12" />
</span>
<span class="lock-icon alt">
<component
:is="track.locked ? PhLockOpen : PhLock"
:size="12"
:weight="track.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</span>
<div
class="track-lane group-lane"
@@ -2310,6 +2397,7 @@ onBeforeUnmount(() => {
.track.locked {
height: 15px;
background: color-mix(in srgb, #ff6b6b 8%, transparent);
}
.track.clip-track.locked {
@@ -2322,6 +2410,7 @@ onBeforeUnmount(() => {
.track.locked .track-name {
font-size: 10px;
background: color-mix(in srgb, #ff6b6b 8%, var(--panel));
}
.track.locked .track-lane {
@@ -2361,6 +2450,12 @@ onBeforeUnmount(() => {
pointer-events: auto;
}
/* Locked groups stay collapsible — re-enable pointer events on the name */
.track.group-track.locked .track-name {
pointer-events: auto;
cursor: pointer;
}
.lock-btn {
margin-left: auto;
display: inline-flex;