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,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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user