From 86ae4dacd81728a286be8a154815c79829bee318 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Mon, 9 Mar 2026 23:32:35 +0530 Subject: [PATCH] Add groupItems() and ungroupItems() with spring animations Children converge ~25px toward combined center on group (gentle spring), then reparent into a Container. Ungroup reparents back to viewport and spreads children ~25px outward with matching animation. --- frontend/src/canvas/SceneManager.ts | 209 ++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index c2ab8f1..05407ad 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -16,6 +16,7 @@ import type { VideoObject, TextObject, GroupObject, + SceneObject, } from './scene-format'; // --------------------------------------------------------------------------- @@ -369,4 +370,212 @@ export class SceneManager { return this.items.get(data.id)!; } + + // -- Group / Ungroup with Spring Animation -------------------------------- + + /** + * Group 2+ items into a single group item. + * + * Animates children ~25px toward the combined center, then reparents them + * into a Container. Returns the newly created group SceneItem. + */ + groupItems(ids: string[]): SceneItem | null { + if (ids.length < 2) return null; + + // Collect valid children + const children: SceneItem[] = []; + for (const id of ids) { + const item = this.items.get(id); + if (item) children.push(item); + } + if (children.length < 2) return null; + + // Calculate combined bounds center + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const child of children) { + const d = child.data; + const w = ('w' in d ? (d as SceneObject).w : 0) * d.sx; + const h = ('h' in d ? (d as SceneObject).h : 0) * d.sy; + minX = Math.min(minX, d.x); + minY = Math.min(minY, d.y); + maxX = Math.max(maxX, d.x + w); + maxY = Math.max(maxY, d.y + h); + } + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const boundsW = maxX - minX; + const boundsH = maxY - minY; + + // Create the group data + const groupData: GroupObject = { + id: crypto.randomUUID(), + type: 'group', + x: cx - boundsW / 2, + y: cy - boundsH / 2, + w: boundsW, + h: boundsH, + sx: 1, + sy: 1, + angle: 0, + z: this.nextZ(), + opacity: 1, + locked: false, + name: '', + visible: true, + children: children.map((c) => c.id), + }; + + // Create a Container for the group + const container = new Container(); + container.position.set(groupData.x, groupData.y); + container.label = groupData.id; + container.cursor = 'pointer'; + container.eventMode = 'static'; + this.viewport.addChild(container); + + const groupItem: SceneItem = { + id: groupData.id, + type: 'group', + displayObject: container, + data: groupData, + }; + this.items.set(groupData.id, groupItem); + + // Animate each child ~25px toward center, then reparent into container + const CONVERGE_PX = 25; + + for (const child of children) { + const obj = child.displayObject; + const dx = cx - (child.data.x + ((child.data as SceneObject).w * child.data.sx) / 2); + const dy = cy - (child.data.y + ((child.data as SceneObject).h * child.data.sy) / 2); + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 1) continue; // already at center + + const ratio = Math.min(CONVERGE_PX / dist, 1); + const targetX = child.data.x + dx * ratio; + const targetY = child.data.y + dy * ratio; + + const startX = obj.x; + const startY = obj.y; + + // Spring animates a normalized 0→1 progress value + const spring = new Spring(0, 1, PRESETS.gentle); + const capturedChild = child; + + spring.onUpdate = (v) => { + if (!obj.destroyed) { + obj.x = startX + (targetX - startX) * v; + obj.y = startY + (targetY - startY) * v; + } + }; + + spring.onComplete = () => { + if (!obj.destroyed && !container.destroyed) { + // Reparent: adjust position relative to container + obj.x = obj.x - container.x; + obj.y = obj.y - container.y; + container.addChild(obj); + // Update stored data + capturedChild.data.x = obj.x + container.x; + capturedChild.data.y = obj.y + container.y; + } + }; + + this.springs.add(spring); + } + + this._applyZOrder(); + this._onChange?.(); + return groupItem; + } + + /** + * Ungroup a group item, releasing children back to the viewport. + * + * Animates children ~25px outward from the group center, then removes + * the group from the items map. Returns the freed children. + */ + ungroupItems(groupId: string): SceneItem[] { + const groupItem = this.items.get(groupId); + if (!groupItem || groupItem.type !== 'group') return []; + + const groupData = groupItem.data as GroupObject; + const container = groupItem.displayObject; + const freed: SceneItem[] = []; + + // Group center in world coords + const gcx = groupData.x + groupData.w / 2; + const gcy = groupData.y + groupData.h / 2; + + const SPREAD_PX = 25; + + for (const childId of groupData.children) { + const child = this.items.get(childId); + if (!child) continue; + + const obj = child.displayObject; + + // Reparent back to viewport — convert position to world coords + const worldX = obj.x + container.x; + const worldY = obj.y + container.y; + this.viewport.addChild(obj); + obj.x = worldX; + obj.y = worldY; + + // Update stored data to world coords + child.data.x = worldX; + child.data.y = worldY; + + freed.push(child); + + // Animate outward from group center + const childCX = worldX + ((child.data as SceneObject).w * child.data.sx) / 2; + const childCY = worldY + ((child.data as SceneObject).h * child.data.sy) / 2; + const dx = childCX - gcx; + const dy = childCY - gcy; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 1) continue; + + const ratio = SPREAD_PX / dist; + const offsetX = dx * ratio; + const offsetY = dy * ratio; + + const startX = obj.x; + const startY = obj.y; + const targetX = startX + offsetX; + const targetY = startY + offsetY; + + const spring = new Spring(0, 1, PRESETS.gentle); + const capturedChild = child; + + spring.onUpdate = (v) => { + if (!obj.destroyed) { + obj.x = startX + (targetX - startX) * v; + obj.y = startY + (targetY - startY) * v; + } + }; + + spring.onComplete = () => { + // Update final position in data + if (!obj.destroyed) { + capturedChild.data.x = obj.x; + capturedChild.data.y = obj.y; + } + }; + + this.springs.add(spring); + } + + // Remove group container and item + if (!container.destroyed) { + container.destroy(); + } + this.items.delete(groupId); + + this._applyZOrder(); + this._onChange?.(); + return freed; + } }