0.17.0: split SVG layer into per-path layers

Right-click an SVG-backed layer to split it into one layer per
top-level drawable. Each part keeps the original stage position
and ends up as a same-size standalone SVG.

Also: delete confirm now floats above the welcome dialog (topmost
z-index + scoped ESC/Tab via modal stack) and autofocuses its
primary button so Enter triggers confirm instead of bubbling back
to the underlying row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 12:26:57 +02:00
1 parent baf775c8de
commit a858f94bb7
10 files changed
+887 -84

No files matched your search

+152
View File
@@ -28,6 +28,7 @@ import type {
} from '@/types/scene'
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit'
import {
buildMarkerMap,
evalAction,
@@ -40,6 +41,40 @@ const uid = () => Math.random().toString(36).slice(2, 10)
type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : never) : never
/** A source URL is considered SVG if it's a `data:image/svg+xml` payload.
* External URLs are rare in this app (imports data-URL everything) and
* splitting them would need a network fetch we don't want to run
* synchronously inside an action. */
function isSvgSource(src: string | undefined): boolean {
if (!src) return false
return src.startsWith('data:image/svg+xml')
}
/** Pull the SVG text out of a data URL. Supports both the URL-encoded
* (`data:image/svg+xml,`...) and base64 (`data:image/svg+xml;base64,`...)
* forms produced by the importer. Returns null when the payload is
* malformed so callers can silently bail out. */
function decodeSvgDataUrl(src: string): string | null {
const comma = src.indexOf(',')
if (comma < 0) return null
const header = src.slice(0, comma)
const payload = src.slice(comma + 1)
try {
if (header.includes(';base64')) {
// atob produces a binary string; re-decode as UTF-8 so high-
// byte chars (non-ASCII attribute values, embedded CSS, etc.)
// come through intact instead of mojibake.
const binary = atob(payload)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new TextDecoder('utf-8').decode(bytes)
}
return decodeURIComponent(payload)
} catch {
return null
}
}
const KEYFRAME_EPSILON = 1e-3
/** Shared cascading push-aside helper used by the audio-clip and clip-
@@ -704,6 +739,123 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerIds = this.selectedLayerIds.filter((x) => x !== id)
}
},
/** Replace a single SVG-backed layer with one layer per top-level
* drawable in the SVG. Each new layer lands in the stack slot of
* the original and preserves every animatable field — base,
* keyframes, scale, rotation, opacity. Positions are recomputed
* so the parts stay visually pinned to their pre-split spots on
* the stage. Returns the new layer ids, or `null` if the layer
* isn't SVG-backed or the SVG has fewer than 2 splittable parts. */
splitLayerSvg(layerId: string): string[] | null {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
const asset = this.scene.assets.find((a) => a.id === layer.assetId)
if (!asset || asset.kind !== 'image') return null
if (!isSvgSource(asset.src)) return null
const svgText = decodeSvgDataUrl(asset.src)
if (!svgText) return null
const split = splitSvgRuntime(svgText)
if (!split || split.parts.length < 2) return null
const parts = split.parts
// Insert location: wherever the original sat inside trackOrder
// or inside its parent group's childIds.
const order = this.scene.trackOrder ?? []
const topIdx = order.indexOf(layerId)
let parentGroup: TrackGroup | null = null
let childIdx = -1
if (topIdx < 0) {
for (const g of this.scene.groups ?? []) {
const i = g.childIds.indexOf(layerId)
if (i >= 0) {
parentGroup = g
childIdx = i
break
}
}
}
const newLayerIds: string[] = []
const newAssets: Asset[] = []
const newLayers: Layer[] = []
// Each part is a full-sized copy of the original SVG with only
// one drawable in its body. That means every new layer inherits
// the original's position/scale/keyframes verbatim — the part
// renders exactly where it was because its SVG canvas is the
// same size and coordinate space as before.
const cloneKeyframes = (kfs: Keyframe[] | undefined): Keyframe[] | undefined => {
if (!kfs) return kfs
return kfs.map((kf) => ({
...kf,
id: uid(),
props: { ...kf.props },
}))
}
for (const part of parts) {
const partAsset: Asset = {
id: uid(),
kind: 'image',
name: `${asset.name} · ${part.name}`,
src: part.dataUrl,
width: asset.width,
height: asset.height,
}
newAssets.push(partAsset)
const partLayer: Layer = {
id: uid(),
name: `${layer.name} · ${part.name}`,
assetId: partAsset.id,
visible: layer.visible,
base: { ...layer.base },
keyframes: cloneKeyframes(layer.keyframes) ?? [],
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
}
if (layer.positionKeyframes) {
partLayer.positionKeyframes = cloneKeyframes(layer.positionKeyframes)
}
if (layer.rotationKeyframes) {
partLayer.rotationKeyframes = cloneKeyframes(layer.rotationKeyframes)
}
if (layer.separated) partLayer.separated = true
if (layer.color !== undefined) partLayer.color = layer.color
if (layer.colorOverridden !== undefined) partLayer.colorOverridden = layer.colorOverridden
if (layer.locked) partLayer.locked = layer.locked
if (layer.editorHidden) partLayer.editorHidden = layer.editorHidden
newLayers.push(partLayer)
newLayerIds.push(partLayer.id)
}
// Commit: push new assets, insert new layers at the original
// slot, then remove the original.
this.scene.assets.push(...newAssets)
const layers = this.activeLayers
const origIdx = layers.findIndex((l) => l.id === layerId)
if (origIdx >= 0) layers.splice(origIdx, 1, ...newLayers)
else layers.push(...newLayers)
if (parentGroup && childIdx >= 0) {
parentGroup.childIds.splice(
childIdx,
1,
...newLayers.map((l) => l.id),
)
} else if (topIdx >= 0) {
this.scene.trackOrder!.splice(topIdx, 1, ...newLayers.map((l) => l.id))
} else {
;(this.scene.trackOrder ||= []).unshift(...newLayers.map((l) => l.id))
}
if (this.selectedLayerId === layerId) {
this.selectedLayerId = newLayerIds[0] ?? null
this.selectedLayerIds = newLayerIds
}
return newLayerIds
},
moveLayer(fromIndex: number, toIndex: number) {
const arr = this.activeLayers
if (fromIndex < 0 || fromIndex >= arr.length) return