Skip to content

Comments

feat: add layer groups system with linked transforms and temporal shifting#18

Merged
epavanello merged 2 commits intomainfrom
claude/layer-groups-system-KJXrp
Feb 13, 2026
Merged

feat: add layer groups system with linked transforms and temporal shifting#18
epavanello merged 2 commits intomainfrom
claude/layer-groups-system-KJXrp

Conversation

@epavanello
Copy link
Owner

@epavanello epavanello commented Feb 12, 2026

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

Summary by CodeRabbit

Release Notes

  • New Features

    • Added layer grouping functionality—combine multiple layers into collapsible groups.
    • Introduced drag-and-drop support to reorder layers and move them into groups.
    • AI now supports grouping and ungrouping operations via new commands.
    • Hierarchical layer display in layers panel and timeline with visual organization and indentation.
    • Group collapse/expand controls for improved workspace organization.
  • Enhancements

    • Improved layer hierarchy visualization with parent-child relationships.

…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
@coderabbitai
Copy link

coderabbitai bot commented Feb 12, 2026

📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
AI Operations & Mutations
src/lib/ai/ai-operations.svelte.ts, src/lib/ai/mutations.ts, src/lib/ai/schemas.ts
Adds AI-facing group/ungroup executors and mutations with validation, parent-child relationship management, group transform insertion, and structured error handling. Excludes group type from per-layer tool generation and exports new tools group_layers and ungroup_layers.
AI Chat Integration
src/lib/components/ai/ai-chat.svelte
Wires new executeGroupLayers and executeUngroupLayers executors into tool dispatch logic for AI chat interactions.
Layer Rendering & Panels
src/lib/components/editor/canvas/layers-renderer.svelte, src/lib/components/editor/panels/layers-panel.svelte
Implements two-tier hierarchical rendering of top-level layers and group children, adds group transform/visibility propagation, introduces group collapse state, drag-and-drop group nesting, and group-specific UI controls (collapse/expand, ungroup).
Timeline Visualization
src/lib/components/editor/timeline/timeline.svelte, src/lib/components/editor/timeline/timeline-layer.svelte
Updates timeline to render grouped children as indented rows, adds group-specific bar rendering, and supports visual hierarchy with indent-based layout.
Layer Type System
src/lib/layers/components/GroupLayer.svelte, src/lib/layers/typed-registry.ts, src/lib/schemas/base.ts
Introduces new group layer type with metadata and schema, registers in type system, and adds optional parentId field to base layer schema for parent-child relationships.
Project Store
src/lib/stores/project.svelte.ts
Adds comprehensive group management API: layer hierarchy queries (getChildLayers, getTopLevelLayers, getParentGroup), group operations (createGroup, ungroupLayers, addLayerToGroup, removeLayerFromGroup), and time-shift logic for maintaining group child alignment during edits.
MCP Server Handler
src/routes/mcp/+server.ts
Extends mutation dispatch to handle new group_layers and ungroup_layers tools, delegating to corresponding mutation functions.
Minor Formatting
src/lib/components/editor/VideoRecorder.svelte
Non-functional line-wrapping and layout adjustments.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 A group is born, nested and neat,
Children gathered where transforms meet,
Hierarchies spring from simple code,
Timeline layers take a new road,
Ungrouping too—flexibility complete!

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature: a comprehensive layer groups system with linked transforms and temporal shifting. It is specific, concise, and clearly represents the primary change.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/layer-groups-system-KJXrp

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Reordering 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's parentId, 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:

  1. Calling removeLayerFromGroup before reordering when the dragged layer has a parentId, or
  2. 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 $derived list 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 visibleLayers in 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't type === '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: Recursive removeLayer for groups may issue redundant layer-array reassignments.

Each recursive await this.removeLayer(childId) at line 222 reassigns this.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.

Comment on lines +348 to +358
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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +370 to +474
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'
};
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n 'enterTime|exitTime' --type=ts --type=tsx --type=svelte -B 2 -A 2 | head -100

Repository: epavanello/devmotion

Length of output: 91


🏁 Script executed:

rg -n 'type.*Layer|interface.*Layer' --type=ts --type=tsx | head -30

Repository: epavanello/devmotion

Length of output: 91


🏁 Script executed:

rg -n 'group.*render|render.*group' --type=svelte -B 3 -A 3 | head -80

Repository: epavanello/devmotion

Length of output: 94


🏁 Script executed:

rg -n 'parentId.*group\|group.*children' --type=ts --type=tsx -B 2 -A 2 | head -60

Repository: epavanello/devmotion

Length of output: 91


🏁 Script executed:

rg 'enterTime|exitTime' -B 2 -A 2 | head -120

Repository: epavanello/devmotion

Length of output: 6955


🏁 Script executed:

rg 'type Layer|interface Layer' -B 1 -A 15 | head -100

Repository: epavanello/devmotion

Length of output: 6024


🏁 Script executed:

find . -name '*.svelte' -type f | xargs grep -l 'group' | head -5

Repository: epavanello/devmotion

Length of output: 375


🏁 Script executed:

rg 'mutateGroupLayers' -B 3 -A 3

Repository: epavanello/devmotion

Length of output: 2178


🏁 Script executed:

rg 'group.*bar\|bar.*group\|renderGroup\|TimelineTrack' -B 2 -A 5 | head -100

Repository: epavanello/devmotion

Length of output: 46


🏁 Script executed:

rg 'createLayer' -B 2 -A 10 | grep -A 10 'export\|function createLayer' | head -60

Repository: epavanello/devmotion

Length of output: 784


🏁 Script executed:

rg 'type.*"group"\|type === .*group' src/lib/ai/mutations.ts -B 3 -A 10 | head -80

Repository: epavanello/devmotion

Length of output: 46


🏁 Script executed:

find . -name '*timeline*' -o -name '*time-track*' | head -10

Repository: epavanello/devmotion

Length of output: 395


🏁 Script executed:

cat src/lib/components/editor/timeline/timeline-layer.svelte | head -150

Repository: epavanello/devmotion

Length of output: 4809


🏁 Script executed:

rg 'enterTime|exitTime' src/lib/components/editor/timeline/ -B 2 -A 2

Repository: epavanello/devmotion

Length of output: 4204


🏁 Script executed:

cat src/lib/engine/layer-factory.ts | head -100

Repository: 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.

Comment on lines +498 to +512
// 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;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +134 to +142
<!-- 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>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
<!-- 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.

Comment on lines +406 to +443
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);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

@epavanello epavanello merged commit 2d538de into main Feb 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants