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
@@ -0,0 +1,91 @@
import { Graphics } from 'pixi.js';
/**
* DrawingSprite — renders a freehand stroke from a flat points array.
* Points stored as [x0, y0, x1, y1, ...] relative to the item's origin.
*
* Uses batched redraw during live drawing — accumulates points and redraws
* on the next animation frame. This avoids expensive per-pointermove redraws
* while keeping the stroke visually smooth.
*/
export class DrawingSprite extends Graphics {
private _points: number[] = [];
private _color: string;
private _strokeWidth: number;
private _rafId: number | null = null;
private _dirty = false;
constructor(points: number[], color: string, strokeWidth: number) {
super();
this._points = points;
this._color = color;
this._strokeWidth = strokeWidth;
this._redraw();
}
get points(): number[] {
return this._points;
}
/** Append a point during live drawing — batches redraw to next rAF. */
addPoint(x: number, y: number): void {
this._points.push(x, y);
if (!this._dirty) {
this._dirty = true;
this._rafId = requestAnimationFrame(() => {
this._rafId = null;
this._dirty = false;
this._redraw();
});
}
}
/** Replace all points and redraw immediately. Used for scene load / sync. */
setPoints(pts: number[]): void {
this._points = pts;
if (this._rafId !== null) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
this._dirty = false;
}
this._redraw();
}
private _redraw(): void {
this.clear();
const pts = this._points;
if (pts.length < 4) return;
this.setStrokeStyle({
width: this._strokeWidth,
color: this._color,
cap: 'round',
join: 'round',
});
this.moveTo(pts[0], pts[1]);
// Use quadratic curve smoothing for 3+ points
if (pts.length >= 6) {
for (let i = 2; i < pts.length - 2; i += 2) {
const mx = (pts[i] + pts[i + 2]) / 2;
const my = (pts[i + 1] + pts[i + 3]) / 2;
this.quadraticCurveTo(pts[i], pts[i + 1], mx, my);
}
// Last segment
this.lineTo(pts[pts.length - 2], pts[pts.length - 1]);
} else {
this.lineTo(pts[2], pts[3]);
}
this.stroke();
}
override destroy(options?: any): void {
if (this._rafId !== null) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
}
super.destroy(options);
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* FrameSprite — visual container for groups/frames.
*
* Renders as a colored BORDER (not fill) with rounded corners + title label.
* Extends Container. The border and label are the first children,
* so actual group children render on top.
* Lightweight: one Graphics rect + one Text. No filters or shaders.
*/
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
import type { GroupObject } from '../scene-format';
const DEFAULT_PADDING = 16;
const LABEL_FONT_SIZE = 11;
const BORDER_WIDTH = 2;
const CORNER_RADIUS = 6;
const FRAME_COLORS = [
'#4a90d9', '#69db7c', '#ff6b6b', '#7950f2', '#38d9a9',
'#ffa94d', '#e64980', '#20c997', '#4dabf7', '#ffd43b',
];
/** Pick a random frame color for new groups. */
export function randomFrameColor(): string {
return FRAME_COLORS[Math.floor(Math.random() * FRAME_COLORS.length)];
}
export class FrameSprite extends Container {
private _bg: Graphics;
private _label: Text;
private _labelBg: Graphics;
private _bgColor: string;
private _padding: number;
private _frameW: number;
private _frameH: number;
private _labelText: string;
constructor(w: number, h: number, bgColor?: string, label?: string, padding?: number) {
super();
this._bgColor = bgColor || '';
this._padding = padding ?? DEFAULT_PADDING;
this._frameW = w;
this._frameH = h;
this._labelText = label || '';
// Border rect (drawn first = behind everything)
this._bg = new Graphics();
this._bg.label = '__frame_bg';
this.addChild(this._bg);
// Label background pill
this._labelBg = new Graphics();
this._labelBg.label = '__frame_labelbg';
this.addChild(this._labelBg);
// Title label
this._label = new Text({
text: this._labelText,
style: new TextStyle({
fontSize: LABEL_FONT_SIZE,
fontFamily: 'system-ui, -apple-system, sans-serif',
fill: '#ffffff',
fontWeight: '600',
}),
});
this._label.label = '__frame_label';
this.addChild(this._label);
this._redraw();
}
get bgColor(): string { return this._bgColor; }
get padding(): number { return this._padding; }
setBgColor(color: string): void {
this._bgColor = color;
this._redraw();
}
setLabel(text: string): void {
this._labelText = text;
this._label.text = text;
this._redraw();
}
/** Update frame dimensions (call after children bounds change). */
setFrameSize(w: number, h: number): void {
this._frameW = w;
this._frameH = h;
this._redraw();
}
/** Update from GroupObject data. */
updateFromData(data: GroupObject): void {
this._bgColor = data.bgColor || '';
this._labelText = data.label || '';
this._padding = data.padding ?? DEFAULT_PADDING;
this._frameW = data.w;
this._frameH = data.h;
this._label.text = this._labelText;
this._redraw();
}
private _redraw(): void {
this._bg.clear();
this._labelBg.clear();
if (!this._bgColor) {
this._bg.visible = false;
this._labelBg.visible = false;
this._label.visible = false;
return;
}
this._bg.visible = true;
const pad = this._padding;
const color = parseInt(this._bgColor.replace('#', ''), 16);
// Draw border-only rounded rect (no fill, just stroke)
this._bg.roundRect(-pad, -pad, this._frameW + pad * 2, this._frameH + pad * 2, CORNER_RADIUS);
this._bg.stroke({
color,
alpha: 0.6,
width: BORDER_WIDTH,
});
// Label positioned at top-left corner, overlapping the border
const hasLabel = this._labelText.length > 0;
this._label.visible = hasLabel;
this._labelBg.visible = hasLabel;
if (hasLabel) {
const lx = -pad;
const ly = -pad - LABEL_FONT_SIZE - 6;
this._label.position.set(lx + 8, ly + 3);
// Background pill behind label text
const lw = this._label.width + 16;
const lh = LABEL_FONT_SIZE + 6;
this._labelBg.roundRect(lx, ly, lw, lh, CORNER_RADIUS);
this._labelBg.fill({ color, alpha: 0.8 });
}
}
}
+40 -61
View File
@@ -1,25 +1,24 @@
import { Sprite, Texture, Graphics } from "pixi.js";
import { DropShadowFilter } from "pixi-filters";
import { TextureManager, LODTier } from "../TextureManager";
import { Container, Sprite, Texture, Graphics } from "pixi.js";
import { TextureManager } from "../TextureManager";
/**
* A Sprite subclass that manages LOD tier switching and lazy loading.
* Shows a placeholder shimmer rect until the first texture tier loads,
* then swaps textures as zoom level changes.
* A Container holding a shadow graphic + sprite with lazy texture loading.
* Uses a lightweight Graphics shadow instead of DropShadowFilter (GPU-heavy).
*/
// Shadow defaults (resting state)
const SHADOW_REST = { offsetX: 3, offsetY: 3, blur: 4, alpha: 0.25 };
const SHADOW_REST = { offsetX: 3, offsetY: 3, alpha: 0.2 };
// Shadow lifted state (during drag)
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, blur: 8, alpha: 0.35 };
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 };
export class ImageSprite extends Sprite {
export class ImageSprite extends Container {
readonly assetKey: string;
readonly shadow: DropShadowFilter;
private textures: TextureManager;
private currentTier: LODTier | null = null;
private loaded = false;
private loading = false;
private placeholder: Graphics | null = null;
private _sprite: Sprite;
private _shadow: Graphics;
private _naturalWidth: number;
private _naturalHeight: number;
@@ -29,24 +28,23 @@ export class ImageSprite extends Sprite {
h: number,
textures: TextureManager,
) {
super(Texture.EMPTY);
super();
this.assetKey = assetKey;
this.textures = textures;
this._naturalWidth = w;
this._naturalHeight = h;
this.width = w;
this.height = h;
// Shadow: simple dark rect behind the sprite (cheap, no GPU filter)
this._shadow = new Graphics();
this._drawShadow(SHADOW_REST);
this.addChild(this._shadow);
// Drop shadow for photos-on-a-desk feel
this.shadow = new DropShadowFilter({
offset: { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY },
blur: SHADOW_REST.blur,
alpha: SHADOW_REST.alpha,
color: 0x000000,
});
this.filters = [this.shadow];
// Main sprite
this._sprite = new Sprite(Texture.EMPTY);
this._sprite.width = w;
this._sprite.height = h;
this.addChild(this._sprite);
// Create placeholder: dark rect shown until first texture loads
const placeholder = new Graphics();
@@ -54,40 +52,39 @@ export class ImageSprite extends Sprite {
this.placeholder = placeholder;
this.addChild(placeholder);
// Immediately start loading the thumbnail tier
this.loadTier("thumb");
// Immediately start loading the full texture
this.loadTexture();
}
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
this._shadow.clear();
this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight);
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
}
/** Expand shadow for drag-lift effect. */
liftShadow(): void {
this.shadow.offset = { x: SHADOW_LIFT.offsetX, y: SHADOW_LIFT.offsetY };
this.shadow.blur = SHADOW_LIFT.blur;
this.shadow.alpha = SHADOW_LIFT.alpha;
this._drawShadow(SHADOW_LIFT);
}
/** Restore shadow to resting state. */
dropShadow(): void {
this.shadow.offset = { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY };
this.shadow.blur = SHADOW_REST.blur;
this.shadow.alpha = SHADOW_REST.alpha;
this._drawShadow(SHADOW_REST);
}
/**
* Load a specific LOD tier texture for this sprite.
* Skips if already at the requested tier or currently loading.
*/
async loadTier(tier: LODTier): Promise<void> {
if (this.currentTier === tier || this.loading) return;
/** Load the full-res texture. GPU handles scaling natively. */
async loadTexture(): Promise<void> {
if (this.loaded || this.loading) return;
this.loading = true;
try {
const tex = await this.textures.load(this.assetKey, tier);
this.texture = tex;
this.width = this._naturalWidth;
this.height = this._naturalHeight;
this.currentTier = tier;
const tex = await this.textures.load(this.assetKey);
this._sprite.texture = tex;
this._sprite.width = this._naturalWidth;
this._sprite.height = this._naturalHeight;
this.loaded = true;
// Remove placeholder after first successful load
// Remove placeholder after successful load
if (this.placeholder) {
this.removeChild(this.placeholder);
this.placeholder.destroy();
@@ -95,29 +92,11 @@ export class ImageSprite extends Sprite {
}
} catch (err) {
console.warn(
`[ImageSprite] Failed to load tier "${tier}" for "${this.assetKey}":`,
`[ImageSprite] Failed to load "${this.assetKey}":`,
err,
);
} finally {
this.loading = false;
}
}
/**
* Evaluate the current zoom level and switch LOD tier if needed.
*/
updateLOD(zoom: number): void {
const needed = this.textures.tierForZoom(zoom);
if (needed !== this.currentTier) {
this.loadTier(needed);
}
}
/**
* Release the current texture (for off-screen sprites to free GPU memory).
*/
unloadTexture(): void {
this.texture = Texture.EMPTY;
this.currentTier = null;
}
}