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
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user