Add inline text editing on double-click for RefBoard text elements
Introduces TextEditor utility that overlays a styled <textarea> matching the PixiJS Text object's font, size, and color. Hides the PixiJS text while editing, commits on blur/Enter, cancels on Escape, and updates dimensions from measured bounds on save.
This commit is contained in:
@@ -7,10 +7,10 @@
|
||||
|
||||
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import { SceneManager, SceneItem } from './SceneManager';
|
||||
import { SceneManager, SceneItem, getItemWorldBounds, isGroupChild } from './SceneManager';
|
||||
import { TransformBox } from './TransformBox';
|
||||
import { ImageSprite } from './sprites/ImageSprite';
|
||||
import { Spring, PRESETS } from './spring';
|
||||
import { VideoSprite } from './sprites/VideoSprite';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -23,7 +23,6 @@ const BAND_STROKE_ALPHA = 0.6;
|
||||
const BAND_STROKE_WIDTH = 1;
|
||||
const RUBBER_BAND_THRESHOLD = 5; // px in screen space before rubber band activates
|
||||
const DRAG_THRESHOLD = 5; // px in screen space before object drag activates
|
||||
const DRAG_LIFT_SCALE = 1.03; // scale during drag
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SelectionManager
|
||||
@@ -38,6 +37,7 @@ export class SelectionManager {
|
||||
private _overlay: Container;
|
||||
private _bandGfx: Graphics;
|
||||
private _onSelectionChange: ((ids: string[]) => void) | null = null;
|
||||
private _onItemTransform: ((item: SceneItem) => void) | null = null;
|
||||
|
||||
// Pointer state
|
||||
private _pointerDown = false;
|
||||
@@ -53,6 +53,15 @@ export class SelectionManager {
|
||||
private _lastDragWorldX = 0;
|
||||
private _lastDragWorldY = 0;
|
||||
|
||||
// Double-click detection
|
||||
private _lastClickTime = 0;
|
||||
private _lastClickItemId: string | null = null;
|
||||
private static readonly DOUBLE_CLICK_MS = 400;
|
||||
|
||||
private _onDoubleClickText: ((item: SceneItem) => void) | null = null;
|
||||
|
||||
private _enabled = true;
|
||||
|
||||
constructor(viewport: Viewport, scene: SceneManager) {
|
||||
this._viewport = viewport;
|
||||
this._scene = scene;
|
||||
@@ -79,12 +88,32 @@ export class SelectionManager {
|
||||
viewport.on('pointerupoutside', this._onPointerUp, this);
|
||||
}
|
||||
|
||||
/** Enable/disable selection interaction (disable during draw/text/eraser tools). */
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._enabled = enabled;
|
||||
if (!enabled) {
|
||||
this._pointerDown = false;
|
||||
this._rubberBanding = false;
|
||||
this._bandGfx.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
set onSelectionChange(fn: (ids: string[]) => void) {
|
||||
this._onSelectionChange = fn;
|
||||
}
|
||||
|
||||
/** Called during drag with each moved item for live sync broadcast. */
|
||||
set onItemTransform(fn: (item: SceneItem) => void) {
|
||||
this._onItemTransform = fn;
|
||||
}
|
||||
|
||||
/** Called on double-click of a text item (for inline editing). */
|
||||
set onDoubleClickText(fn: (item: SceneItem) => void) {
|
||||
this._onDoubleClickText = fn;
|
||||
}
|
||||
|
||||
/** Select only this item, deselecting everything else. */
|
||||
selectOnly(id: string): void {
|
||||
this.selectedIds.clear();
|
||||
@@ -105,7 +134,7 @@ export class SelectionManager {
|
||||
/** Select all unlocked, visible items. */
|
||||
selectAll(): void {
|
||||
this.selectedIds.clear();
|
||||
for (const item of this._scene.getAllItems()) {
|
||||
for (const item of this._scene.getTopLevelItems()) {
|
||||
if (!item.data.locked && item.data.visible) {
|
||||
this.selectedIds.add(item.id);
|
||||
}
|
||||
@@ -134,7 +163,7 @@ export class SelectionManager {
|
||||
|
||||
/** Check all scene items in reverse z-order; return first whose world bounds contain (wx, wy). */
|
||||
_hitTest(wx: number, wy: number): SceneItem | null {
|
||||
const all = this._scene.getAllItems();
|
||||
const all = this._scene.getTopLevelItems();
|
||||
// Sort by z descending (topmost first)
|
||||
all.sort((a, b) => b.data.z - a.data.z);
|
||||
|
||||
@@ -142,11 +171,7 @@ export class SelectionManager {
|
||||
if (item.data.locked) continue;
|
||||
if (!item.data.visible) continue;
|
||||
|
||||
// Use item.data (world-space) instead of getBounds() (screen-space)
|
||||
const ix = item.data.x;
|
||||
const iy = item.data.y;
|
||||
const iw = item.data.w * Math.abs(item.data.sx);
|
||||
const ih = item.data.h * Math.abs(item.data.sy);
|
||||
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||
|
||||
if (ix <= wx && wx <= ix + iw && iy <= wy && wy <= iy + ih) {
|
||||
return item;
|
||||
@@ -158,6 +183,7 @@ export class SelectionManager {
|
||||
// -- Pointer Handlers -----------------------------------------------------
|
||||
|
||||
private _onPointerDown(e: FederatedPointerEvent): void {
|
||||
if (!this._enabled) return;
|
||||
// Only handle primary button
|
||||
if (e.button !== 0) return;
|
||||
|
||||
@@ -212,14 +238,16 @@ export class SelectionManager {
|
||||
this._lastDragWorldX = currentWorld.x;
|
||||
this._lastDragWorldY = currentWorld.y;
|
||||
|
||||
// Move all selected items by delta
|
||||
for (const item of this.getSelectedItems()) {
|
||||
// Move all selected items by delta and broadcast transforms
|
||||
const selected = this.getSelectedItems();
|
||||
for (const item of selected) {
|
||||
item.displayObject.x += ddx;
|
||||
item.displayObject.y += ddy;
|
||||
item.data.x = item.displayObject.x;
|
||||
item.data.y = item.displayObject.y;
|
||||
this._onItemTransform?.(item);
|
||||
}
|
||||
this.transformBox.update(this.getSelectedItems());
|
||||
this.transformBox.update(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -268,6 +296,26 @@ export class SelectionManager {
|
||||
if (!e.shiftKey) {
|
||||
this.clear();
|
||||
}
|
||||
} else if (this._hitItemOnDown && !this._objectDragging) {
|
||||
// Click (no drag) on an item — check for double-click
|
||||
const now = Date.now();
|
||||
const item = this._hitItemOnDown;
|
||||
if (
|
||||
this._lastClickItemId === item.id &&
|
||||
now - this._lastClickTime < SelectionManager.DOUBLE_CLICK_MS
|
||||
) {
|
||||
// Double-click detected
|
||||
if (item.displayObject instanceof VideoSprite) {
|
||||
item.displayObject.togglePlayPause();
|
||||
} else if (item.type === 'text') {
|
||||
this._onDoubleClickText?.(item);
|
||||
}
|
||||
this._lastClickTime = 0;
|
||||
this._lastClickItemId = null;
|
||||
} else {
|
||||
this._lastClickTime = now;
|
||||
this._lastClickItemId = item.id;
|
||||
}
|
||||
}
|
||||
|
||||
this._hitItemOnDown = null;
|
||||
@@ -275,43 +323,23 @@ export class SelectionManager {
|
||||
|
||||
// -- Drag Shadow Lift / Drop ----------------------------------------------
|
||||
|
||||
/** Lift shadows and spring-scale selected items up for drag. */
|
||||
/** Lift shadows on selected items for drag. */
|
||||
private _applyLift(): void {
|
||||
const items = this.getSelectedItems();
|
||||
for (const item of items) {
|
||||
const dobj = item.displayObject;
|
||||
|
||||
// Lift shadow on ImageSprites
|
||||
if (dobj instanceof ImageSprite) {
|
||||
dobj.liftShadow();
|
||||
for (const item of this.getSelectedItems()) {
|
||||
const obj = item.displayObject;
|
||||
if (obj instanceof ImageSprite || obj instanceof VideoSprite) {
|
||||
obj.liftShadow();
|
||||
}
|
||||
|
||||
// Spring scale 1.0 → 1.03
|
||||
const spring = new Spring(1.0, DRAG_LIFT_SCALE, PRESETS.snappy);
|
||||
spring.onUpdate = (v) => {
|
||||
dobj.scale.set(v, v);
|
||||
};
|
||||
this._scene.springs.add(spring);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop shadows and spring-scale selected items back to rest. */
|
||||
/** Drop shadows on selected items after drag. */
|
||||
private _applyDrop(): void {
|
||||
const items = this.getSelectedItems();
|
||||
for (const item of items) {
|
||||
const dobj = item.displayObject;
|
||||
|
||||
// Drop shadow on ImageSprites
|
||||
if (dobj instanceof ImageSprite) {
|
||||
dobj.dropShadow();
|
||||
for (const item of this.getSelectedItems()) {
|
||||
const obj = item.displayObject;
|
||||
if (obj instanceof ImageSprite || obj instanceof VideoSprite) {
|
||||
obj.dropShadow();
|
||||
}
|
||||
|
||||
// Spring scale 1.03 → 1.0
|
||||
const spring = new Spring(DRAG_LIFT_SCALE, 1.0, PRESETS.snappy);
|
||||
spring.onUpdate = (v) => {
|
||||
dobj.scale.set(v, v);
|
||||
};
|
||||
this._scene.springs.add(spring);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,14 +370,10 @@ export class SelectionManager {
|
||||
this.selectedIds.clear();
|
||||
}
|
||||
|
||||
for (const item of this._scene.getAllItems()) {
|
||||
for (const item of this._scene.getTopLevelItems()) {
|
||||
if (item.data.locked || !item.data.visible) continue;
|
||||
|
||||
// Use world-space data instead of screen-space getBounds()
|
||||
const ix = item.data.x;
|
||||
const iy = item.data.y;
|
||||
const iw = item.data.w * Math.abs(item.data.sx);
|
||||
const ih = item.data.h * Math.abs(item.data.sy);
|
||||
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||
|
||||
// Check intersection between rubber band rect and item world bounds
|
||||
const intersects =
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* TextEditor — inline text editing overlay for PixiJS Text objects.
|
||||
*
|
||||
* Shows an absolutely-positioned <textarea> over the text item,
|
||||
* matching its font, size (scaled by zoom), and color.
|
||||
* Commits on blur/Enter, cancels on Escape.
|
||||
*/
|
||||
|
||||
import { Text } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { SceneItem } from './SceneManager';
|
||||
import type { TextObject } from './scene-format';
|
||||
|
||||
export class TextEditor {
|
||||
private _textarea: HTMLTextAreaElement | null = null;
|
||||
private _item: SceneItem | null = null;
|
||||
private _viewport: Viewport | null = null;
|
||||
private _container: HTMLElement | null = null;
|
||||
private _onChange: (() => void) | null = null;
|
||||
private _originalText: string = '';
|
||||
|
||||
/** True when a textarea is open. */
|
||||
get isEditing(): boolean {
|
||||
return this._textarea !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an inline textarea over the given text item.
|
||||
*/
|
||||
startEditing(
|
||||
item: SceneItem,
|
||||
viewport: Viewport,
|
||||
container: HTMLElement,
|
||||
onChange: () => void,
|
||||
): void {
|
||||
// Only text items
|
||||
if (item.type !== 'text') return;
|
||||
const pixiText = item.displayObject;
|
||||
if (!(pixiText instanceof Text)) return;
|
||||
|
||||
// Prevent double-open
|
||||
if (this._textarea) this.stopEditing(false);
|
||||
|
||||
this._item = item;
|
||||
this._viewport = viewport;
|
||||
this._container = container;
|
||||
this._onChange = onChange;
|
||||
|
||||
const data = item.data as TextObject;
|
||||
this._originalText = data.text;
|
||||
|
||||
// Hide the PixiJS text while editing
|
||||
pixiText.visible = false;
|
||||
|
||||
// Compute screen position of the text
|
||||
const worldPos = pixiText.getGlobalPosition();
|
||||
const renderer = viewport.parent?.parent; // stage -> app (not reliable)
|
||||
// Use viewport.toScreen to convert world → screen coords
|
||||
const screen = viewport.toScreen(item.data.x, item.data.y);
|
||||
const zoom = viewport.scale.x;
|
||||
|
||||
// Scaled font size
|
||||
const scaledFontSize = data.fontSize * zoom * Math.abs(item.data.sx);
|
||||
|
||||
// Create textarea
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = data.text;
|
||||
ta.style.position = 'absolute';
|
||||
ta.style.left = `${screen.x}px`;
|
||||
ta.style.top = `${screen.y}px`;
|
||||
ta.style.fontSize = `${scaledFontSize}px`;
|
||||
ta.style.fontFamily = data.fontFamily;
|
||||
ta.style.color = data.fill;
|
||||
ta.style.background = 'transparent';
|
||||
ta.style.border = 'none';
|
||||
ta.style.outline = 'none';
|
||||
ta.style.resize = 'none';
|
||||
ta.style.overflow = 'hidden';
|
||||
ta.style.padding = '0';
|
||||
ta.style.margin = '0';
|
||||
ta.style.lineHeight = '1.2';
|
||||
ta.style.whiteSpace = 'pre';
|
||||
ta.style.zIndex = '1000';
|
||||
ta.style.minWidth = '20px';
|
||||
ta.style.minHeight = `${scaledFontSize * 1.3}px`;
|
||||
ta.style.transformOrigin = 'top left';
|
||||
// Apply rotation if any
|
||||
if (item.data.angle) {
|
||||
ta.style.transform = `rotate(${item.data.angle}deg)`;
|
||||
}
|
||||
|
||||
// Auto-size the textarea to fit content
|
||||
const autoSize = () => {
|
||||
ta.style.height = 'auto';
|
||||
ta.style.width = 'auto';
|
||||
// Use a hidden measurement trick: set to scroll dimensions
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
ta.style.width = `${Math.max(ta.scrollWidth + 4, 20)}px`;
|
||||
};
|
||||
|
||||
// Event handlers
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
e.stopPropagation(); // Prevent canvas shortcuts
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.stopEditing(false);
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.stopEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
// Small delay to allow Escape to fire first
|
||||
setTimeout(() => {
|
||||
if (this._textarea === ta) {
|
||||
this.stopEditing(true);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
autoSize();
|
||||
};
|
||||
|
||||
ta.addEventListener('keydown', onKeyDown);
|
||||
ta.addEventListener('blur', onBlur);
|
||||
ta.addEventListener('input', onInput);
|
||||
|
||||
container.appendChild(ta);
|
||||
this._textarea = ta;
|
||||
|
||||
// Initial size and focus
|
||||
autoSize();
|
||||
ta.focus();
|
||||
ta.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the textarea. If save=true, commit the text; otherwise restore original.
|
||||
*/
|
||||
stopEditing(save: boolean): void {
|
||||
const ta = this._textarea;
|
||||
const item = this._item;
|
||||
if (!ta || !item) return;
|
||||
|
||||
const pixiText = item.displayObject;
|
||||
if (!(pixiText instanceof Text)) return;
|
||||
|
||||
const data = item.data as TextObject;
|
||||
|
||||
if (save) {
|
||||
const newText = ta.value || this._originalText; // Don't allow empty
|
||||
data.text = newText;
|
||||
pixiText.text = newText;
|
||||
|
||||
// Update dimensions from measured PixiJS text bounds
|
||||
pixiText.visible = true;
|
||||
const bounds = pixiText.getLocalBounds();
|
||||
data.w = bounds.width;
|
||||
data.h = bounds.height;
|
||||
|
||||
this._onChange?.();
|
||||
} else {
|
||||
// Restore original text
|
||||
data.text = this._originalText;
|
||||
pixiText.text = this._originalText;
|
||||
pixiText.visible = true;
|
||||
}
|
||||
|
||||
// Remove textarea from DOM
|
||||
ta.remove();
|
||||
|
||||
// Reset state
|
||||
this._textarea = null;
|
||||
this._item = null;
|
||||
this._viewport = null;
|
||||
this._container = null;
|
||||
this._onChange = null;
|
||||
this._originalText = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
|
||||
import { SelectionManager } from '../canvas/SelectionManager';
|
||||
import { TextEditor } from '../canvas/TextEditor';
|
||||
import { setupSync, SyncHandle } from '../canvas/sync';
|
||||
import { setupDragDrop, setupPaste } from '../canvas/image-drop';
|
||||
import { UndoManager } from '../canvas/history';
|
||||
import { InboxZone } from '../canvas/InboxZone';
|
||||
import { connectSocket, disconnectSocket } from '../socket';
|
||||
|
||||
interface OnlineUser {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const CURSOR_COLORS = [
|
||||
'#ff6b6b', '#ffa94d', '#ffd43b', '#69db7c', '#38d9a9',
|
||||
'#4dabf7', '#7950f2', '#e64980', '#20c997', '#ff922b',
|
||||
];
|
||||
|
||||
function userColor(userId: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash) + userId.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
||||
}
|
||||
|
||||
interface CanvasSetupDeps {
|
||||
boardData: any;
|
||||
resolvedBoardId: string | undefined;
|
||||
user: any;
|
||||
isPublicView?: boolean;
|
||||
canvasRef: React.RefObject<PixiCanvasHandle | null>;
|
||||
selectionRef: React.MutableRefObject<SelectionManager | null>;
|
||||
undoRef: React.MutableRefObject<UndoManager | null>;
|
||||
syncRef: React.MutableRefObject<SyncHandle | null>;
|
||||
inboxZoneRef: React.MutableRefObject<InboxZone | null>;
|
||||
onCanvasChange: () => void;
|
||||
showToast: (msg: string) => void;
|
||||
setOnlineUsers: React.Dispatch<React.SetStateAction<OnlineUser[]>>;
|
||||
setSelectedLayerIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the canvas infrastructure: selection, undo, sync, socket, drag/drop, paste, inbox zone.
|
||||
* Runs once when boardData is loaded and canvas is ready.
|
||||
*/
|
||||
export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
const {
|
||||
boardData, resolvedBoardId, user, isPublicView,
|
||||
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef,
|
||||
onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
||||
} = deps;
|
||||
|
||||
const dropCleanupRef = useRef<(() => void) | null>(null);
|
||||
const pasteCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardData || !resolvedBoardId) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const scene = canvasRef.current?.getScene();
|
||||
const viewport = canvasRef.current?.getViewport();
|
||||
if (!scene || !viewport) return;
|
||||
|
||||
// Create SelectionManager
|
||||
const selection = new SelectionManager(viewport, scene);
|
||||
selectionRef.current = selection;
|
||||
|
||||
// Wire selection change to update layer panel state
|
||||
selection.onSelectionChange = (ids: string[]) => {
|
||||
setSelectedLayerIds(ids);
|
||||
};
|
||||
|
||||
// Inline text editing on double-click
|
||||
const textEditor = new TextEditor();
|
||||
// Find the DOM container for the canvas (parent of the <canvas> element)
|
||||
const canvasElements = document.querySelectorAll('canvas');
|
||||
let domContainer: HTMLElement | null = null;
|
||||
for (const c of canvasElements) {
|
||||
if (c.parentElement && c.width > 100) {
|
||||
domContainer = c.parentElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
selection.onDoubleClickText = (item) => {
|
||||
if (!domContainer) return;
|
||||
textEditor.startEditing(item, viewport, domContainer, () => {
|
||||
syncRef.current?.broadcastElements([item.id]);
|
||||
onCanvasChange();
|
||||
});
|
||||
};
|
||||
|
||||
// Refresh transform box when item dimensions change (e.g. video metadata loaded)
|
||||
scene.onItemDimensionsChanged = (itemId: string) => {
|
||||
if (selection.selectedIds.has(itemId)) {
|
||||
selection.transformBox.update(selection.getSelectedItems());
|
||||
}
|
||||
};
|
||||
|
||||
// Create UndoManager
|
||||
undoRef.current = new UndoManager(scene);
|
||||
|
||||
// Create InboxZone and add to viewport
|
||||
const inboxZone = new InboxZone(scene.textures, scene.springs);
|
||||
viewport.addChild(inboxZone);
|
||||
inboxZoneRef.current = inboxZone;
|
||||
|
||||
if (user) {
|
||||
const socket = connectSocket();
|
||||
|
||||
syncRef.current = setupSync(scene, socket, resolvedBoardId, {
|
||||
onRemoteTransform: (item) => {
|
||||
if (selection.selectedIds.has(item.id)) {
|
||||
selection.transformBox.update(selection.getSelectedItems());
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Wire live drag/resize transforms to sync broadcast
|
||||
selection.onItemTransform = (item) => {
|
||||
syncRef.current?.broadcastTransform(item);
|
||||
};
|
||||
selection.transformBox.onItemTransform = (item) => {
|
||||
syncRef.current?.broadcastTransform(item);
|
||||
};
|
||||
|
||||
socket.on('user:joined', (data: any) => {
|
||||
const uid = data.userId || data.id;
|
||||
const name = data.displayName || data.display_name || data.username || '';
|
||||
setOnlineUsers((prev) => {
|
||||
if (prev.find((u) => u.userId === uid)) return prev;
|
||||
return [...prev, { userId: uid, displayName: name, color: userColor(uid) }];
|
||||
});
|
||||
if (name) showToast(`${name} joined`);
|
||||
});
|
||||
|
||||
socket.on('user:left', (data: any) => {
|
||||
const uid = data.userId || data.id;
|
||||
const name = data.displayName || data.display_name || data.username || '';
|
||||
setOnlineUsers((prev) => prev.filter((u) => u.userId !== uid));
|
||||
if (name) showToast(`${name} left`);
|
||||
});
|
||||
|
||||
socket.on('room:users', (data: any) => {
|
||||
if (Array.isArray(data.users)) {
|
||||
setOnlineUsers(data.users.map((u: any) => ({
|
||||
userId: u.userId || u.id,
|
||||
displayName: u.displayName || u.display_name || u.username || '',
|
||||
color: userColor(u.userId || u.id),
|
||||
})));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('board:media-arrived', (data: any) => {
|
||||
const assets = data?.assets;
|
||||
if (Array.isArray(assets) && assets.length > 0 && inboxZoneRef.current) {
|
||||
inboxZoneRef.current.addMedia(assets);
|
||||
showToast(`${assets.length} image${assets.length !== 1 ? 's' : ''} arrived in Inbox`);
|
||||
}
|
||||
});
|
||||
|
||||
// Cursor tracking — throttled to ~30fps to avoid flooding the socket
|
||||
let cursorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onPointerMove = (e: any) => {
|
||||
if (cursorTimer) return;
|
||||
const world = viewport.toWorld(e.global.x, e.global.y);
|
||||
socket.emit('cursor:move', {
|
||||
boardId: resolvedBoardId,
|
||||
x: world.x,
|
||||
y: world.y,
|
||||
});
|
||||
cursorTimer = setTimeout(() => { cursorTimer = null; }, 33);
|
||||
};
|
||||
viewport.on('pointermove', onPointerMove);
|
||||
}
|
||||
|
||||
// Setup drag/drop and paste
|
||||
if (!isPublicView || user) {
|
||||
const canvasElements = document.querySelectorAll('canvas');
|
||||
let domContainer: HTMLElement | null = null;
|
||||
for (const c of canvasElements) {
|
||||
if (c.parentElement && c.width > 100) {
|
||||
domContainer = c.parentElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (domContainer) {
|
||||
dropCleanupRef.current = setupDragDrop(domContainer, viewport, scene, resolvedBoardId, onCanvasChange);
|
||||
pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange);
|
||||
} else {
|
||||
pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
selectionRef.current?.destroy();
|
||||
selectionRef.current = null;
|
||||
if (inboxZoneRef.current) {
|
||||
inboxZoneRef.current.clear();
|
||||
inboxZoneRef.current.destroy({ children: true });
|
||||
inboxZoneRef.current = null;
|
||||
}
|
||||
syncRef.current?.cleanup();
|
||||
dropCleanupRef.current?.();
|
||||
pasteCleanupRef.current?.();
|
||||
disconnectSocket();
|
||||
};
|
||||
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, setOnlineUsers, setSelectedLayerIds]);
|
||||
}
|
||||
Reference in New Issue
Block a user