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
+49 -33
View File
@@ -12,10 +12,11 @@ import {
useImperativeHandle,
useRef,
useCallback,
useState,
} from 'react';
import { Application } from 'pixi.js';
import { Viewport } from 'pixi-viewport';
import { SceneManager } from './SceneManager';
import { SceneManager, getItemWorldBounds } from './SceneManager';
import { TextureManager } from './TextureManager';
import { SpringManager } from './spring';
import { convertFabricToV2 } from './scene-format';
@@ -28,13 +29,14 @@ import type { SceneData } from './scene-format';
export interface PixiCanvasHandle {
getViewport: () => Viewport | null;
getScene: () => SceneManager | null;
getApp: () => Application | null;
fitAll: () => void;
getZoom: () => number;
setZoom: (zoom: number) => void;
}
export interface PixiCanvasProps {
canvasState?: string | null;
canvasState?: string | object | null;
currentTool: string;
boardId?: string;
onChange?: () => void;
@@ -99,6 +101,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
const initialLoadDone = useRef(false);
const spaceHeld = useRef(false);
const onChangeRef = useRef(onChange);
const [pixiReady, setPixiReady] = useState(false);
// Keep onChange ref current without re-running effects
onChangeRef.current = onChange;
@@ -121,6 +124,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
antialias: true,
autoDensity: true,
resolution: window.devicePixelRatio,
preserveDrawingBuffer: true,
});
if (destroyed) {
@@ -160,7 +164,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
springs.tick(ticker.deltaMS / 1000);
});
// -- Culling + LOD ticker (runs every 200ms, not every frame) ------
// -- Visibility culling ticker (runs every 200ms, not every frame) --
let lastCullCheck = 0;
app.ticker.add((ticker) => {
@@ -168,29 +172,18 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
if (lastCullCheck < 200) return;
lastCullCheck = 0;
const zoom = viewport.scale.x;
const bounds = viewport.getVisibleBounds();
const margin = 200;
for (const item of scene.getAllItems()) {
const d = item.displayObject;
const ib = d.getBounds();
const inView =
ib.x + ib.width > bounds.x - margin &&
ib.x < bounds.x + bounds.width + margin &&
ib.y + ib.height > bounds.y - margin &&
ib.y < bounds.y + bounds.height + margin;
if (item.type === 'image' && 'updateLOD' in d) {
if (inView) {
(d as any).updateLOD(zoom);
d.visible = true;
} else {
d.visible = false;
}
}
if (item.type === 'video' && 'onVisibilityChange' in d) {
(d as any).onVisibilityChange(inView);
if (item.type === 'video' && 'onVisibilityChange' in item.displayObject) {
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
const inView =
ix + iw > bounds.x - margin &&
ix < bounds.x + bounds.width + margin &&
iy + ih > bounds.y - margin &&
iy < bounds.y + bounds.height + margin;
(item.displayObject as any).onVisibilityChange(inView);
}
}
});
@@ -230,6 +223,18 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
// Store observer for cleanup
(container as any).__pixiRO = ro;
// Signal that PixiJS is ready for scene loading
setPixiReady(true);
// -- Prevent Ctrl+wheel browser zoom on canvas -----------------------
const preventBrowserZoom = (e: WheelEvent) => {
if (e.ctrlKey) {
e.preventDefault();
}
};
container.addEventListener('wheel', preventBrowserZoom, { passive: false });
(container as any).__pixiWheelHandler = preventBrowserZoom;
})();
// -- Cleanup ---------------------------------------------------------
@@ -243,6 +248,12 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
delete (container as any).__pixiRO;
}
const wheelHandler = (container as any).__pixiWheelHandler;
if (wheelHandler) {
container.removeEventListener('wheel', wheelHandler);
delete (container as any).__pixiWheelHandler;
}
textures.clear();
if (appRef.current) {
@@ -263,11 +274,16 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
if (initialLoadDone.current) return;
if (!canvasState || !sceneRef.current) return;
// canvasState may be a JSON string or already-parsed object
let parsed: any;
try {
parsed = JSON.parse(canvasState);
} catch {
return;
if (typeof canvasState === 'string') {
try {
parsed = JSON.parse(canvasState);
} catch {
return;
}
} else {
parsed = canvasState;
}
let sceneData: SceneData;
@@ -279,7 +295,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
initialLoadDone.current = true;
sceneRef.current.loadScene(sceneData, false);
}, [canvasState]);
}, [canvasState, pixiReady]);
// ── Space key for pan mode ────────────────────────────────────────
@@ -337,12 +353,11 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
let maxY = -Infinity;
for (const item of items) {
const obj = item.displayObject;
const bounds = obj.getBounds();
if (bounds.x < minX) minX = bounds.x;
if (bounds.y < minY) minY = bounds.y;
if (bounds.x + bounds.width > maxX) maxX = bounds.x + bounds.width;
if (bounds.y + bounds.height > maxY) maxY = bounds.y + bounds.height;
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
if (ix < minX) minX = ix;
if (iy < minY) minY = iy;
if (ix + iw > maxX) maxX = ix + iw;
if (iy + ih > maxY) maxY = iy + ih;
}
const padding = 40;
@@ -368,6 +383,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
() => ({
getViewport: () => viewportRef.current,
getScene: () => sceneRef.current,
getApp: () => appRef.current,
fitAll,
getZoom: () => viewportRef.current?.scale.x ?? 1,
setZoom: (zoom: number) => {