0.8.0: group colour cascade, still-frame past end, project rename, UX polish
Headline: - Group colour cascades to every non-overridden child (layer or clip track). New `colorOverridden` flag on Layer / ClipTrack / ScriptTrack / TrackGroup; setTrackColor on a non-group pins the track, clearing re-inherits from the parent group. moveTrackIntoGroup also inherits. normalizeScene migrates legacy scenes (existing colour ⇒ overridden). - Keyframes inside a button-asset timeline snap to the 4 state slots (normal/hover/pressed/disabled). upsertKeyframe / setKeyframeTime / duplicateKeyframe all enforce it; colliding drags refuse the move, duplicates walk to the nearest free slot. - Backspace / Delete on a selected layer removes it (respecting the layer's own lock and any enclosing group lock). - "Still frame" allowed on the last keyframe: the layer stays on stage past its final kf. Timeline draws a solid dim tail line from the last kf to the end, and single-keyframe tracks now show a full-span line so on-stage/off-stage reads at a glance. Height stays 3px. - Project name moved next to the logo, turns into a rename button on hover (pencil fades in). Rename syncs the stored record when the project is already persisted. - Shadow + blur keyframes animate in the preview and exported runtime: the runtime's lerpProps used to drop them, so every preview rendered shadow=0 between keyframes. RUNTIME_VERSION 11 → 13 (covers the button-mask + shadow fixes). Held-last-kf also taught to the runtime. - Button masks render across every path: edit-mode mask-preview, nested buttons on the main stage, sidebar thumbnails, fullscreen preview + exports. drawButtonFrame new in render.ts. Smaller fixes: - Dragging layer A on the stage with a stale selection on layer B used to move B and snap A back. Now the shift-selected branch only runs when the dragged layer is itself in the selection. - Clip-track row was missing its icon — PhFilmStrip was referenced but never imported. Added, plus dropped the bespoke `gap` on clip / script name rows so their icon+label spacing matches layer rows. - Clip-art drawer pre-lays-out its 400px grid behind `overflow: hidden` so slide-in reveals a finished grid instead of reflowing auto-fill columns over 220ms. - Timeline clip-track dropzone hides until the library has a clip; its "drop a clip here" hint stays centred in the visible ruler area. - Keyframe context menu: check-mark and icons share a single 12px column so the ✓ on an easing option lines up with PhPauseCircle / PhCopy / PhTrash on the rows below. "Still frame"'s active indicator moved to the trailing-check slot. - Button editor's mask-preview now shows the button's own stage instead of leaking through to the main scene. drawButtonFrame drives it. Regression tests cover all of the above: group-color cascade, pin, clear-re-inherit, migration, button-kf snap and collision rules, backspace-deletes-selected-layer (incl. lock guards + editable-target guard), still-frame-past-end visibility, clipart drawer rigid width, mask rendering in drawButtonFrame / drawSceneFrame, shadow + blur interpolation in both canvas and runtime paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ac89f6762d
commit
1e89ea86e0
15 files changed
+849
-74
No files matched your search
@@ -2414,7 +2414,12 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
if (!layer) return
|
||||
const newX = node.x()
|
||||
const newY = node.y()
|
||||
if (selectedKfs.value.length > 0 && anySelectedKfAtPlayhead()) {
|
||||
// Multi-keyframe shift applies only when the layer being dragged is
|
||||
// itself part of the current selection. Otherwise a stale selection on
|
||||
// some other layer would steal the drag delta, leaving the dragged
|
||||
// layer to snap back while the selected one moved instead.
|
||||
const draggedInSelection = selectedKfs.value.some((k) => k.layerId === id)
|
||||
if (draggedInSelection && anySelectedKfAtPlayhead()) {
|
||||
const samp = sampleLayer(layer, store.playhead)
|
||||
const dx = newX - samp.x
|
||||
const dy = newY - samp.y
|
||||
|
||||
@@ -49,7 +49,7 @@ function onItemDragStart(e: DragEvent, index: number) {
|
||||
>
|
||||
<component :is="clipart.open ? PhCaretLeft : PhCaretRight" :size="14" />
|
||||
</button>
|
||||
<div v-if="clipart.open" class="drawer-panel">
|
||||
<div class="drawer-panel">
|
||||
<header class="drawer-header">
|
||||
<PhImagesSquare :size="14" class="title-icon" />
|
||||
<h3>{{ t('clipart.title') }}</h3>
|
||||
@@ -106,6 +106,11 @@ function onItemDragStart(e: DragEvent, index: number) {
|
||||
background: var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
position: relative;
|
||||
/* The drawer-panel is always mounted at its full 400px width so the
|
||||
thumbnail grid's columns don't reflow mid-animation. `overflow:hidden`
|
||||
clips it to the drawer's animated width so while closed/hovering the
|
||||
panel is rendered but invisible. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clipart-drawer:hover {
|
||||
@@ -157,8 +162,11 @@ function onItemDragStart(e: DragEvent, index: number) {
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Rigid 400px width — never flex-shrinks. Combined with the drawer's
|
||||
`overflow: hidden`, this lets the grid be laid out correctly the
|
||||
moment it mounts and stay that way regardless of the drawer's
|
||||
current animated width. */
|
||||
flex: 0 0 400px;
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+79
-33
@@ -8,6 +8,7 @@ import {
|
||||
PhCode,
|
||||
PhCopy,
|
||||
PhDotsSixVertical,
|
||||
PhFilmStrip,
|
||||
PhFolder,
|
||||
PhFolderPlus,
|
||||
PhLock,
|
||||
@@ -1062,12 +1063,35 @@ interface KfSegment {
|
||||
toTime: number
|
||||
easing: Easing
|
||||
held: boolean
|
||||
/** True when this segment is the "still-frame tail" extending past
|
||||
* the last keyframe to the end of the timeline. Rendered dimmer so
|
||||
* it reads as an after-image rather than an interpolation. */
|
||||
tail?: boolean
|
||||
}
|
||||
|
||||
function segmentsOf(kfs: Keyframe[]): KfSegment[] {
|
||||
if (kfs.length < 2) return []
|
||||
function segmentsOf(kfs: Keyframe[], duration: number): KfSegment[] {
|
||||
if (kfs.length === 0) return []
|
||||
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
||||
const out: KfSegment[] = []
|
||||
|
||||
// Single keyframe means the layer is always visible (no animation
|
||||
// range), so the line should span the entire timeline as a solid
|
||||
// dim "on-stage" indicator — same treatment as a still-frame tail.
|
||||
if (sorted.length === 1) {
|
||||
const only = sorted[0]
|
||||
if (duration > 0) {
|
||||
out.push({
|
||||
id: `${only.id}>full`,
|
||||
fromTime: 0,
|
||||
toTime: duration,
|
||||
easing: only.easing,
|
||||
held: false,
|
||||
tail: true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i]
|
||||
const b = sorted[i + 1]
|
||||
@@ -1079,6 +1103,20 @@ function segmentsOf(kfs: Keyframe[]): KfSegment[] {
|
||||
held: !!a.hold,
|
||||
})
|
||||
}
|
||||
// Last-keyframe "still frame" tail: when the final keyframe is held,
|
||||
// extend the on-stage indicator to the end of the timeline. Solid
|
||||
// and dim — the value isn't interpolating, it's just idling.
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (last.hold && duration > last.time) {
|
||||
out.push({
|
||||
id: `${last.id}>tail`,
|
||||
fromTime: last.time,
|
||||
toTime: duration,
|
||||
easing: last.easing,
|
||||
held: false,
|
||||
tail: true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1974,6 +2012,20 @@ function onDocumentKeyDown(e: KeyboardEvent) {
|
||||
store.removeMarker(id)
|
||||
return
|
||||
}
|
||||
// Selected-track deletion. Only layer tracks carry a selection
|
||||
// today (clip / script / group tracks don't set selectedLayerId).
|
||||
// Skip the delete when the layer is locked directly or through its
|
||||
// parent group — Backspace should respect the same locks that block
|
||||
// mouse interaction.
|
||||
if (store.selectedLayerId) {
|
||||
const layerId = store.selectedLayerId
|
||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||
if (!layer) return
|
||||
if (layer.locked || lockedGroupChildIds.value.has(layerId)) return
|
||||
e.preventDefault()
|
||||
store.removeLayer(layerId)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (
|
||||
e.key.toLowerCase() === 'm' &&
|
||||
@@ -2390,10 +2442,10 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||
<span
|
||||
v-for="seg in segmentsOf(track.kfs)"
|
||||
v-for="seg in segmentsOf(track.kfs, duration)"
|
||||
:key="seg.id"
|
||||
class="kf-seg"
|
||||
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
|
||||
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
|
||||
:style="segmentStyle(seg)"
|
||||
/>
|
||||
<span
|
||||
@@ -2777,14 +2829,13 @@ onBeforeUnmount(() => {
|
||||
:disabled="menu.isFirst || menu.isHeld"
|
||||
@click="applyEasing(opt.value)"
|
||||
>
|
||||
<span class="check">
|
||||
<span class="kf-menu-icon">
|
||||
<PhCheck
|
||||
v-if="menu.currentEasing === opt.value && !menu.isHeld"
|
||||
:size="12"
|
||||
weight="bold"
|
||||
/>
|
||||
</span>
|
||||
<span class="kf-menu-icon-spacer"></span>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
|
||||
@@ -2794,24 +2845,23 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="kf-menu-item"
|
||||
:class="{ active: menu.isHeld }"
|
||||
:disabled="menu.isLast"
|
||||
:title="menu.isLast ? t('timeline.lastKfHint') : ''"
|
||||
@click="toggleHold"
|
||||
>
|
||||
<span class="check">
|
||||
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
|
||||
</span>
|
||||
<PhPauseCircle :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.stillFrame') }}
|
||||
<PhCheck
|
||||
v-if="menu.isHeld"
|
||||
:size="12"
|
||||
weight="bold"
|
||||
class="kf-menu-trailing-check"
|
||||
/>
|
||||
</button>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="duplicateFromMenu">
|
||||
<span class="check"></span>
|
||||
<PhCopy :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.duplicate') }}
|
||||
</button>
|
||||
<button class="kf-menu-item danger" @click="deleteFromMenu">
|
||||
<span class="check"></span>
|
||||
<PhTrash :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.delete') }}
|
||||
</button>
|
||||
@@ -3803,6 +3853,21 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* "Tail" = on-stage indicator where the layer is visible but no
|
||||
interpolation is happening (still-frame past the last keyframe, or
|
||||
a single-keyframe layer that's always on stage). Solid line at low
|
||||
opacity — reads as "layer idling on stage" rather than "thin line".
|
||||
Overrides any preceding easing-* gradient background so the fill is
|
||||
always a flat accent colour at the track's tint. */
|
||||
.kf-seg.tail,
|
||||
.kf-seg.tail.easing-linear,
|
||||
.kf-seg.tail.easing-easeIn,
|
||||
.kf-seg.tail.easing-easeOut,
|
||||
.kf-seg.tail.easing-easeInOut {
|
||||
background: var(--track-color, var(--accent));
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.kf {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -3871,10 +3936,6 @@ onBeforeUnmount(() => {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.clip-track-name {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.clip-lane {
|
||||
position: relative;
|
||||
}
|
||||
@@ -3896,10 +3957,6 @@ onBeforeUnmount(() => {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.script-track-name {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.script-lane {
|
||||
position: relative;
|
||||
}
|
||||
@@ -4238,18 +4295,7 @@ onBeforeUnmount(() => {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.check {
|
||||
width: 14px;
|
||||
height: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kf-menu-icon,
|
||||
.kf-menu-icon-spacer {
|
||||
.kf-menu-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
Reference in new issue
Block a user