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:
Hiren Kangad
2026-03-10 03:58:53 +05:30
parent cfd7adf73d
commit e9509ef7b0
41 changed files with 4171 additions and 705 deletions
+286 -33
View File
@@ -5,16 +5,22 @@
* and spring-animated add/remove operations.
*/
import { Container, Sprite, Texture, Graphics, Text, TextStyle } from 'pixi.js';
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import { TextureManager, LODTier } from './TextureManager';
import { TextureManager } from './TextureManager';
import { ImageSprite } from './sprites/ImageSprite';
import { VideoSprite } from './sprites/VideoSprite';
import { DrawingSprite } from './sprites/DrawingSprite';
import { FrameSprite } from './sprites/FrameSprite';
import { SpringManager, Spring, PRESETS } from './spring';
import { reparentGroupChildren } from './grouping';
import type {
SceneData,
AnySceneObject,
ImageObject,
VideoObject,
TextObject,
DrawingObject,
GroupObject,
SceneObject,
} from './scene-format';
@@ -25,11 +31,106 @@ import type {
export interface SceneItem {
id: string;
type: 'image' | 'video' | 'text' | 'group';
type: 'image' | 'video' | 'text' | 'drawing' | 'group';
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
// ---------------------------------------------------------------------------
@@ -41,6 +142,7 @@ export class SceneManager {
readonly springs: SpringManager;
private _onChange: (() => void) | null = null;
private _onItemDimensionsChanged: ((itemId: string) => void) | null = null;
private _zCounter: number = 0;
constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) {
@@ -55,6 +157,10 @@ export class SceneManager {
this._onChange = fn;
}
set onItemDimensionsChanged(fn: ((itemId: string) => void) | null) {
this._onItemDimensionsChanged = fn;
}
get onChange(): (() => void) | null {
return this._onChange;
}
@@ -98,6 +204,21 @@ export class SceneManager {
}
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.items.delete(id);
}
@@ -121,7 +242,11 @@ export class SceneManager {
await Promise.all(loadPromises);
// 4. Apply z-ordering
// 4. Reparent group children into their group containers
reparentGroupChildren(this);
rebuildGroupChildSet(this);
// 5. Apply z-ordering
this._applyZOrder();
this._onChange?.();
@@ -136,31 +261,18 @@ export class SceneManager {
switch (data.type) {
case 'image': {
const imgData = data as ImageObject;
const sprite = new Sprite(Texture.EMPTY);
sprite.width = imgData.w;
sprite.height = imgData.h;
// Load texture asynchronously at appropriate LOD
const zoom = this.viewport.scale.x;
const tier = this.textures.tierForZoom(zoom);
this.textures.load(imgData.asset, tier).then((tex) => {
if (!sprite.destroyed) {
sprite.texture = tex;
sprite.width = imgData.w;
sprite.height = imgData.h;
}
});
displayObject = sprite;
displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
break;
}
case 'video': {
const vidData = data as VideoObject;
const gfx = new Graphics();
gfx.rect(0, 0, vidData.w, vidData.h);
gfx.fill(0x333333);
displayObject = gfx;
const videoUrl = this.textures.urlForAsset(vidData.asset);
const videoSprite = new VideoSprite(vidData.asset, vidData.w, vidData.h, videoUrl);
// 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;
}
@@ -175,8 +287,15 @@ export class SceneManager {
break;
}
case 'drawing': {
const drawData = data as DrawingObject;
displayObject = new DrawingSprite(drawData.points, drawData.color, drawData.strokeWidth);
break;
}
case 'group': {
displayObject = new Container();
const gd = data as GroupObject;
displayObject = new FrameSprite(gd.w, gd.h, gd.bgColor, gd.label, gd.padding);
break;
}
@@ -206,23 +325,52 @@ export class SceneManager {
this.items.set(data.id, item);
// Animate entrance: spring scale from 0 → 1.05 → 1.0 with fade-in
// 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;
this._onChange?.();
this._onItemDimensionsChanged?.(item.id);
};
}
// Animate entrance: spring scale from center 0 → 1.05 → 1.0 with fade-in
if (animate) {
displayObject.scale.set(0, 0);
displayObject.alpha = 0;
// Scale from center by adjusting position to keep center point fixed
const halfW = data.w * data.sx / 2;
const halfH = data.h * data.sy / 2;
const finalX = data.x;
const finalY = data.y;
const scaleSpring = new Spring(0, 1.05, PRESETS.bounce);
scaleSpring.onUpdate = (v) => {
if (!displayObject.destroyed) {
displayObject.scale.set(v * data.sx, v * data.sy);
displayObject.position.set(
finalX + halfW * (1 - v),
finalY + halfH * (1 - v),
);
}
};
scaleSpring.onComplete = () => {
// Second spring: 1.05 → 1.0
const settleSpring = new Spring(1.05, 1.0, PRESETS.snappy);
settleSpring.onUpdate = (v) => {
if (!displayObject.destroyed) {
displayObject.scale.set(v * data.sx, v * data.sy);
displayObject.position.set(
finalX + halfW * (1 - v),
finalY + halfH * (1 - v),
);
}
};
settleSpring.onComplete = () => {
if (!displayObject.destroyed) {
displayObject.position.set(finalX, finalY);
displayObject.scale.set(data.sx, data.sy);
}
};
this.springs.add(settleSpring);
@@ -252,6 +400,21 @@ export class SceneManager {
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 Text) {
const txtData = data as TextObject;
obj.text = txtData.text;
obj.style.fontSize = txtData.fontSize;
obj.style.fill = txtData.fill;
}
if (data.type === 'group' && obj instanceof FrameSprite) {
obj.updateFromData(data as GroupObject);
}
// Update stored data
item.data = { ...data };
item.type = data.type;
@@ -259,16 +422,47 @@ export class SceneManager {
// -- Z-Ordering ----------------------------------------------------------
/** Sort items by z and reorder children in the viewport. */
/** 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,
);
for (let i = 0; i < sorted.length; i++) {
const child = sorted[i].displayObject;
// Reorder top-level items in the viewport
let vpIndex = 0;
for (const item of sorted) {
const child = item.displayObject;
if (child.parent === this.viewport) {
this.viewport.setChildIndex(child, i);
// 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);
}
}
}
}
}
@@ -283,13 +477,27 @@ export class SceneManager {
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. */
/** 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.items.delete(childId);
}
}
if (!animate) {
item.displayObject.destroy({ children: true });
this.items.delete(id);
@@ -302,10 +510,20 @@ export class SceneManager {
// Remove from map immediately to prevent double-remove
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.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);
@@ -371,6 +589,41 @@ export class SceneManager {
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,
): 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,
};
this._createItem(data, true);
this._applyZOrder();
this._onChange?.();
return this.items.get(data.id)!;
}
// -- Group / Ungroup with Spring Animation --------------------------------
/**