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:
th committed 2026-04-25 21:45:58 +02:00
1 parent 492f25f629
commit d03fbe4ecb
15 files changed
+2529 -222

No files matched your search

+33 -25
View File
@@ -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