- 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
193 lines
6.0 KiB
TypeScript
193 lines
6.0 KiB
TypeScript
/**
|
|
* 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 });
|
|
}
|
|
}
|