Files
refboard-ayon/frontend/src/canvas/PresenceOverlay.ts
T
Hiren Kangad e9509ef7b0 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
2026-03-10 03:58:53 +05:30

173 lines
5.2 KiB
TypeScript

/**
* PresenceOverlay — renders colored selection borders for remote users.
*
* When another user selects items on the collaborative whiteboard, this overlay
* draws thin colored outlines around those items on the local screen, with a
* name-label pill above the first selected item.
*/
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import type { SceneManager } from './SceneManager';
import { getItemWorldBounds } from './SceneManager';
interface RemoteSelection {
userId: string;
displayName: string;
color: number; // hex color, e.g. 0xff6600
itemIds: string[];
}
export class PresenceOverlay {
private _viewport: Viewport;
private _scene: SceneManager;
private _container: Container;
private _selections: Map<string, RemoteSelection> = new Map();
private _graphics: Map<string, Graphics> = new Map(); // per-user graphics
private _labels: Map<string, Text> = new Map(); // per-user name label
private _rafId: number | null = null;
constructor(viewport: Viewport, scene: SceneManager) {
this._viewport = viewport;
this._scene = scene;
this._container = new Container();
this._container.label = '__presence_overlay';
this._container.eventMode = 'none';
this._container.interactiveChildren = false;
viewport.addChild(this._container);
}
/** Update a remote user's selection. Empty array = deselected. */
updateSelection(userId: string, displayName: string, color: number, itemIds: string[]): void {
if (itemIds.length === 0) {
this._selections.delete(userId);
this._cleanupUser(userId);
} else {
this._selections.set(userId, { userId, displayName, color, itemIds });
}
this._scheduleRedraw();
}
/** Remove a user entirely (they disconnected / left). */
removeUser(userId: string): void {
this._selections.delete(userId);
this._cleanupUser(userId);
}
private _cleanupUser(userId: string): void {
const gfx = this._graphics.get(userId);
if (gfx) {
gfx.destroy();
this._graphics.delete(userId);
}
const label = this._labels.get(userId);
if (label) {
label.destroy();
this._labels.delete(userId);
}
}
private _scheduleRedraw(): void {
if (this._rafId !== null) return;
this._rafId = requestAnimationFrame(() => {
this._rafId = null;
this._redraw();
});
}
private _redraw(): void {
const zoom = this._viewport.scale.x;
for (const [userId, sel] of this._selections) {
// Get or create graphics for this user
let gfx = this._graphics.get(userId);
if (!gfx) {
gfx = new Graphics();
gfx.label = `__presence_${userId}`;
this._container.addChild(gfx);
this._graphics.set(userId, gfx);
}
gfx.clear();
// Get or create label
let label = this._labels.get(userId);
if (!label) {
label = new Text({
text: sel.displayName,
style: new TextStyle({
fontSize: 10,
fontFamily: 'system-ui, sans-serif',
fill: '#ffffff',
fontWeight: '600',
}),
});
label.label = `__presence_label_${userId}`;
this._container.addChild(label);
this._labels.set(userId, label);
}
label.text = sel.displayName;
label.visible = false; // hide until we know where to place it
label.scale.set(1 / zoom); // fixed screen-space size
// Draw border around each selected item, track first bounds for label
let firstBounds: { x: number; y: number; w: number; h: number } | null = null;
for (const itemId of sel.itemIds) {
const item = this._scene.getById(itemId);
if (!item) continue;
const b = getItemWorldBounds(item);
const pad = 2 / zoom;
gfx.rect(b.x - pad, b.y - pad, b.w + pad * 2, b.h + pad * 2);
gfx.stroke({ color: sel.color, width: 1.5 / zoom, alpha: 0.7 });
if (!firstBounds) firstBounds = b;
}
// Position name-label pill above the first selected item
if (firstBounds) {
// Draw background pill first so text renders on top
const labelOffsetY = 16 / zoom;
const px = 3 / zoom;
// We need label dimensions — make it visible and measure
label.visible = true;
label.position.set(firstBounds.x, firstBounds.y - labelOffsetY);
const lw = label.width;
const lh = label.height;
gfx.roundRect(
firstBounds.x - px,
firstBounds.y - labelOffsetY - px / 2,
lw + px * 2,
lh + px,
2 / zoom,
);
gfx.fill({ color: sel.color, alpha: 0.85 });
}
}
// Clean up graphics/labels for users no longer in _selections
for (const userId of this._graphics.keys()) {
if (!this._selections.has(userId)) {
this._cleanupUser(userId);
}
}
}
/** Call when items may have moved to keep borders in sync. */
refresh(): void {
if (this._selections.size > 0) this._redraw();
}
destroy(): void {
if (this._rafId !== null) cancelAnimationFrame(this._rafId);
this._container.destroy({ children: true });
this._graphics.clear();
this._labels.clear();
this._selections.clear();
}
}