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:
1 parent
63042b284d
commit
69de48ea8d
10 files changed
+522
-35
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user