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:
1 parent
baf775c8de
commit
a858f94bb7
10 files changed
+887
-84
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user