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