Files
refboard-ayon/frontend/src/canvas/grouping.ts
T
Hiren Kangad e9509ef7b0 feat(refboard): canvas polish — transform box sync, dot grid, snap tuning, perf fixes
- Fix transform box not updating after alignment/arrangement/normalize/flip operations
  (shortcuts, context menu, and selection toolbar all fixed with DRY _opUpdate helper)
- Replace 100ms polling loop with event-driven viewport updates (moved + wheel-scroll)
- Add adaptive dot grid background that responds to zoom/pan
- Reduce snap guide threshold from 8px to 4px for subtler snapping
- Remove PresenceOverlay (remote selection highlighting) — too heavy for minimal benefit
- Offset multiple dropped images so they don't overlap
- Add new canvas modules: SnapGuides, clipboard, grouping, FrameSprite, DrawingSprite,
  LaserPointer, context-menu-items, SelectionToolbar, Minimap
- Extract Editor hooks into dedicated files (useBoardLoader, useCanvasSetup,
  useShortcutHandler, useLayerPanel, useSaveManager, useFollowMode)
- Sync improvements: real-time transform broadcast, board rooms, viewport sync
2026-03-10 03:58:53 +05:30

198 lines
5.9 KiB
TypeScript

/**
* Grouping module — group/ungroup operations for scene items.
*
* Data model:
* - Group children store LOCAL x/y (relative to group origin) in data.x/y
* - The group's data.x/y is the world-space top-left of the bounding box
* - On serialize, children's data.x/y are local coords → correct on reload
* - On ungroup, children's data.x/y are converted back to world coords
*/
import type { Viewport } from 'pixi-viewport';
import type { SceneManager, SceneItem } from './SceneManager';
import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
import type { GroupObject } from './scene-format';
import { randomFrameColor } from './sprites/FrameSprite';
/**
* Group selected items into a single group container.
* Requires at least 2 selected items.
*/
export function groupItems(
scene: SceneManager,
selection: SelectionManager,
onChange: () => void,
): void {
const selected = selection.getSelectedItems();
if (selected.length < 2) return;
// Allow nested groups — groups can contain other groups
// Compute the bounding box of all selected items
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const item of selected) {
const b = getItemWorldBounds(item);
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w);
maxY = Math.max(maxY, b.y + b.h);
}
const groupX = minX;
const groupY = minY;
const groupW = maxX - minX;
const groupH = maxY - minY;
const groupData: GroupObject = {
id: crypto.randomUUID(),
type: 'group',
x: groupX,
y: groupY,
w: groupW,
h: groupH,
sx: 1,
sy: 1,
angle: 0,
z: scene.nextZ(),
opacity: 1,
locked: false,
name: '',
visible: true,
children: selected.map((s) => s.id),
bgColor: randomFrameColor(),
label: '',
padding: 12,
};
// Create the group container
scene._createItem(groupData, false);
const groupItem = scene.getById(groupData.id);
if (!groupItem) return;
// Reparent each selected item into the group container
for (const child of selected) {
const obj = child.displayObject;
// Convert world position to local (relative to group origin)
const localX = child.data.x - groupX;
const localY = child.data.y - groupY;
// Update DATA to store local coords (critical for serialization)
child.data.x = localX;
child.data.y = localY;
// Reparent display object
obj.parent?.removeChild(obj);
obj.position.set(localX, localY);
groupItem.displayObject.addChild(obj);
}
selection.clear();
selection.selectOnly(groupItem.id);
rebuildGroupChildSet(scene);
scene._applyZOrder();
onChange();
}
/**
* Ungroup the selected group, reparenting children back to the viewport.
* Propagates group's scale, angle, and opacity to each child so visual
* appearance is preserved after ungrouping.
* Requires exactly 1 selected item of type 'group'.
*/
export function ungroupItems(
scene: SceneManager,
selection: SelectionManager,
viewport: Viewport,
onChange: () => void,
): void {
const selected = selection.getSelectedItems();
if (selected.length !== 1) return;
const groupItem = selected[0];
if (groupItem.data.type !== 'group') return;
const groupData = groupItem.data as GroupObject;
const groupX = groupData.x;
const groupY = groupData.y;
const groupSx = groupData.sx;
const groupSy = groupData.sy;
const groupAngle = groupData.angle;
const childIds: string[] = [];
// Reparent each child back to the viewport, propagating group transforms
for (const childId of groupData.children) {
const childItem = scene.getById(childId);
if (!childItem) continue;
const obj = childItem.displayObject;
// Convert local position to world space, accounting for group scale
const worldX = groupX + childItem.data.x * groupSx;
const worldY = groupY + childItem.data.y * groupSy;
// Propagate group scale to child
childItem.data.sx *= groupSx;
childItem.data.sy *= groupSy;
// Propagate group angle to child
childItem.data.angle = (childItem.data.angle || 0) + groupAngle;
// Update DATA to store world coords
childItem.data.x = worldX;
childItem.data.y = worldY;
// Reparent display object
obj.parent?.removeChild(obj);
viewport.addChild(obj);
obj.position.set(worldX, worldY);
obj.scale.set(childItem.data.sx, childItem.data.sy);
obj.angle = childItem.data.angle;
childIds.push(childId);
}
// Remove the group item itself (don't destroy children — they're reparented)
const groupObj = groupItem.displayObject;
groupObj.parent?.removeChild(groupObj);
groupObj.destroy();
scene.items.delete(groupItem.id);
// Select the ungrouped children
selection.clear();
for (const id of childIds) {
selection.selectedIds.add(id);
}
selection.transformBox.update(selection.getSelectedItems());
rebuildGroupChildSet(scene);
scene._applyZOrder();
onChange();
}
/**
* After loading a scene, reparent group children into their group containers.
* Called by SceneManager.loadScene() after all items are created.
*/
export function reparentGroupChildren(scene: SceneManager): void {
for (const item of scene.items.values()) {
if (item.data.type !== 'group') continue;
const groupData = item.data as GroupObject;
const groupContainer = item.displayObject;
for (const childId of groupData.children) {
const childItem = scene.getById(childId);
if (!childItem) continue;
const obj = childItem.displayObject;
// Only reparent if currently in viewport (not already in a group)
if (obj.parent !== groupContainer) {
obj.parent?.removeChild(obj);
// data.x/y are already local coords (saved that way)
obj.position.set(childItem.data.x, childItem.data.y);
groupContainer.addChild(obj);
}
}
}
}