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
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const KEY = 'animatr:showLockedTracks'
|
||||
|
||||
function read(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY)
|
||||
return v === null ? true : v === '1'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level shared ref so Timeline, CanvasStage and anything else can
|
||||
// observe the same "show locked tracks" preference.
|
||||
export const showLocked = ref(read())
|
||||
|
||||
watch(showLocked, (v) => {
|
||||
try {
|
||||
localStorage.setItem(KEY, v ? '1' : '0')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
@@ -2403,7 +2403,18 @@ export function registerAllTests(): void {
|
||||
)
|
||||
}
|
||||
|
||||
function resetSectionPrefs() {
|
||||
// Ensure the Appearance section is open (default) regardless of any
|
||||
// previous session's state persisted in localStorage.
|
||||
try {
|
||||
localStorage.removeItem('animatr:propSections')
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
test('fill: toggle transparent on writes "transparent" to keyframe', async () => {
|
||||
resetSectionPrefs()
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({
|
||||
kind: 'rect',
|
||||
@@ -2428,6 +2439,7 @@ export function registerAllTests(): void {
|
||||
})
|
||||
|
||||
test('fill: toggle off restores the previous non-transparent color', async () => {
|
||||
resetSectionPrefs()
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({
|
||||
kind: 'rect',
|
||||
@@ -2459,6 +2471,7 @@ export function registerAllTests(): void {
|
||||
})
|
||||
|
||||
test('stroke: toggle on then off restores to a visible color (auto-black fallback)', async () => {
|
||||
resetSectionPrefs()
|
||||
const s = makeFreshStore()
|
||||
// Rect without an explicit stroke — the restore should fall back to black
|
||||
const a = s.addAsset({
|
||||
@@ -2488,6 +2501,7 @@ export function registerAllTests(): void {
|
||||
})
|
||||
|
||||
test('fill: toggle off respects a user-picked color before transparency', async () => {
|
||||
resetSectionPrefs()
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({
|
||||
kind: 'rect',
|
||||
@@ -2515,4 +2529,236 @@ export function registerAllTests(): void {
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('timeline — show locked tracks', () => {
|
||||
test('toggling showLocked persists to localStorage', async () => {
|
||||
const { showLocked } = await import('@/composables/useShowLocked')
|
||||
const KEY = 'animatr:showLockedTracks'
|
||||
showLocked.value = true
|
||||
await tick()
|
||||
expect(localStorage.getItem(KEY)).toBe('1')
|
||||
showLocked.value = false
|
||||
await tick()
|
||||
expect(localStorage.getItem(KEY)).toBe('0')
|
||||
showLocked.value = true
|
||||
})
|
||||
|
||||
test('showLocked ref is shared across importers', async () => {
|
||||
const a = await import('@/composables/useShowLocked')
|
||||
const b = await import('@/composables/useShowLocked')
|
||||
// Same module → same ref instance
|
||||
expect(a.showLocked).toBe(b.showLocked)
|
||||
a.showLocked.value = false
|
||||
await tick()
|
||||
expect(b.showLocked.value).toBe(false)
|
||||
a.showLocked.value = true
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — group lock', () => {
|
||||
test('setTrackLocked on a group id sets group.locked', () => {
|
||||
const s = makeFreshStore()
|
||||
const g = s.addTrackGroup('G')
|
||||
s.setTrackLocked(g.id, true)
|
||||
const grp = s.scene.groups!.find((x) => x.id === g.id)!
|
||||
expect(grp.locked).toBe(true)
|
||||
})
|
||||
|
||||
test('setTrackLocked(false) clears group.locked', () => {
|
||||
const s = makeFreshStore()
|
||||
const g = s.addTrackGroup('G')
|
||||
s.setTrackLocked(g.id, true)
|
||||
s.setTrackLocked(g.id, false)
|
||||
const grp = s.scene.groups!.find((x) => x.id === g.id)!
|
||||
expect(grp.locked).toBe(undefined)
|
||||
})
|
||||
|
||||
test('setTrackLocked dispatches to layer, clip or group based on id', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
const ct = s.addClipTrack()
|
||||
const g = s.addTrackGroup('G')
|
||||
s.setTrackLocked(l.id, true)
|
||||
s.setTrackLocked(ct.id, true)
|
||||
s.setTrackLocked(g.id, true)
|
||||
expect(l.locked).toBe(true)
|
||||
const stored = s.scene.clipTracks!.find((x) => x.id === ct.id)!
|
||||
expect(stored.locked).toBe(true)
|
||||
const grp = s.scene.groups!.find((x) => x.id === g.id)!
|
||||
expect(grp.locked).toBe(true)
|
||||
})
|
||||
|
||||
test('locking a group does not mutate child layer.locked', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l.id, g.id)
|
||||
s.setTrackLocked(g.id, true)
|
||||
// The layer itself stays unlocked; effective lock is derived in UI code.
|
||||
expect(l.locked).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
suite('render — crop option', () => {
|
||||
function setupStraddlingScene() {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100
|
||||
s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
const a = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'Wide',
|
||||
width: 60,
|
||||
height: 20,
|
||||
fill: '#00ff00',
|
||||
})
|
||||
const layer = s.addLayer(a.id)
|
||||
// Centered just off the right edge — 40px of the rect spills past x=100.
|
||||
s.upsertKeyframe(layer.id, 0, { x: 110, y: 50 })
|
||||
return s
|
||||
}
|
||||
|
||||
test('default crop clips content outside stage bounds', () => {
|
||||
const s = setupStraddlingScene()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 200
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {})
|
||||
// Sample a pixel ~20px past the right edge — still inside the rect's
|
||||
// visual box but outside the stage. Should be cleared by the clip.
|
||||
const outside = ctx.getImageData(120, 50, 1, 1).data
|
||||
expect(outside[3]).toBe(0)
|
||||
})
|
||||
|
||||
test('crop:false lets content render past the stage edge', () => {
|
||||
const s = setupStraddlingScene()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 200
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||||
const outside = ctx.getImageData(120, 50, 1, 1).data
|
||||
// Green channel visible in the part of the rect that extends past the stage
|
||||
expect(outside[1] > 200).toBe(true)
|
||||
expect(outside[3] > 200).toBe(true)
|
||||
})
|
||||
|
||||
test('crop:false still paints stage background inside stage bounds', () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100
|
||||
s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = '#ff0000'
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||||
expect(inside[0] > 200).toBe(true)
|
||||
expect(inside[3] > 200).toBe(true)
|
||||
})
|
||||
|
||||
test('mask still applies when crop is disabled', () => {
|
||||
// Same setup as the positive-mask render test but with crop:false —
|
||||
// the mask should still clear content outside the mask shape, and the
|
||||
// crop flag should only affect the stage clip, not the masked group.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100
|
||||
s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
const content = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'BG',
|
||||
width: 100,
|
||||
height: 100,
|
||||
fill: '#ff0000',
|
||||
})
|
||||
const contentLayer = s.addLayer(content.id)
|
||||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||||
const mask = s.addAsset({
|
||||
kind: 'circle',
|
||||
name: 'M',
|
||||
radius: 20,
|
||||
fill: '#ffffff',
|
||||
})
|
||||
const maskLayer = s.addLayer(mask.id)
|
||||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||||
s.setLayerMask(maskLayer.id, true, 'positive')
|
||||
const group = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(maskLayer.id, group.id)
|
||||
s.moveTrackIntoGroup(contentLayer.id, group.id)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||||
// Positive mask keeps content inside
|
||||
expect(inside[0] > 200).toBe(true)
|
||||
expect(inside[3] > 200).toBe(true)
|
||||
// Outside mask cleared by the mask (even without stage clipping)
|
||||
expect(outside[3]).toBe(0)
|
||||
})
|
||||
|
||||
test('content outside the stage renders past the edge when crop:false and mask-like groups exist elsewhere', () => {
|
||||
// A straddling rect + a totally separate positive-mask group. The
|
||||
// straddling rect must still bleed past the stage edge and the mask
|
||||
// must still work inside its own group — they don't interfere.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100
|
||||
s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
// Straddling rect — spills past right edge
|
||||
const strad = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'Wide',
|
||||
width: 60,
|
||||
height: 20,
|
||||
fill: '#00ff00',
|
||||
})
|
||||
const sLayer = s.addLayer(strad.id)
|
||||
s.upsertKeyframe(sLayer.id, 0, { x: 110, y: 50 })
|
||||
// Masked group elsewhere
|
||||
const content = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'Blue',
|
||||
width: 40,
|
||||
height: 40,
|
||||
fill: '#0000ff',
|
||||
})
|
||||
const cLayer = s.addLayer(content.id)
|
||||
s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 })
|
||||
const mask = s.addAsset({
|
||||
kind: 'circle',
|
||||
name: 'M',
|
||||
radius: 10,
|
||||
fill: '#fff',
|
||||
})
|
||||
const mLayer = s.addLayer(mask.id)
|
||||
s.upsertKeyframe(mLayer.id, 0, { x: 50, y: 50 })
|
||||
s.setLayerMask(mLayer.id, true, 'positive')
|
||||
const group = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(mLayer.id, group.id)
|
||||
s.moveTrackIntoGroup(cLayer.id, group.id)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 200
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {}, { crop: false })
|
||||
// Straddling rect's spill past x=100 is visible
|
||||
const spill = ctx.getImageData(120, 50, 1, 1).data
|
||||
expect(spill[1] > 200).toBe(true)
|
||||
expect(spill[3] > 200).toBe(true)
|
||||
// Mask still trims the blue rect outside its circle.
|
||||
// (32, 50) is inside the blue rect (30–70) but outside the 10-radius
|
||||
// mask circle (distance ~18), and well away from the green rect
|
||||
// (80–140), so it must be cleared by the mask.
|
||||
const maskOutside = ctx.getImageData(32, 50, 1, 1).data
|
||||
expect(maskOutside[3]).toBe(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -173,6 +173,8 @@ export const en = {
|
||||
toggleTimeUnit: 'Toggle time unit',
|
||||
snapOn: 'Snapping on (playhead & keyframes snap together)',
|
||||
snapOff: 'Snapping off',
|
||||
hideLocked: 'Hide locked tracks',
|
||||
showLocked: 'Show locked tracks',
|
||||
stillFrameTooltip: 'Still frame {time} — holds until the next keyframe',
|
||||
keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options',
|
||||
trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open',
|
||||
|
||||
+7
-3
@@ -333,14 +333,18 @@ export function drawSceneFrame(
|
||||
scene: Scene,
|
||||
time: number,
|
||||
images: Record<string, HTMLImageElement>,
|
||||
options?: { crop?: boolean },
|
||||
) {
|
||||
const lw = scene.canvas.width
|
||||
const lh = scene.canvas.height
|
||||
const crop = options?.crop !== false
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, lw, lh)
|
||||
ctx.clip()
|
||||
if (crop) {
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, lw, lh)
|
||||
ctx.clip()
|
||||
}
|
||||
ctx.fillStyle = scene.canvas.background
|
||||
ctx.fillRect(0, 0, lw, lh)
|
||||
|
||||
|
||||
@@ -957,6 +957,12 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (clipTrack) {
|
||||
if (locked) clipTrack.locked = true
|
||||
else delete clipTrack.locked
|
||||
return
|
||||
}
|
||||
const group = this.scene.groups?.find((g) => g.id === trackId)
|
||||
if (group) {
|
||||
if (locked) group.locked = true
|
||||
else delete group.locked
|
||||
}
|
||||
},
|
||||
setLayerSeparated(layerId: string, separated: boolean) {
|
||||
|
||||
@@ -142,6 +142,7 @@ export interface TrackGroup {
|
||||
childIds: string[]
|
||||
collapsed?: boolean
|
||||
color?: string | null
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export interface ClipInstance {
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.5.5'
|
||||
export const APP_VERSION = '0.5.6'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user