1.2.0: SVG asset editor + end keyframe type + UX bundle
This release bundles three independently-useful pieces of work that
overlap heavily in shared files (types, store, i18n, App, CanvasStage,
tests), so they ship together.
SVG asset editor
- New asset kind 'svg' (SvgAsset { canvas, layers }), authored from
inside the existing shape primitives (rect / circle / text / polygon /
freeform). "+" button under the Images sidebar header; opens a
sub-editor that swaps the library sidebar for a dedicated layer
panel, hides the timeline, and uses the same canvas-resize flow
("Edit canvas size" via right-click → no Document settings entry
while in scope). Children carry a single t=0 keyframe each.
- Multi-select rotation in SVG scope only: a temporary group pivot
appears at the selection centroid (draggable), rotation around it
shifts each layer's x/y/rotation. Pivot follows the centroid drift
on Konva-transformer scale/move. Outside SVG, transformer
rotateEnabled stays off (group-rotate write-back is unreliable
per prior feedback).
- Authored SVGs are placed as layers in the main scene and rendered
via drawMaskedGroup at t=0; thumbnails surface in the sidebar via
renderSvgThumbnail. Drop / click / drag / dbl-click-to-edit work
via a transparent svg-hit catcher rect routed in the stage handler.
- Freeform default stroke is red (#e23b3b) so the open path is
visible immediately. addDrawingPoint resolves the layer through
activeLayers so freeform draws correctly inside SVG and clip
scopes too.
End keyframe type
- Keyframe.kind: 'normal' | 'end'. End kfs carry no props, mark the
visibility cap. layerActiveRange / isLayerActiveAt / sampleKeyframeSet
ignore them for interpolation and use them only as the visible-from
/ visible-until bounds.
- One-time migration in normalizeProject synthesizes an end kf at the
time of the last normal kf for layers that relied on the legacy
"last kf = disappears" semantics; layers using hold:true to extend
visibility past the end skip the migration. Idempotent.
- Timeline UI: new "+end" toolbar button (multi-select fan-out, toggle),
"Convert to end" / "Back to normal" entries in the kf context menu
(easing / still-frame / duplicate hidden for end kfs), end kfs
render as a flat axis-aligned square (vs. the diamond-rotated normal
kf). Render filter drops end kfs that collide with a normal kf at
the same time so legacy migrated projects don't read as four-point
stars.
UX polish
- TAB-collapse stage anchoring: the canvas content stays put when the
asset / properties asides toggle (CanvasStage's ResizeObserver
compensates viewX/Y by the container's bounding-rect drift).
- Topbar clipboard button copies the current frame to the system
clipboard as a PNG via navigator.clipboard.write([ClipboardItem]).
- chromeClick predicate adds .timeline to the editing-panels bail so
the synthetic click after a Timeline box-select drag no longer
wipes the just-committed kf selection.
- Right-click on a polygon vertex stops propagation so the vertex
context menu wins instead of the generic stage menu opening on top.
This commit is contained in:
1 parent
492f25f629
commit
d03fbe4ecb
15 files changed
+2529
-222
No files matched your search
+33
-25
@@ -99,7 +99,10 @@ function sampleKeyframeSet(
|
||||
time: number,
|
||||
): AnimatableProps {
|
||||
if (!kfs || !kfs.length) return { ...base }
|
||||
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
||||
// End-kfs are visibility markers only — they carry no props and
|
||||
// must not influence interpolation. Filter them out before sorting.
|
||||
const sorted = kfs.filter((k) => k.kind !== 'end').sort((a, b) => a.time - b.time)
|
||||
if (!sorted.length) return { ...base }
|
||||
if (time <= sorted[0].time) return merge(base, sorted[0].props)
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (time >= last.time) return merge(base, last.props)
|
||||
@@ -128,18 +131,27 @@ function sampleKeyframeSet(
|
||||
return { ...base }
|
||||
}
|
||||
|
||||
/** Earliest end-kf time across the layer's main track, or null when
|
||||
* no end kf exists (→ layer never auto-disappears). End kfs only
|
||||
* live on `keyframes`, never on the separated position/rotation
|
||||
* tracks — there's only one end-of-life per layer. */
|
||||
export function layerEndTime(layer: Layer): number | null {
|
||||
let end: number | null = null
|
||||
for (const k of layer.keyframes ?? []) {
|
||||
if (k.kind !== 'end') continue
|
||||
if (end === null || k.time < end) end = k.time
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
/**
|
||||
* Union range of all keyframe times on a layer (main + position + rotation
|
||||
* tracks). A layer is considered "alive" only within this range.
|
||||
* Union range of all NORMAL keyframe times on a layer (main + position
|
||||
* + rotation tracks). The layer is "alive" from `min` onward; the end
|
||||
* is determined by `layerEndTime` or, if there is no end kf, runs out
|
||||
* to Infinity.
|
||||
*
|
||||
* Returns null (→ always visible) when the layer has zero or one total
|
||||
* keyframes — a single keyframe is a static layer with no animation range.
|
||||
*
|
||||
* Special case: when the keyframe with the latest time has `hold: true`,
|
||||
* the max is extended to Infinity — the layer stays alive past its last
|
||||
* keyframe, pinned to that keyframe's sampled value. That's how users
|
||||
* turn the final keyframe of a short anim into a still frame so the
|
||||
* layer stays on-screen for the remainder of the timeline.
|
||||
* Returns null when the layer has zero normal keyframes — a static
|
||||
* layer with no animation range, always visible.
|
||||
*/
|
||||
export function layerActiveRange(
|
||||
layer: Layer,
|
||||
@@ -147,31 +159,25 @@ export function layerActiveRange(
|
||||
const main = layer.keyframes || []
|
||||
const pos = layer.positionKeyframes || []
|
||||
const rot = layer.rotationKeyframes || []
|
||||
const total = main.length + pos.length + rot.length
|
||||
if (total <= 1) return null
|
||||
let min = Infinity
|
||||
let max = -Infinity
|
||||
let lastHold = false
|
||||
let foundNormal = false
|
||||
for (const arr of [main, pos, rot]) {
|
||||
for (const k of arr) {
|
||||
if (k.kind === 'end') continue
|
||||
foundNormal = true
|
||||
if (k.time < min) min = k.time
|
||||
if (k.time > max) {
|
||||
max = k.time
|
||||
lastHold = !!k.hold
|
||||
} else if (k.time === max && k.hold) {
|
||||
lastHold = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastHold) max = Infinity
|
||||
return { min, max }
|
||||
if (!foundNormal) return null
|
||||
const end = layerEndTime(layer)
|
||||
return { min, max: end ?? Infinity }
|
||||
}
|
||||
|
||||
export function isLayerActiveAt(layer: Layer, time: number): boolean {
|
||||
const range = layerActiveRange(layer)
|
||||
if (!range) return true
|
||||
const EPSILON = 1e-6
|
||||
return time >= range.min - EPSILON && time <= range.max + EPSILON
|
||||
return time >= range.min - EPSILON && time < range.max - EPSILON
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,7 +198,9 @@ export function isLayerActiveAt(layer: Layer, time: number): boolean {
|
||||
export function isButtonLayerActiveAt(layer: Layer, time: number): boolean {
|
||||
const EPSILON = 1e-6
|
||||
const kfs: Keyframe[] = []
|
||||
for (const k of layer.keyframes) kfs.push(k)
|
||||
// End kfs aren't meaningful in button-asset edit (button states
|
||||
// are discrete frame slots, not a timeline) — skip them.
|
||||
for (const k of layer.keyframes) if (k.kind !== 'end') kfs.push(k)
|
||||
if (layer.positionKeyframes) for (const k of layer.positionKeyframes) kfs.push(k)
|
||||
if (layer.rotationKeyframes) for (const k of layer.rotationKeyframes) kfs.push(k)
|
||||
if (kfs.length === 0) return true
|
||||
|
||||
Reference in new issue
Block a user