Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 45 additions & 14 deletions src/components/shell/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { createDefaultProject } from '@/editor/scene'
import { useEditorStore } from '@/editor/store'
import type { ExportOptions } from '@/io/export-options'
import { downloadProject, LARGE_PROJECT_PERSIST_LAYER_THRESHOLD, openProjectFile } from '@/io/project'
import { downloadLottie, exportLottie, readLottieFromFile } from '@/io/lottie'
import { downloadLottie, exportLottie, readLottieFromFile, computeLottieImportProgress, formatLottieImportProgress } from '@/io/lottie'
import {
computeSvgImportProgress,
createImportLayerIds,
Expand Down Expand Up @@ -92,7 +92,8 @@ export function Toolbar() {
const [isExporting, setIsExporting] = useState(false)
const [isImportingSvg, setIsImportingSvg] = useState(false)
const [isImportingHtml, setIsImportingHtml] = useState(false)
const isImporting = isImportingSvg || isImportingHtml
const [isImportingLottie, setIsImportingLottie] = useState(false)
const isImporting = isImportingSvg || isImportingHtml || isImportingLottie
const project = useEditorStore((state) => state.project)
const activeArtboardId = useEditorStore((state) => state.activeArtboardId)
const playbackState = useEditorStore((state) => state.playbackState)
Expand Down Expand Up @@ -353,23 +354,53 @@ export function Toolbar() {
return
}

const imported = await readLottieFromFile(file)
if (!imported) {
setIsImportingLottie(true)
const toastId = showToast({
title: 'Importing Lottie',
description: formatLottieImportProgress({ stage: 'parsing' }),
variant: 'loading',
progress: computeLottieImportProgress({ stage: 'parsing' }),
})
await waitForPaint()

try {
const imported = await readLottieFromFile(file, {
onProgress: (progress) => {
updateToast(toastId, {
description: formatLottieImportProgress(progress),
progress: computeLottieImportProgress(progress),
})
},
})

dismissToast(toastId)

if (!imported) {
showToast({
title: 'Lottie import failed',
description: `"${file.name}" is not a valid Lottie JSON file.`,
variant: 'error',
})
return
}

setProject(imported)
requestAnimationFrame(fitCanvasToScreen)
showToast({
title: 'Lottie opened',
description: `Loaded ${imported.layers.length} layer${imported.layers.length === 1 ? '' : 's'} as a new project.`,
variant: 'success',
})
} catch (error) {
updateToast(toastId, {
title: 'Lottie import failed',
description: `"${file.name}" is not a valid Lottie JSON file.`,
description:
error instanceof Error ? error.message : 'Something went wrong while importing.',
variant: 'error',
})
return
} finally {
setIsImportingLottie(false)
}

setProject(imported)
requestAnimationFrame(fitCanvasToScreen)
showToast({
title: 'Lottie opened',
description: 'Loaded animation as a new project.',
variant: 'success',
})
}

const handleExport = async (options: ExportOptions) => {
Expand Down
23 changes: 18 additions & 5 deletions src/editor/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import type {
NumericAnimatableProperty,
Shape,
} from '@/editor/types'
import {
applyGroupTransformsToShape,
layerHasGroupAnimation,
type AnimatedShapeContext,
} from '@/editor/group-animation'
export type { AnimatedShapeContext } from '@/editor/group-animation'
import { sampleEasing } from '@/editor/easing'
import { layerHasAnimation } from '@/editor/layer-animation'
import type { AffineMatrix } from '@/io/svg-transform'
Expand Down Expand Up @@ -139,7 +145,7 @@ export function samplePropertyAtTime(
return sampleNumericTrackAtTime(track, time, fallback)
}

function sampleNumericTrackAtTime(track: Keyframe[], time: number, fallback: number): number {
export function sampleNumericTrackAtTime(track: Keyframe[], time: number, fallback: number): number {
return sampleSegmentValue(track, time, fallback, (current, next, eased) =>
lerp(current.value as number, next.value as number, eased),
)
Expand Down Expand Up @@ -310,14 +316,19 @@ function layerNeedsMatrixSampling(layer: Layer): boolean {
)
}

export function getAnimatedShape(layer: Layer, time: number): Shape {
if (!layerHasAnimation(layer) && !layerNeedsMatrixSampling(layer)) {
export function getAnimatedShape(
layer: Layer,
time: number,
context?: AnimatedShapeContext,
): Shape {
const hasGroupAnimation = layerHasGroupAnimation(layer, context?.layerGroups)
if (!layerHasAnimation(layer) && !layerNeedsMatrixSampling(layer) && !hasGroupAnimation) {
return layer.shape
}

const cached = animatedShapeCache.get(layer)
if (cached && cached.time === time) {
return cached.shape
return applyGroupTransformsToShape(cached.shape, layer, time, context)
}

const { shape } = layer
Expand Down Expand Up @@ -390,6 +401,8 @@ export function getAnimatedShape(layer: Layer, time: number): Shape {
}
}

const resultWithGroups = applyGroupTransformsToShape(result, layer, time, context)

animatedShapeCache.set(layer, { time, shape: result })
return result
return resultWithGroups
}
138 changes: 138 additions & 0 deletions src/editor/group-animation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { sampleNumericTrackAtTime } from '@/editor/animation'
import { getShapeBounds } from '@/editor/bounds'
import type { Keyframe, Layer, LayerGroupMeta, Shape } from '@/editor/types'

export type AnimatedShapeContext = {
layerGroups?: Record<string, LayerGroupMeta>
}

function getGroupAncestorChain(
groupId: string | null,
layerGroups?: Record<string, LayerGroupMeta>,
): string[] {
if (!groupId || !layerGroups) {
return []
}

const chain: string[] = []
let current: string | null = groupId

while (current && layerGroups[current]) {
chain.push(current)
current = layerGroups[current]!.parentGroupId
}

return chain.reverse()
}

function sampleGroupNumeric(
keyframes: Keyframe[],
property: Keyframe['property'],
time: number,
fallback: number,
): number {
if (keyframes.length === 0) {
return fallback
}

const track = keyframes
.filter((keyframe) => keyframe.property === property)
.sort((left, right) => left.time - right.time)

return sampleNumericTrackAtTime(track, time, fallback)
}

function getShapeCenter(shape: Shape): { x: number; y: number } {
const bounds = getShapeBounds(shape)
return {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2,
}
}

function centerToTopLeft(
center: { x: number; y: number },
shape: Shape,
): Pick<Shape, 'x' | 'y'> {
const bounds = getShapeBounds(shape)

if (shape.type === 'ellipse') {
return { x: center.x, y: center.y }
}

return {
x: center.x - bounds.width / 2,
y: center.y - bounds.height / 2,
}
}

export function applyGroupTransformsToShape(
shape: Shape,
layer: Layer,
time: number,
context?: AnimatedShapeContext,
): Shape {
const layerGroups = context?.layerGroups
if (!layerGroups || !layer.groupId) {
return shape
}

let center = getShapeCenter(shape)
let rotation = shape.rotation
let scaleX = shape.scaleX
let scaleY = shape.scaleY
let opacity = shape.opacity

for (const groupId of getGroupAncestorChain(layer.groupId, layerGroups)) {
const group = layerGroups[groupId]
const keyframes = group?.keyframes ?? []
if (keyframes.length === 0) {
continue
}

const translateX = sampleGroupNumeric(keyframes, 'x', time, 0)
const translateY = sampleGroupNumeric(keyframes, 'y', time, 0)
const groupRotation = sampleGroupNumeric(keyframes, 'rotation', time, 0)
const groupScaleX = sampleGroupNumeric(keyframes, 'scaleX', time, 1)
const groupScaleY = sampleGroupNumeric(keyframes, 'scaleY', time, 1)
const groupOpacity = sampleGroupNumeric(keyframes, 'opacity', time, 1)

if (groupRotation !== 0) {
rotation += groupRotation
}

if (translateX !== 0) {
center = { ...center, x: center.x + translateX }
}

if (translateY !== 0) {
center = { ...center, y: center.y + translateY }
}

scaleX *= groupScaleX
scaleY *= groupScaleY
opacity *= groupOpacity
}

return {
...shape,
...centerToTopLeft(center, shape),
rotation,
scaleX,
scaleY,
opacity,
}
}

export function layerHasGroupAnimation(
layer: Layer,
layerGroups?: Record<string, LayerGroupMeta>,
): boolean {
if (!layerGroups || !layer.groupId) {
return false
}

return getGroupAncestorChain(layer.groupId, layerGroups).some(
(groupId) => (layerGroups[groupId]?.keyframes?.length ?? 0) > 0,
)
}
17 changes: 12 additions & 5 deletions src/editor/store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { create } from 'zustand'

import { DEFAULT_CUSTOM_BEZIER } from '@/editor/easing'
import { getAnimatedShape } from '@/editor/animation'
import { getAnimatedShape as resolveAnimatedShape } from '@/editor/animation'
import { getShapeBounds } from '@/editor/bounds'
import { applyPresetToLayers, type PresetId, type PresetOptions } from '@/editor/presets'
import {
Expand Down Expand Up @@ -390,7 +390,9 @@ function getSelectedAlignItems(state: EditorStore) {
)
.map((layer) => ({
id: layer.id,
shape: getAnimatedShape(layer, state.currentTime),
shape: resolveAnimatedShape(layer, state.currentTime, {
layerGroups: state.project.layerGroups,
}),
}))
}

Expand Down Expand Up @@ -443,7 +445,7 @@ function restoreSnapshot(snapshot: ReturnType<typeof createSnapshot>): Partial<E
}
}

export const useEditorStore = create<EditorStore>((set) => ({
export const useEditorStore = create<EditorStore>((set, get) => ({
project: initialProject,
activeArtboardId: initialProject.artboards[0]?.id ?? null,
selectedLayerIds: [],
Expand Down Expand Up @@ -1965,7 +1967,9 @@ export const useEditorStore = create<EditorStore>((set) => ({
const boundsList = state.selectedLayerIds
.map((id) => state.project.layers.find((layer) => layer.id === id))
.filter((layer): layer is Layer => Boolean(layer))
.map((layer) => getShapeBounds(getAnimatedShape(layer, state.currentTime)))
.map((layer) => getShapeBounds(resolveAnimatedShape(layer, state.currentTime, {
layerGroups: state.project.layerGroups,
})))

if (boundsList.length === 0) {
return state
Expand Down Expand Up @@ -2030,7 +2034,10 @@ export const useEditorStore = create<EditorStore>((set) => ({
history: pushSnapshot(state.history, createSnapshot(state.project, state.selectedLayerIds)),
})),

getAnimatedShape,
getAnimatedShape: (layer, time) => {
const { project } = get()
return resolveAnimatedShape(layer, time, { layerGroups: project.layerGroups })
},
}))

export function useActiveArtboard(): Artboard {
Expand Down
25 changes: 25 additions & 0 deletions src/editor/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,29 @@ describe('buildShapeTransform', () => {

expect(transform).toBe('translate(200 300) rotate(0) translate(0 40) scale(1.2 0.7) translate(0 -40)')
})

it('rotates paths around their visual center', () => {
const transform = buildShapeTransform({
id: 'shape-2',
type: 'path',
x: 226,
y: 364,
rotation: 90,
points: [
{ x: 0, y: 0 },
{ x: 0, y: 36 },
],
closed: false,
fill: 'none',
stroke: '#141416',
strokeWidth: 1.3,
opacity: 1,
scaleX: 1,
scaleY: 1,
})

expect(transform).toBe(
'translate(226 382) rotate(90) scale(1 1) translate(0 -18)',
)
})
})
13 changes: 10 additions & 3 deletions src/editor/transforms.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Shape } from '@/editor/types'
import { getShapeBounds } from '@/editor/bounds'

export function buildShapeTransform(shape: Shape): string {
const scaleX = shape.scaleX
Expand All @@ -19,7 +20,13 @@ export function buildShapeTransform(shape: Shape): string {
return `translate(${shape.x} ${shape.y}) rotate(${shape.rotation}) translate(0 ${shape.ry}) scale(${scaleX} ${scaleY}) translate(0 ${-shape.ry})`
}

// Imported/baked path points already live in world space. Keyframed deltas are
// decomposed as translate(x,y) → rotate → scale around the origin.
return `translate(${shape.x} ${shape.y}) rotate(${shape.rotation}) scale(${scaleX} ${scaleY})`
// Rotate paths around their visual center so imported wheel spokes stay aligned.
if (shape.type === 'path') {
const bounds = getShapeBounds(shape)
const pivotX = bounds.width / 2
const pivotY = bounds.height / 2
return `translate(${shape.x + pivotX} ${shape.y + pivotY}) rotate(${shape.rotation}) scale(${scaleX} ${scaleY}) translate(${-pivotX} ${-pivotY})`
}

return ''
}
Loading
Loading