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