859 lines
26 KiB
TypeScript
859 lines
26 KiB
TypeScript
/**
|
|
* SceneManager — central object model replacing Fabric.js canvas object management.
|
|
*
|
|
* Owns the SceneItem map, handles diff-based scene loading, z-ordering,
|
|
* and spring-animated add/remove operations.
|
|
*/
|
|
|
|
import { Container } from 'pixi.js';
|
|
import type { Viewport } from 'pixi-viewport';
|
|
import { TextureManager } from './TextureManager';
|
|
import { ImageSprite } from './sprites/ImageSprite';
|
|
import { AnimatedGifSprite } from './sprites/AnimatedGifSprite';
|
|
import { VideoSprite } from './sprites/VideoSprite';
|
|
import { DrawingSprite } from './sprites/DrawingSprite';
|
|
import { FrameSprite } from './sprites/FrameSprite';
|
|
import { StickySprite } from './sprites/StickySprite';
|
|
import { TextSprite } from './sprites/TextSprite';
|
|
import { SpringManager, Spring, PRESETS } from './spring';
|
|
import { reparentGroupChildren } from './grouping';
|
|
import { SpatialGrid } from './SpatialGrid';
|
|
import type {
|
|
SceneData,
|
|
AnySceneObject,
|
|
ImageObject,
|
|
VideoObject,
|
|
TextObject,
|
|
DrawingObject,
|
|
GroupObject,
|
|
StickyObject,
|
|
SceneObject,
|
|
} from './scene-format';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GIF detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Check if an asset key points to a GIF file.
|
|
* Strips query strings and fragments before checking extension. */
|
|
function isGifAsset(asset: string): boolean {
|
|
// Strip query string and fragment
|
|
const clean = asset.split('?')[0].split('#')[0];
|
|
return clean.toLowerCase().endsWith('.gif');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SceneItem
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface SceneItem {
|
|
id: string;
|
|
type: 'image' | 'video' | 'text' | 'drawing' | 'group' | 'sticky';
|
|
displayObject: Container;
|
|
data: AnySceneObject;
|
|
}
|
|
|
|
/** Set of child IDs that belong to a group — rebuilt when groups change. */
|
|
let _groupChildIds: Set<string> | null = null;
|
|
|
|
/** Rebuild the set of all group-child IDs. Call after group add/remove/load. */
|
|
export function rebuildGroupChildSet(scene: SceneManager): void {
|
|
_groupChildIds = new Set<string>();
|
|
for (const item of scene.items.values()) {
|
|
if (item.data.type !== 'group') continue;
|
|
const gd = item.data as GroupObject;
|
|
for (const cid of gd.children) _groupChildIds.add(cid);
|
|
}
|
|
}
|
|
|
|
/** Returns true if the item is a child of a group (not independently selectable). */
|
|
export function isGroupChild(id: string): boolean {
|
|
return _groupChildIds?.has(id) ?? false;
|
|
}
|
|
|
|
/** Single source of truth for an item's world-space bounding rect.
|
|
* Uses data.sx/sy (not obj.scale which may be mid-animation).
|
|
* For groups: computes the union of children bounds (w/h on group data may be 0).
|
|
* For group children: converts local coords to world using parent group transforms. */
|
|
export function getItemWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
|
|
if (item.data.type === 'group') {
|
|
return _getGroupWorldBounds(item);
|
|
}
|
|
|
|
// If this item is a child of a group, convert local → world
|
|
const parent = item.displayObject.parent;
|
|
if (parent && parent.label && _groupChildIds?.has(item.id)) {
|
|
const px = parent.position.x;
|
|
const py = parent.position.y;
|
|
const psx = parent.scale.x;
|
|
const psy = parent.scale.y;
|
|
return {
|
|
x: px + item.data.x * psx,
|
|
y: py + item.data.y * psy,
|
|
w: item.data.w * Math.abs(item.data.sx * psx),
|
|
h: item.data.h * Math.abs(item.data.sy * psy),
|
|
};
|
|
}
|
|
|
|
return {
|
|
x: item.data.x,
|
|
y: item.data.y,
|
|
w: item.data.w * Math.abs(item.data.sx),
|
|
h: item.data.h * Math.abs(item.data.sy),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compute group bounds from its data.w/h (set during grouping) or fall back
|
|
* to stored children data. Children store LOCAL x/y relative to group.
|
|
*/
|
|
function _getGroupWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
|
|
const groupX = item.data.x;
|
|
const groupY = item.data.y;
|
|
const groupW = item.data.w;
|
|
const groupH = item.data.h;
|
|
|
|
// If group has valid w/h (set during creation), apply scale just like regular items
|
|
if (groupW > 0 && groupH > 0) {
|
|
return {
|
|
x: groupX,
|
|
y: groupY,
|
|
w: groupW * Math.abs(item.data.sx),
|
|
h: groupH * Math.abs(item.data.sy),
|
|
};
|
|
}
|
|
|
|
// Fallback: shouldn't happen, but compute from children display objects
|
|
const container = item.displayObject;
|
|
if (container.children.length === 0) {
|
|
return { x: groupX, y: groupY, w: 0, h: 0 };
|
|
}
|
|
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const child of container.children) {
|
|
const cx = child.x;
|
|
const cy = child.y;
|
|
// Approximate child size from its local bounds
|
|
const lb = child.getLocalBounds();
|
|
const cw = lb.width;
|
|
const ch = lb.height;
|
|
minX = Math.min(minX, cx);
|
|
minY = Math.min(minY, cy);
|
|
maxX = Math.max(maxX, cx + cw);
|
|
maxY = Math.max(maxY, cy + ch);
|
|
}
|
|
|
|
if (!isFinite(minX)) return { x: groupX, y: groupY, w: 0, h: 0 };
|
|
// Children positions are local to group, so offset by group world position
|
|
return { x: groupX + minX, y: groupY + minY, w: maxX - minX, h: maxY - minY };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SceneManager
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class SceneManager {
|
|
readonly items: Map<string, SceneItem> = new Map();
|
|
readonly viewport: Viewport;
|
|
readonly textures: TextureManager;
|
|
readonly springs: SpringManager;
|
|
readonly spatialGrid: SpatialGrid<SceneItem> = new SpatialGrid<SceneItem>(512);
|
|
|
|
private _onChange: (() => void) | null = null;
|
|
private _onItemDimensionsChanged: ((itemId: string) => void) | null = null;
|
|
private _zCounter: number = 0;
|
|
|
|
constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) {
|
|
this.viewport = viewport;
|
|
this.textures = textures;
|
|
this.springs = springs;
|
|
}
|
|
|
|
/** Update an item's spatial index entry from its current data. */
|
|
updateSpatialEntry(item: SceneItem): void {
|
|
const { x, y, w, h } = getItemWorldBounds(item);
|
|
this.spatialGrid.upsert(item.id, item, x, y, w, h);
|
|
}
|
|
|
|
/** Query items overlapping the given world rectangle. */
|
|
queryRegion(rx: number, ry: number, rw: number, rh: number): SceneItem[] {
|
|
return this.spatialGrid.query(rx, ry, rw, rh).map(e => e.data);
|
|
}
|
|
|
|
// -- onChange callback ----------------------------------------------------
|
|
|
|
set onChange(fn: (() => void) | null) {
|
|
this._onChange = fn;
|
|
}
|
|
|
|
set onItemDimensionsChanged(fn: ((itemId: string) => void) | null) {
|
|
this._onItemDimensionsChanged = fn;
|
|
}
|
|
|
|
get onChange(): (() => void) | null {
|
|
return this._onChange;
|
|
}
|
|
|
|
// -- Serialization -------------------------------------------------------
|
|
|
|
/** Convert all items to v2 scene format, sorted by z. */
|
|
serialize(): SceneData {
|
|
const objects = Array.from(this.items.values())
|
|
.sort((a, b) => a.data.z - b.data.z)
|
|
.map((item) => ({ ...item.data }));
|
|
|
|
return {
|
|
v: 2,
|
|
bg: '',
|
|
objects,
|
|
};
|
|
}
|
|
|
|
// -- Scene Loading (diff-based) ------------------------------------------
|
|
|
|
/**
|
|
* Load a scene using diff-based reconciliation:
|
|
* 1. Remove items not present in incoming data
|
|
* 2. Update existing items with new properties
|
|
* 3. Create new items
|
|
* 4. Apply z-ordering
|
|
*/
|
|
async loadScene(scene: SceneData, _animate?: boolean): Promise<void> {
|
|
const incomingById = new Map<string, AnySceneObject>();
|
|
for (const obj of scene.objects) {
|
|
incomingById.set(obj.id, obj);
|
|
}
|
|
|
|
// 1. Remove items not in incoming set
|
|
const toRemove: string[] = [];
|
|
for (const id of this.items.keys()) {
|
|
if (!incomingById.has(id)) {
|
|
toRemove.push(id);
|
|
}
|
|
}
|
|
for (const id of toRemove) {
|
|
const item = this.items.get(id)!;
|
|
|
|
// If removing a group, reparent its PixiJS children to viewport first
|
|
// so they survive the container destruction (they may still be in the
|
|
// incoming set as ungrouped top-level items).
|
|
if (item.data.type === 'group') {
|
|
const container = item.displayObject;
|
|
// Reparent actual scene children (skip internal frame bg/label)
|
|
const toReparent = container.children.filter(
|
|
(c) => !c.label?.startsWith('__frame_'),
|
|
);
|
|
for (const child of toReparent) {
|
|
this.viewport.addChild(child);
|
|
}
|
|
}
|
|
|
|
item.displayObject.destroy({ children: true });
|
|
this.spatialGrid.remove(id);
|
|
this.items.delete(id);
|
|
}
|
|
|
|
// 2 & 3. Update existing, create new
|
|
const loadPromises: Promise<void>[] = [];
|
|
|
|
for (const data of scene.objects) {
|
|
const existing = this.items.get(data.id);
|
|
if (existing) {
|
|
this._updateItem(existing, data);
|
|
} else {
|
|
loadPromises.push(this._createItem(data));
|
|
}
|
|
|
|
// Track highest z for nextZ()
|
|
if (data.z >= this._zCounter) {
|
|
this._zCounter = data.z + 1;
|
|
}
|
|
}
|
|
|
|
await Promise.all(loadPromises);
|
|
|
|
// 4. Reparent group children into their group containers
|
|
reparentGroupChildren(this);
|
|
rebuildGroupChildSet(this);
|
|
|
|
// 5. Apply z-ordering
|
|
this._applyZOrder();
|
|
|
|
this._onChange?.();
|
|
}
|
|
|
|
// -- Item Creation -------------------------------------------------------
|
|
|
|
/** Create a PixiJS display object from scene data and add it to the viewport. */
|
|
async _createItem(data: AnySceneObject, _animate?: boolean): Promise<void> {
|
|
let displayObject: Container;
|
|
|
|
switch (data.type) {
|
|
case 'image': {
|
|
const imgData = data as ImageObject;
|
|
if (isGifAsset(imgData.asset)) {
|
|
displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
|
} else {
|
|
const imgSprite = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
|
if (imgData.crop) imgSprite.applyCrop(imgData.crop);
|
|
displayObject = imgSprite;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'video': {
|
|
const vidData = data as VideoObject;
|
|
const videoUrl = this.textures.urlForAsset(vidData.asset);
|
|
// Use cached native dimensions if available (avoids re-init just to discover size)
|
|
const vw = vidData.nativeW || vidData.w;
|
|
const vh = vidData.nativeH || vidData.h;
|
|
const videoSprite = new VideoSprite(vidData.asset, vw, vh, videoUrl, vidData.poster, this.textures);
|
|
// Apply cached native dims to scene data so display matches
|
|
if (vidData.nativeW && vidData.nativeH) {
|
|
data.w = vw;
|
|
data.h = vh;
|
|
}
|
|
// Auto-correct dimensions when video metadata loads
|
|
// NOTE: callback must update item.data (the stored copy), not the original `data` param.
|
|
// We wire this after the item is created below (see post-creation video wiring).
|
|
displayObject = videoSprite;
|
|
break;
|
|
}
|
|
|
|
case 'text': {
|
|
displayObject = new TextSprite(data as TextObject);
|
|
break;
|
|
}
|
|
|
|
case 'drawing': {
|
|
const drawData = data as DrawingObject;
|
|
displayObject = new DrawingSprite(drawData.points, drawData.color, drawData.strokeWidth);
|
|
break;
|
|
}
|
|
|
|
case 'group': {
|
|
const gd = data as GroupObject;
|
|
displayObject = new FrameSprite(gd.w, gd.h, gd.bgColor, gd.label, gd.padding);
|
|
break;
|
|
}
|
|
|
|
case 'sticky': {
|
|
const stickyData = data as StickyObject;
|
|
const sprite = new StickySprite(stickyData);
|
|
// Sync auto-computed height back to data
|
|
data.h = sprite.computedHeight;
|
|
displayObject = sprite;
|
|
break;
|
|
}
|
|
|
|
default:
|
|
displayObject = new Container();
|
|
break;
|
|
}
|
|
|
|
// Common properties
|
|
displayObject.position.set(data.x, data.y);
|
|
displayObject.scale.set(data.sx, data.sy);
|
|
displayObject.angle = data.angle;
|
|
displayObject.alpha = data.opacity;
|
|
displayObject.visible = data.visible;
|
|
displayObject.eventMode = data.locked ? 'none' : 'static';
|
|
displayObject.label = data.id;
|
|
displayObject.cursor = 'pointer';
|
|
|
|
this.viewport.addChild(displayObject);
|
|
|
|
const item: SceneItem = {
|
|
id: data.id,
|
|
type: data.type,
|
|
displayObject,
|
|
data: { ...data },
|
|
};
|
|
|
|
this.items.set(data.id, item);
|
|
this.updateSpatialEntry(item);
|
|
|
|
// Wire video dimension auto-correction to the STORED item.data (not the original param)
|
|
if (data.type === 'video' && displayObject instanceof VideoSprite) {
|
|
displayObject.onDimensionsKnown = (realW, realH) => {
|
|
item.data.w = realW;
|
|
item.data.h = realH;
|
|
// Cache native dimensions so future scene loads skip re-init for size
|
|
(item.data as VideoObject).nativeW = realW;
|
|
(item.data as VideoObject).nativeH = realH;
|
|
this._onChange?.();
|
|
this._onItemDimensionsChanged?.(item.id);
|
|
};
|
|
}
|
|
|
|
// No entrance animation — items must be visible and draggable immediately.
|
|
}
|
|
|
|
// -- Item Update ---------------------------------------------------------
|
|
|
|
/** Update an existing item's display object properties from new data. */
|
|
_updateItem(item: SceneItem, data: AnySceneObject): void {
|
|
const obj = item.displayObject;
|
|
|
|
obj.position.set(data.x, data.y);
|
|
obj.scale.set(data.sx, data.sy);
|
|
obj.angle = data.angle;
|
|
obj.alpha = data.opacity;
|
|
obj.visible = data.visible;
|
|
obj.eventMode = data.locked ? 'none' : 'static';
|
|
|
|
// Type-specific updates
|
|
if (data.type === 'drawing' && obj instanceof DrawingSprite) {
|
|
const drawData = data as DrawingObject;
|
|
obj.setPoints(drawData.points);
|
|
}
|
|
if (data.type === 'text' && obj instanceof TextSprite) {
|
|
const dimensionsChanged = obj.updateFromData(data as TextObject);
|
|
if (dimensionsChanged) {
|
|
data.w = obj.measuredWidth;
|
|
data.h = obj.measuredHeight;
|
|
}
|
|
}
|
|
if (data.type === 'group' && obj instanceof FrameSprite) {
|
|
obj.updateFromData(data as GroupObject);
|
|
}
|
|
if (data.type === 'sticky' && obj instanceof StickySprite) {
|
|
obj.updateFromData(data as StickyObject);
|
|
// Sync auto-computed height
|
|
data.h = obj.computedHeight;
|
|
}
|
|
if (data.type === 'image' && obj instanceof ImageSprite) {
|
|
const imgData = data as ImageObject;
|
|
obj.applyCrop(imgData.crop);
|
|
}
|
|
|
|
// Update stored data
|
|
item.data = { ...data };
|
|
item.type = data.type;
|
|
this.updateSpatialEntry(item);
|
|
}
|
|
|
|
// -- Z-Ordering ----------------------------------------------------------
|
|
|
|
/** Sort items by z and reorder children in both viewport and group containers. */
|
|
_applyZOrder(): void {
|
|
const sorted = Array.from(this.items.values()).sort(
|
|
(a, b) => a.data.z - b.data.z,
|
|
);
|
|
|
|
// Reorder top-level items in the viewport
|
|
let vpIndex = 0;
|
|
for (const item of sorted) {
|
|
const child = item.displayObject;
|
|
if (child.parent === this.viewport) {
|
|
// Clamp index to valid range (selection overlay etc. may also be viewport children)
|
|
const maxIdx = this.viewport.children.length - 1;
|
|
const targetIdx = Math.min(vpIndex, maxIdx);
|
|
if (this.viewport.getChildIndex(child) !== targetIdx) {
|
|
this.viewport.setChildIndex(child, targetIdx);
|
|
}
|
|
vpIndex++;
|
|
}
|
|
}
|
|
|
|
// Reorder children within each group container
|
|
for (const item of sorted) {
|
|
if (item.data.type !== 'group') continue;
|
|
const groupData = item.data as GroupObject;
|
|
const container = item.displayObject;
|
|
// Sort children by their z value within the group
|
|
const childItems = groupData.children
|
|
.map((id) => this.items.get(id))
|
|
.filter((c): c is SceneItem => !!c)
|
|
.sort((a, b) => a.data.z - b.data.z);
|
|
|
|
for (let i = 0; i < childItems.length; i++) {
|
|
const child = childItems[i].displayObject;
|
|
if (child.parent === container) {
|
|
const maxIdx = container.children.length - 1;
|
|
const targetIdx = Math.min(i, maxIdx);
|
|
if (container.getChildIndex(child) !== targetIdx) {
|
|
container.setChildIndex(child, targetIdx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- Lookup Helpers ------------------------------------------------------
|
|
|
|
getById(id: string): SceneItem | undefined {
|
|
return this.items.get(id);
|
|
}
|
|
|
|
getAllItems(): SceneItem[] {
|
|
return Array.from(this.items.values());
|
|
}
|
|
|
|
/** Get only top-level items (excludes group children). Used for selection/hit testing. */
|
|
getTopLevelItems(): SceneItem[] {
|
|
return Array.from(this.items.values()).filter((item) => !isGroupChild(item.id));
|
|
}
|
|
|
|
// -- Remove Item ---------------------------------------------------------
|
|
|
|
/** Remove an item, optionally animating scale→0.8 + fade out before destroy.
|
|
* If item is a group, recursively removes all children from the items map. */
|
|
removeItem(id: string, animate = true): void {
|
|
const item = this.items.get(id);
|
|
if (!item) return;
|
|
|
|
// If this is a group, remove children from the items map first
|
|
if (item.data.type === 'group') {
|
|
const groupData = item.data as import('./scene-format').GroupObject;
|
|
for (const childId of groupData.children) {
|
|
this.spatialGrid.remove(childId);
|
|
this.items.delete(childId);
|
|
}
|
|
}
|
|
|
|
if (!animate) {
|
|
item.displayObject.destroy({ children: true });
|
|
this.spatialGrid.remove(id);
|
|
this.items.delete(id);
|
|
this._onChange?.();
|
|
return;
|
|
}
|
|
|
|
const obj = item.displayObject;
|
|
|
|
// Remove from map immediately to prevent double-remove
|
|
this.spatialGrid.remove(id);
|
|
this.items.delete(id);
|
|
|
|
// Scale toward center on removal
|
|
const halfW = item.data.w * item.data.sx / 2;
|
|
const halfH = item.data.h * item.data.sy / 2;
|
|
const startX = item.data.x;
|
|
const startY = item.data.y;
|
|
|
|
const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy);
|
|
scaleSpring.onUpdate = (v) => {
|
|
if (!obj.destroyed) {
|
|
obj.scale.set(v * item.data.sx, v * item.data.sy);
|
|
obj.position.set(
|
|
startX + halfW * (1 - v),
|
|
startY + halfH * (1 - v),
|
|
);
|
|
}
|
|
};
|
|
this.springs.add(scaleSpring);
|
|
|
|
const alphaSpring = new Spring(obj.alpha, 0, PRESETS.snappy);
|
|
alphaSpring.onUpdate = (v) => {
|
|
if (!obj.destroyed) {
|
|
obj.alpha = Math.max(0, v);
|
|
}
|
|
};
|
|
alphaSpring.onComplete = () => {
|
|
if (!obj.destroyed) {
|
|
obj.destroy({ children: true });
|
|
}
|
|
};
|
|
this.springs.add(alphaSpring);
|
|
|
|
this._onChange?.();
|
|
}
|
|
|
|
// -- Z Counter -----------------------------------------------------------
|
|
|
|
/** Increment and return the next z value. */
|
|
nextZ(): number {
|
|
return this._zCounter++;
|
|
}
|
|
|
|
// -- Convenience: Add Image From Upload ----------------------------------
|
|
|
|
/** Create an ImageObject from an upload and add it to the scene with animation. */
|
|
addImageFromUpload(
|
|
assetKey: string,
|
|
w: number,
|
|
h: number,
|
|
x: number,
|
|
y: number,
|
|
): SceneItem {
|
|
const data: ImageObject = {
|
|
id: crypto.randomUUID(),
|
|
type: 'image',
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
sx: 1,
|
|
sy: 1,
|
|
angle: 0,
|
|
z: this.nextZ(),
|
|
opacity: 1,
|
|
locked: false,
|
|
name: '',
|
|
visible: true,
|
|
asset: assetKey,
|
|
filters: [],
|
|
};
|
|
|
|
// Fire-and-forget the async creation (animation handles visual feedback)
|
|
this._createItem(data);
|
|
|
|
this._applyZOrder();
|
|
this._onChange?.();
|
|
|
|
return this.items.get(data.id)!;
|
|
}
|
|
|
|
/** Create a VideoObject from an upload and add it to the scene with animation. */
|
|
addVideoFromUpload(
|
|
assetKey: string,
|
|
w: number,
|
|
h: number,
|
|
x: number,
|
|
y: number,
|
|
posterAssetKey?: string,
|
|
duration?: number,
|
|
): SceneItem {
|
|
const data: VideoObject = {
|
|
id: crypto.randomUUID(),
|
|
type: 'video',
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
sx: 1,
|
|
sy: 1,
|
|
angle: 0,
|
|
z: this.nextZ(),
|
|
opacity: 1,
|
|
locked: false,
|
|
name: '',
|
|
visible: true,
|
|
asset: assetKey,
|
|
muted: true,
|
|
loop: true,
|
|
nativeW: w,
|
|
nativeH: h,
|
|
poster: posterAssetKey,
|
|
duration,
|
|
};
|
|
|
|
this._createItem(data);
|
|
this._applyZOrder();
|
|
this._onChange?.();
|
|
|
|
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);
|
|
this.updateSpatialEntry(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.spatialGrid.remove(groupId);
|
|
this.items.delete(groupId);
|
|
|
|
this._applyZOrder();
|
|
this._onChange?.();
|
|
return freed;
|
|
}
|
|
}
|