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.
This commit is contained in:
@@ -16,6 +16,7 @@ import type {
|
|||||||
VideoObject,
|
VideoObject,
|
||||||
TextObject,
|
TextObject,
|
||||||
GroupObject,
|
GroupObject,
|
||||||
|
SceneObject,
|
||||||
} from './scene-format';
|
} from './scene-format';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -369,4 +370,212 @@ export class SceneManager {
|
|||||||
|
|
||||||
return this.items.get(data.id)!;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user