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
+192
View File
@@ -0,0 +1,192 @@
/**
* SnapGuides — alignment snap guides for drag/resize operations (like Figma).
*
* Shows thin magenta guide lines when edges or centers of dragged items
* align with other items on the canvas, and returns snap corrections.
*/
import { Container, Graphics } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import { SceneManager, getItemWorldBounds } from './SceneManager';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SNAP_THRESHOLD = 4; // pixels in screen space (subtle, not aggressive)
const GUIDE_COLOR = 0xff4081;
const GUIDE_PADDING = 20; // world-space extension beyond bounds
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SnapEdge {
axis: 'x' | 'y';
value: number;
min: number;
max: number;
}
export interface SnapResult {
dx: number;
dy: number;
guides: SnapEdge[];
}
// ---------------------------------------------------------------------------
// SnapGuides
// ---------------------------------------------------------------------------
export class SnapGuides {
private _scene: SceneManager;
private _gfx: Graphics;
/** Cached candidate edges from non-selected items. */
private _candidatesX: { value: number; min: number; max: number }[] = [];
private _candidatesY: { value: number; min: number; max: number }[] = [];
constructor(scene: SceneManager, parent: Container) {
this._scene = scene;
this._gfx = new Graphics();
this._gfx.label = '__snap_guides';
parent.addChild(this._gfx);
}
// -----------------------------------------------------------------------
// Session management
// -----------------------------------------------------------------------
/** Cache candidate edges from all visible, unlocked, non-selected top-level items. */
beginSession(excludeIds: Set<string>): void {
this._candidatesX = [];
this._candidatesY = [];
for (const item of this._scene.getTopLevelItems()) {
if (excludeIds.has(item.id)) continue;
if (item.data.locked || !item.data.visible) continue;
const { x, y, w, h } = getItemWorldBounds(item);
const right = x + w;
const bottom = y + h;
const cx = x + w / 2;
const cy = y + h / 2;
// X-axis edges: left, center, right
this._candidatesX.push({ value: x, min: y, max: bottom });
this._candidatesX.push({ value: cx, min: y, max: bottom });
this._candidatesX.push({ value: right, min: y, max: bottom });
// Y-axis edges: top, center, bottom
this._candidatesY.push({ value: y, min: x, max: right });
this._candidatesY.push({ value: cy, min: x, max: right });
this._candidatesY.push({ value: bottom, min: x, max: right });
}
}
/** Clear cached edges and guide lines. */
endSession(): void {
this._candidatesX = [];
this._candidatesY = [];
this._gfx.clear();
}
// -----------------------------------------------------------------------
// Snap computation
// -----------------------------------------------------------------------
/**
* Compare 6 edges of the selection bounds against cached candidates.
* Returns correction deltas and guide lines to draw.
*/
computeSnap(
bounds: { x: number; y: number; w: number; h: number },
viewport: Viewport,
): SnapResult {
const scale = viewport.scale.x;
const threshold = SNAP_THRESHOLD / scale;
const { x, y, w, h } = bounds;
const right = x + w;
const bottom = y + h;
const cx = x + w / 2;
const cy = y + h / 2;
// Selection edges for each axis
const selEdgesX = [x, cx, right];
const selEdgesY = [y, cy, bottom];
// Selection extent ranges (for guide line extension)
const selMinY = y;
const selMaxY = bottom;
const selMinX = x;
const selMaxX = right;
let bestDx = Infinity;
let bestGuideX: SnapEdge | null = null;
// Find closest X match
for (const selVal of selEdgesX) {
for (const cand of this._candidatesX) {
const diff = cand.value - selVal;
if (Math.abs(diff) < Math.abs(bestDx)) {
bestDx = diff;
const gMin = Math.min(selMinY, cand.min) - GUIDE_PADDING;
const gMax = Math.max(selMaxY, cand.max) + GUIDE_PADDING;
bestGuideX = { axis: 'x', value: cand.value, min: gMin, max: gMax };
}
}
}
let bestDy = Infinity;
let bestGuideY: SnapEdge | null = null;
// Find closest Y match
for (const selVal of selEdgesY) {
for (const cand of this._candidatesY) {
const diff = cand.value - selVal;
if (Math.abs(diff) < Math.abs(bestDy)) {
bestDy = diff;
const gMin = Math.min(selMinX, cand.min) - GUIDE_PADDING;
const gMax = Math.max(selMaxX, cand.max) + GUIDE_PADDING;
bestGuideY = { axis: 'y', value: cand.value, min: gMin, max: gMax };
}
}
}
const guides: SnapEdge[] = [];
const dx = Math.abs(bestDx) <= threshold && bestGuideX ? bestDx : 0;
const dy = Math.abs(bestDy) <= threshold && bestGuideY ? bestDy : 0;
if (dx !== 0 && bestGuideX) guides.push(bestGuideX);
if (dy !== 0 && bestGuideY) guides.push(bestGuideY);
return { dx, dy, guides };
}
// -----------------------------------------------------------------------
// Drawing
// -----------------------------------------------------------------------
/** Draw guide lines; line width compensates for viewport zoom. */
drawGuides(guides: SnapEdge[], viewport: Viewport): void {
this._gfx.clear();
if (guides.length === 0) return;
const lineWidth = 1 / viewport.scale.x;
for (const g of guides) {
this._gfx.moveTo(
g.axis === 'x' ? g.value : g.min,
g.axis === 'x' ? g.min : g.value,
);
this._gfx.lineTo(
g.axis === 'x' ? g.value : g.max,
g.axis === 'x' ? g.max : g.value,
);
}
this._gfx.stroke({ color: GUIDE_COLOR, width: lineWidth });
}
}