feat: add layer groups system with linked transforms and temporal shifting#18
feat: add layer groups system with linked transforms and temporal shifting#18epavanello merged 2 commits intomainfrom
Conversation
…fting Introduces a complete layer groups system that allows grouping 2+ layers into a parent container with shared transforms. Groups support: - Nested CSS transform context (children move/rotate/scale with parent) - Drag & drop layers into groups in the layers panel - Group/ungroup operations preserving world-space positions - Timeline hierarchy with indented child rows and group time bar - Temporal shifting of all children when moving the group bar - Group visibility, locking, and opacity inheritance - AI tool support (group_layers, ungroup_layers) for web + MCP - Automatic child cleanup on group deletion https://claude.ai/code/session_016GG1bhyZuuk1V8zDE3fjLF
📝 WalkthroughWalkthroughThis pull request introduces comprehensive layer grouping functionality to the animation editor. It adds support for creating and ungrouping layer containers, managing hierarchical layer relationships, rendering grouped layers with their children, and propagating group transforms and properties throughout the application's rendering and editing pipelines. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Chat as AI Chat Component
participant Executor as AI Operations<br/>(executeGroupLayers)
participant Mutation as Mutations<br/>(mutateGroupLayers)
participant Store as Project Store
participant Renderer as Layers Renderer
User->>Chat: Request to group layers
Chat->>Executor: executeGroupLayers(input)
Executor->>Mutation: mutateGroupLayers(ctx, input)
Mutation->>Store: Query existing layers & validate
Store-->>Mutation: Layer data & validation result
Mutation->>Store: Create new group layer<br/>Set parentId on children<br/>Reorder layers
Store-->>Mutation: Success with groupId
Mutation-->>Executor: GroupLayersOutput
Executor->>Store: Set selectedLayerId to groupId
Executor-->>Chat: Operation complete
Chat-->>User: Update UI
Store->>Renderer: Notify reactivity
Renderer->>Renderer: Render top-level layers<br/>+ grouped children
Renderer-->>User: Display hierarchical view
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/lib/components/editor/panels/layers-panel.svelte (1)
79-98:⚠️ Potential issue | 🟠 MajorReordering a child layer via drag-drop to a non-group target may break group–child adjacency.
When a child layer is dragged onto a regular (non-group) top-level layer, the code falls through to
reorderLayers(line 96), which simply moves the layer in the array. However, this doesn't clear the child'sparentId, so the layer remains logically parented to the group but is now physically positioned elsewhere in the array. This can cause visual inconsistencies — the layers panel will no longer show it under its group, but it will still render as a group child in the canvas.Consider either:
- Calling
removeLayerFromGroupbefore reordering when the dragged layer has aparentId, or- Preventing the drop (or ignoring it) when a child is dragged onto a non-group target.
Option 1: Unparent before reordering
// If dropping onto a group, add to that group const targetLayer = projectStore.project.layers.find((l) => l.id === targetLayerId); if (targetLayer?.type === 'group' && dragLayerId !== targetLayerId) { projectStore.addLayerToGroup(dragLayerId, targetLayerId); return; } // Otherwise, reorder if (dragIndex !== dropIndex) { + // If the dragged layer is a child, unparent it first + const dragLayer = projectStore.project.layers.find((l) => l.id === dragLayerId); + if (dragLayer?.parentId) { + projectStore.removeLayerFromGroup(dragLayerId); + // Re-read indices after structural change + return; + } projectStore.reorderLayers(dragIndex, dropIndex); }
🤖 Fix all issues with AI agents
In `@src/lib/ai/mutations.ts`:
- Around line 348-358: mutateRemoveLayer's group-removal branch currently drops
child layers without deleting their uploaded files; update the group branch in
mutateRemoveLayer (where it inspects ctx.project.layers and layer.type ===
'group') to mirror ProjectStore.removeLayer's behavior: recursively collect
child layers, for each child layer that has a fileKey issue the same DELETE file
request (or call the same file-cleanup helper used by ProjectStore.removeLayer),
then remove those children from ctx.project.layers before splicing/removing the
parent; reference ctx.project.layers, mutateRemoveLayer and fileKey to locate
where to add the cleanup calls.
- Around line 498-512: The current loop that bakes group transform into children
(iterating ctx.project.layers and checking layer.parentId === resolvedId)
naively adds gt.x/gt.y/gt.z and multiplies scales/rotations, which breaks when
group rotations/skews are present; replace this by composing full transforms:
build the group's transform matrix from gt and gs, build each child's local
transform matrix from layer.transform, multiply groupMatrix * childLocalMatrix
to get the child's world matrix, then decompose that world matrix back into
layer.transform.{x,y,z,rotationX,rotationY,rotationZ,scaleX,scaleY,scaleZ} and
set layer.parentId = undefined (keep opacity multiplication for style), and
apply the same matrix-based fix in ProjectStore.ungroupLayers
(project.svelte.ts) to preserve world positions for rotated/scaled groups.
- Around line 370-474: mutateGroupLayers creates a group layer without
enterTime/exitTime so it spans the full timeline; instead compute the group's
time range from its children's ranges: after computing childLayers (the variable
childLayers in mutateGroupLayers) calculate enterTime = min(child.enterTime ??
0) and exitTime = max(child.exitTime ?? ctx.project.duration) (or use
0/project.duration as sensible defaults), then include these enterTime/exitTime
properties on the groupLayer object before inserting it into ctx.project.layers
so the group's timebounds reflect the union of its children.
In `@src/lib/components/editor/panels/layers-panel.svelte`:
- Around line 134-142: The toolbar div renders an empty bordered area when
selectedIsGroup is false; change rendering so the entire toolbar block is
conditional on selectedIsGroup rather than always outputting the wrapper. Locate
the block that contains the <div class="mb-2 flex items-center gap-1 border-b
pb-2"> and wrap or move that div inside the {`#if` selectedIsGroup} ... {/if} (the
same condition used with handleUngroupSelected and the <Button>) so the toolbar
DOM is only emitted when selectedIsGroup is true.
In `@src/lib/stores/project.svelte.ts`:
- Around line 406-443: The removeLayerFromGroup method currently only bakes
group translation into the child's transform; update it to bake the full
transform exactly like ungroupLayers does: when building updated.transform
include rotation (add group's rotation to child's rotation, defaulting missing
values) and scale (multiply child's scale by group's scale, defaulting to 1), as
well as x/y/z translation, preserving any existing child transform properties;
use the same gt (group.transform) and gs (group.style) variables and the same
additive/multiplicative logic used in ungroupLayers to avoid visual jumps.
🧹 Nitpick comments (3)
src/lib/components/editor/timeline/timeline.svelte (1)
83-96: Duplicated hierarchy traversal between selection logic and rendering.The visible-row ordering is computed here for selection-box hit testing, and an equivalent traversal is done in the template (Lines 184-192). If these ever diverge, the selection marquee will select wrong layers. Consider extracting a shared
$derivedlist of visible rows to use in both places.♻️ Suggested approach
Define a single derived list at the top of the
<script>block:+ const visibleLayers = $derived.by(() => { + const rows: typeof projectStore.project.layers = []; + for (const layer of projectStore.project.layers) { + if (!layer.parentId) { + rows.push(layer); + if (layer.type === 'group') { + for (const child of projectStore.project.layers) { + if (child.parentId === layer.id) { + rows.push(child); + } + } + } + } + } + return rows; + });Then use
visibleLayersin both the selection logic and the{#each}rendering, eliminating the duplication and ensuring they always agree.src/lib/components/editor/panels/layers-panel.svelte (1)
276-298: Child rows are draggable but drop targets aren't group-aware for re-parenting.Children rendered here support
ondragstart/ondragover/ondrop, but dropping another layer onto a child row won't add it to the child's parent group — it attempts a reorder instead (since the child isn'ttype === 'group'). This is potentially confusing UX: dropping onto a child visually suggests adding to the same group.This is a minor UX inconsistency rather than a bug, but worth considering: you could treat drops on child rows as drops on the parent group.
src/lib/stores/project.svelte.ts (1)
214-242: RecursiveremoveLayerfor groups may issue redundant layer-array reassignments.Each recursive
await this.removeLayer(childId)at line 222 reassignsthis.project.layers(line 238). For a group with N children, this triggers N+1 full array re-creations plus N reactivity cycles. For typical group sizes this is fine, but for large groups it could be optimized by collecting all IDs to remove and doing a single filter pass.
| const layer = ctx.project.layers[index]; | ||
| const name = layer.name; | ||
|
|
||
| // If removing a group, also remove all children | ||
| if (layer.type === 'group') { | ||
| ctx.project.layers = ctx.project.layers.filter( | ||
| (l) => l.id !== resolvedId && l.parentId !== resolvedId | ||
| ); | ||
| } else { | ||
| ctx.project.layers.splice(index, 1); | ||
| } |
There was a problem hiding this comment.
Group removal in mutations doesn't clean up child layers' uploaded files.
Unlike ProjectStore.removeLayer (in project.svelte.ts, lines 214–242) which recursively removes children and issues DELETE requests for any fileKey-bearing layers, this mutation path silently drops child layers without file cleanup. If mutateRemoveLayer is invoked via the AI/MCP pathway for a group that contains video/image/audio layers with uploaded files, those files will become orphaned.
This may be acceptable if file cleanup is only expected through the UI path, but worth tracking.
🤖 Prompt for AI Agents
In `@src/lib/ai/mutations.ts` around lines 348 - 358, mutateRemoveLayer's
group-removal branch currently drops child layers without deleting their
uploaded files; update the group branch in mutateRemoveLayer (where it inspects
ctx.project.layers and layer.type === 'group') to mirror
ProjectStore.removeLayer's behavior: recursively collect child layers, for each
child layer that has a fileKey issue the same DELETE file request (or call the
same file-cleanup helper used by ProjectStore.removeLayer), then remove those
children from ctx.project.layers before splicing/removing the parent; reference
ctx.project.layers, mutateRemoveLayer and fileKey to locate where to add the
cleanup calls.
| export function mutateGroupLayers( | ||
| ctx: MutationContext, | ||
| input: GroupLayersInput | ||
| ): GroupLayersOutput { | ||
| try { | ||
| const resolvedIds: string[] = []; | ||
| for (const ref of input.layerIds) { | ||
| const id = resolveLayerId(ctx.project, ref, ctx.layerIdMap); | ||
| if (!id) { | ||
| return { | ||
| success: false, | ||
| message: layerNotFoundError(ctx.project, ref), | ||
| error: `Layer "${ref}" not found` | ||
| }; | ||
| } | ||
| resolvedIds.push(id); | ||
| } | ||
|
|
||
| if (resolvedIds.length < 2) { | ||
| return { | ||
| success: false, | ||
| message: 'Need at least 2 layers to create a group', | ||
| error: 'Insufficient layers' | ||
| }; | ||
| } | ||
|
|
||
| // Validate none are already in a group or are groups themselves | ||
| for (const id of resolvedIds) { | ||
| const layer = ctx.project.layers.find((l) => l.id === id); | ||
| if (layer?.parentId) { | ||
| return { | ||
| success: false, | ||
| message: `Layer "${layer.name}" is already in a group`, | ||
| error: 'Layer already grouped' | ||
| }; | ||
| } | ||
| if (layer?.type === 'group') { | ||
| return { | ||
| success: false, | ||
| message: `Cannot nest group "${layer.name}" inside another group`, | ||
| error: 'Cannot nest groups' | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| const groupId = nanoid(); | ||
|
|
||
| // Create the group layer | ||
| const groupLayer = { | ||
| id: groupId, | ||
| name: input.name ?? 'Group', | ||
| type: 'group' as const, | ||
| transform: { | ||
| x: 0, | ||
| y: 0, | ||
| z: 0, | ||
| rotationX: 0, | ||
| rotationY: 0, | ||
| rotationZ: 0, | ||
| scaleX: 1, | ||
| scaleY: 1, | ||
| scaleZ: 1, | ||
| anchor: 'center' as const | ||
| }, | ||
| style: { opacity: 1 }, | ||
| visible: true, | ||
| locked: false, | ||
| keyframes: [], | ||
| props: { collapsed: false } | ||
| }; | ||
|
|
||
| // Find insertion point (earliest child position) | ||
| const childIdSet = new Set(resolvedIds); | ||
| const indices = ctx.project.layers | ||
| .map((l, i) => (childIdSet.has(l.id) ? i : -1)) | ||
| .filter((i) => i >= 0) | ||
| .sort((a, b) => a - b); | ||
| const insertIndex = indices[0]; | ||
|
|
||
| // Set parentId on children | ||
| for (const layer of ctx.project.layers) { | ||
| if (childIdSet.has(layer.id)) { | ||
| layer.parentId = groupId; | ||
| } | ||
| } | ||
|
|
||
| // Move children out, insert group + children at the right position | ||
| const childLayers = ctx.project.layers.filter((l) => childIdSet.has(l.id)); | ||
| const otherLayers = ctx.project.layers.filter((l) => !childIdSet.has(l.id)); | ||
| otherLayers.splice(insertIndex, 0, groupLayer, ...childLayers); | ||
| ctx.project.layers = otherLayers; | ||
|
|
||
| return { | ||
| success: true, | ||
| groupId, | ||
| message: `Created group "${groupLayer.name}" with ${resolvedIds.length} layers` | ||
| }; | ||
| } catch (err) { | ||
| return { | ||
| success: false, | ||
| message: 'Failed to create group', | ||
| error: err instanceof Error ? err.message : 'Unknown error' | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n 'enterTime|exitTime' --type=ts --type=tsx --type=svelte -B 2 -A 2 | head -100Repository: epavanello/devmotion
Length of output: 91
🏁 Script executed:
rg -n 'type.*Layer|interface.*Layer' --type=ts --type=tsx | head -30Repository: epavanello/devmotion
Length of output: 91
🏁 Script executed:
rg -n 'group.*render|render.*group' --type=svelte -B 3 -A 3 | head -80Repository: epavanello/devmotion
Length of output: 94
🏁 Script executed:
rg -n 'parentId.*group\|group.*children' --type=ts --type=tsx -B 2 -A 2 | head -60Repository: epavanello/devmotion
Length of output: 91
🏁 Script executed:
rg 'enterTime|exitTime' -B 2 -A 2 | head -120Repository: epavanello/devmotion
Length of output: 6955
🏁 Script executed:
rg 'type Layer|interface Layer' -B 1 -A 15 | head -100Repository: epavanello/devmotion
Length of output: 6024
🏁 Script executed:
find . -name '*.svelte' -type f | xargs grep -l 'group' | head -5Repository: epavanello/devmotion
Length of output: 375
🏁 Script executed:
rg 'mutateGroupLayers' -B 3 -A 3Repository: epavanello/devmotion
Length of output: 2178
🏁 Script executed:
rg 'group.*bar\|bar.*group\|renderGroup\|TimelineTrack' -B 2 -A 5 | head -100Repository: epavanello/devmotion
Length of output: 46
🏁 Script executed:
rg 'createLayer' -B 2 -A 10 | grep -A 10 'export\|function createLayer' | head -60Repository: epavanello/devmotion
Length of output: 784
🏁 Script executed:
rg 'type.*"group"\|type === .*group' src/lib/ai/mutations.ts -B 3 -A 10 | head -80Repository: epavanello/devmotion
Length of output: 46
🏁 Script executed:
find . -name '*timeline*' -o -name '*time-track*' | head -10Repository: epavanello/devmotion
Length of output: 395
🏁 Script executed:
cat src/lib/components/editor/timeline/timeline-layer.svelte | head -150Repository: epavanello/devmotion
Length of output: 4809
🏁 Script executed:
rg 'enterTime|exitTime' src/lib/components/editor/timeline/ -B 2 -A 2Repository: epavanello/devmotion
Length of output: 4204
🏁 Script executed:
cat src/lib/engine/layer-factory.ts | head -100Repository: epavanello/devmotion
Length of output: 2376
mutateGroupLayers — group layer missing enterTime/exitTime will span full timeline by default.
The group layer created at lines 418–438 doesn't include enterTime or exitTime, so the timeline will render it with the default fallback values (0 and project.duration), making it span the entire timeline. This appears intentional based on the optional schema design, but if the group's time range should instead reflect the union of its children's ranges, that computation is missing.
The insertion-at-earliest-child-position logic is correct: removing children before reinsertion prevents index drift.
🤖 Prompt for AI Agents
In `@src/lib/ai/mutations.ts` around lines 370 - 474, mutateGroupLayers creates a
group layer without enterTime/exitTime so it spans the full timeline; instead
compute the group's time range from its children's ranges: after computing
childLayers (the variable childLayers in mutateGroupLayers) calculate enterTime
= min(child.enterTime ?? 0) and exitTime = max(child.exitTime ??
ctx.project.duration) (or use 0/project.duration as sensible defaults), then
include these enterTime/exitTime properties on the groupLayer object before
inserting it into ctx.project.layers so the group's timebounds reflect the union
of its children.
| // Bake group transform into children and remove parentId | ||
| for (const layer of ctx.project.layers) { | ||
| if (layer.parentId === resolvedId) { | ||
| layer.parentId = undefined; | ||
| layer.transform.x += gt.x; | ||
| layer.transform.y += gt.y; | ||
| layer.transform.z += gt.z; | ||
| layer.transform.rotationX += gt.rotationX; | ||
| layer.transform.rotationY += gt.rotationY; | ||
| layer.transform.rotationZ += gt.rotationZ; | ||
| layer.transform.scaleX *= gt.scaleX; | ||
| layer.transform.scaleY *= gt.scaleY; | ||
| layer.transform.scaleZ *= gt.scaleZ; | ||
| layer.style.opacity *= gs.opacity; | ||
| } |
There was a problem hiding this comment.
Transform baking on ungroup is a simplification that won't preserve world positions with non-trivial group transforms.
The current approach adds translations and rotations, and multiplies scales. This is only correct when the group transform is purely translational (or the identity). If the group has rotation, child positions are in the group's rotated coordinate space, and simply adding gt.x/gt.y to the child's translation won't yield the correct world position — you'd need to apply the group's rotation matrix to the child's local offset first.
The same simplification exists in ProjectStore.ungroupLayers (project.svelte.ts, lines 335–361), so at least the behavior is consistent across code paths. However, users who rotate a group and then ungroup will see children jump to incorrect positions.
This could be addressed later by composing transformation matrices, but flagging it as a known limitation.
🤖 Prompt for AI Agents
In `@src/lib/ai/mutations.ts` around lines 498 - 512, The current loop that bakes
group transform into children (iterating ctx.project.layers and checking
layer.parentId === resolvedId) naively adds gt.x/gt.y/gt.z and multiplies
scales/rotations, which breaks when group rotations/skews are present; replace
this by composing full transforms: build the group's transform matrix from gt
and gs, build each child's local transform matrix from layer.transform, multiply
groupMatrix * childLocalMatrix to get the child's world matrix, then decompose
that world matrix back into
layer.transform.{x,y,z,rotationX,rotationY,rotationZ,scaleX,scaleY,scaleZ} and
set layer.parentId = undefined (keep opacity multiplication for style), and
apply the same matrix-based fix in ProjectStore.ungroupLayers
(project.svelte.ts) to preserve world positions for rotated/scaled groups.
| <!-- Group / Ungroup toolbar --> | ||
| <div class="mb-2 flex items-center gap-1 border-b pb-2"> | ||
| {#if selectedIsGroup} | ||
| <Button variant="ghost" size="sm" class="h-7 gap-1 text-xs" onclick={handleUngroupSelected}> | ||
| <Ungroup class="size-3.5" /> | ||
| Ungroup | ||
| </Button> | ||
| {/if} | ||
| </div> |
There was a problem hiding this comment.
Empty toolbar div when no group is selected.
When selectedIsGroup is false, the toolbar <div> at line 135 renders with just mb-2 border-b pb-2 but no content — leaving a visible empty border/margin. Consider conditionally rendering the entire toolbar.
Suggested fix
- <div class="mb-2 flex items-center gap-1 border-b pb-2">
- {`#if` selectedIsGroup}
+ {`#if` selectedIsGroup}
+ <div class="mb-2 flex items-center gap-1 border-b pb-2">
<Button variant="ghost" size="sm" class="h-7 gap-1 text-xs" onclick={handleUngroupSelected}>
<Ungroup class="size-3.5" />
Ungroup
</Button>
- {/if}
- </div>
+ </div>
+ {/if}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <!-- Group / Ungroup toolbar --> | |
| <div class="mb-2 flex items-center gap-1 border-b pb-2"> | |
| {#if selectedIsGroup} | |
| <Button variant="ghost" size="sm" class="h-7 gap-1 text-xs" onclick={handleUngroupSelected}> | |
| <Ungroup class="size-3.5" /> | |
| Ungroup | |
| </Button> | |
| {/if} | |
| </div> | |
| <!-- Group / Ungroup toolbar --> | |
| {`#if` selectedIsGroup} | |
| <div class="mb-2 flex items-center gap-1 border-b pb-2"> | |
| <Button variant="ghost" size="sm" class="h-7 gap-1 text-xs" onclick={handleUngroupSelected}> | |
| <Ungroup class="size-3.5" /> | |
| Ungroup | |
| </Button> | |
| </div> | |
| {/if} |
🤖 Prompt for AI Agents
In `@src/lib/components/editor/panels/layers-panel.svelte` around lines 134 - 142,
The toolbar div renders an empty bordered area when selectedIsGroup is false;
change rendering so the entire toolbar block is conditional on selectedIsGroup
rather than always outputting the wrapper. Locate the block that contains the
<div class="mb-2 flex items-center gap-1 border-b pb-2"> and wrap or move that
div inside the {`#if` selectedIsGroup} ... {/if} (the same condition used with
handleUngroupSelected and the <Button>) so the toolbar DOM is only emitted when
selectedIsGroup is true.
| removeLayerFromGroup(layerId: string) { | ||
| const layer = this.project.layers.find((l) => l.id === layerId); | ||
| if (!layer?.parentId) return; | ||
|
|
||
| const groupId = layer.parentId; | ||
| const group = this.project.layers.find((l) => l.id === groupId); | ||
|
|
||
| const gt = group?.transform; | ||
| const gs = group?.style; | ||
|
|
||
| this.project.layers = this.project.layers.map((l) => { | ||
| if (l.id === layerId) { | ||
| const updated: TypedLayer = { ...l, parentId: undefined }; | ||
| if (gt) { | ||
| updated.transform = { | ||
| ...l.transform, | ||
| x: l.transform.x + gt.x, | ||
| y: l.transform.y + gt.y, | ||
| z: l.transform.z + gt.z | ||
| }; | ||
| } | ||
| if (gs) { | ||
| updated.style = { | ||
| ...l.style, | ||
| opacity: l.style.opacity * gs.opacity | ||
| }; | ||
| } | ||
| return updated; | ||
| } | ||
| return l; | ||
| }); | ||
|
|
||
| // If group has fewer than 2 children, dissolve it | ||
| const remaining = this.project.layers.filter((l) => l.parentId === groupId); | ||
| if (remaining.length < 2 && group) { | ||
| this.ungroupLayers(groupId); | ||
| } | ||
| } |
There was a problem hiding this comment.
removeLayerFromGroup only bakes translation — missing rotation and scale, inconsistent with ungroupLayers.
When removing a single child from a group, lines 420–425 only apply the group's translation (x, y, z). However, ungroupLayers (lines 341–351) also bakes rotation (additive) and scale (multiplicative). If the group has non-identity rotation or scale, a child removed via this method will jump to a different visual position/size.
Proposed fix: bake full transform consistently
if (l.id === layerId) {
const updated: TypedLayer = { ...l, parentId: undefined };
if (gt) {
updated.transform = {
...l.transform,
x: l.transform.x + gt.x,
y: l.transform.y + gt.y,
- z: l.transform.z + gt.z
+ z: l.transform.z + gt.z,
+ rotationX: l.transform.rotationX + gt.rotationX,
+ rotationY: l.transform.rotationY + gt.rotationY,
+ rotationZ: l.transform.rotationZ + gt.rotationZ,
+ scaleX: l.transform.scaleX * gt.scaleX,
+ scaleY: l.transform.scaleY * gt.scaleY,
+ scaleZ: l.transform.scaleZ * gt.scaleZ
};
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| removeLayerFromGroup(layerId: string) { | |
| const layer = this.project.layers.find((l) => l.id === layerId); | |
| if (!layer?.parentId) return; | |
| const groupId = layer.parentId; | |
| const group = this.project.layers.find((l) => l.id === groupId); | |
| const gt = group?.transform; | |
| const gs = group?.style; | |
| this.project.layers = this.project.layers.map((l) => { | |
| if (l.id === layerId) { | |
| const updated: TypedLayer = { ...l, parentId: undefined }; | |
| if (gt) { | |
| updated.transform = { | |
| ...l.transform, | |
| x: l.transform.x + gt.x, | |
| y: l.transform.y + gt.y, | |
| z: l.transform.z + gt.z | |
| }; | |
| } | |
| if (gs) { | |
| updated.style = { | |
| ...l.style, | |
| opacity: l.style.opacity * gs.opacity | |
| }; | |
| } | |
| return updated; | |
| } | |
| return l; | |
| }); | |
| // If group has fewer than 2 children, dissolve it | |
| const remaining = this.project.layers.filter((l) => l.parentId === groupId); | |
| if (remaining.length < 2 && group) { | |
| this.ungroupLayers(groupId); | |
| } | |
| } | |
| removeLayerFromGroup(layerId: string) { | |
| const layer = this.project.layers.find((l) => l.id === layerId); | |
| if (!layer?.parentId) return; | |
| const groupId = layer.parentId; | |
| const group = this.project.layers.find((l) => l.id === groupId); | |
| const gt = group?.transform; | |
| const gs = group?.style; | |
| this.project.layers = this.project.layers.map((l) => { | |
| if (l.id === layerId) { | |
| const updated: TypedLayer = { ...l, parentId: undefined }; | |
| if (gt) { | |
| updated.transform = { | |
| ...l.transform, | |
| x: l.transform.x + gt.x, | |
| y: l.transform.y + gt.y, | |
| z: l.transform.z + gt.z, | |
| rotationX: l.transform.rotationX + gt.rotationX, | |
| rotationY: l.transform.rotationY + gt.rotationY, | |
| rotationZ: l.transform.rotationZ + gt.rotationZ, | |
| scaleX: l.transform.scaleX * gt.scaleX, | |
| scaleY: l.transform.scaleY * gt.scaleY, | |
| scaleZ: l.transform.scaleZ * gt.scaleZ | |
| }; | |
| } | |
| if (gs) { | |
| updated.style = { | |
| ...l.style, | |
| opacity: l.style.opacity * gs.opacity | |
| }; | |
| } | |
| return updated; | |
| } | |
| return l; | |
| }); | |
| // If group has fewer than 2 children, dissolve it | |
| const remaining = this.project.layers.filter((l) => l.parentId === groupId); | |
| if (remaining.length < 2 && group) { | |
| this.ungroupLayers(groupId); | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@src/lib/stores/project.svelte.ts` around lines 406 - 443, The
removeLayerFromGroup method currently only bakes group translation into the
child's transform; update it to bake the full transform exactly like
ungroupLayers does: when building updated.transform include rotation (add
group's rotation to child's rotation, defaulting missing values) and scale
(multiply child's scale by group's scale, defaulting to 1), as well as x/y/z
translation, preserving any existing child transform properties; use the same gt
(group.transform) and gs (group.style) variables and the same
additive/multiplicative logic used in ungroupLayers to avoid visual jumps.
Introduces a complete layer groups system that allows grouping 2+ layers
into a parent container with shared transforms. Groups support:
https://claude.ai/code/session_016GG1bhyZuuk1V8zDE3fjLF
Summary by CodeRabbit
Release Notes
New Features
Enhancements