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
+3
View File
@@ -0,0 +1,3 @@
**/node_modules
**/.git
**/.env
+3
View File
@@ -116,6 +116,8 @@ function getImageUrl(minioPath) {
return `/api/images/${minioPath}`; return `/api/images/${minioPath}`;
} }
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE_MB || '200', 10) * 1024 * 1024;
module.exports = { module.exports = {
minioClient, minioClient,
initBucket, initBucket,
@@ -126,4 +128,5 @@ module.exports = {
getImageUrl, getImageUrl,
MINIO_BUCKET, MINIO_BUCKET,
MIME_TO_EXT, MIME_TO_EXT,
MAX_FILE_SIZE,
}; };
+10 -38
View File
@@ -7,7 +7,6 @@ const {
createImage, getImageByMmFileId, createImage, getImageByMmFileId,
} = require('../db'); } = require('../db');
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio'); const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
const { generateLOD } = require('../services/lod-generator');
const sharp = require('sharp'); const sharp = require('sharp');
const router = Router(); const router = Router();
@@ -94,44 +93,23 @@ function classifyMedia(mimeType) {
} }
/** /**
* Upload image buffer with LOD tiers to MinIO. * Upload a media file (image or video) to MinIO as a single file.
* GPU handles all scaling natively — no LOD tiers needed.
*/ */
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) { async function uploadMedia(boardId, imageId, buffer, mimetype) {
const assetKey = `boards/${boardId}/${imageId}`; const ext = MIME_TO_EXT[mimetype] || '.bin';
const originalExt = MIME_TO_EXT[mimetype] || '.bin'; const minioPath = `boards/${boardId}/${imageId}${ext}`;
await putBuffer(minioPath, buffer, mimetype);
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') { let width = null, height = null;
const fullPath = `${assetKey}/full${originalExt}`; if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) {
await putBuffer(fullPath, buffer, mimetype);
let width = null, height = null;
try { try {
const metadata = await sharp(buffer).metadata(); const metadata = await sharp(buffer).metadata();
width = metadata.width || null; width = metadata.width || null;
height = metadata.height || null; height = metadata.height || null;
} catch {} } catch {}
return { assetKey, minioPath: fullPath, width, height };
} }
return { assetKey: minioPath, minioPath, width, height };
const lod = await generateLOD(buffer, originalExt);
await Promise.all([
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
]);
const minioPath = `${assetKey}/full${lod.full.ext}`;
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
}
/**
* Upload video buffer to MinIO (no LOD).
*/
async function uploadVideo(boardId, imageId, buffer, mimetype) {
const assetKey = `boards/${boardId}/${imageId}`;
const ext = MIME_TO_EXT[mimetype] || '.bin';
const fullPath = `${assetKey}/full${ext}`;
await putBuffer(fullPath, buffer, mimetype);
return { assetKey, minioPath: fullPath, width: null, height: null };
} }
// --------------------- // ---------------------
@@ -287,13 +265,7 @@ router.post('/:boardId/mm-pull', async (req, res) => {
const imageId = uuidv4(); const imageId = uuidv4();
const mediaType = classifyMedia(mimeType); const mediaType = classifyMedia(mimeType);
let assetKey, minioPath, width, height; const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType);
if (mediaType === 'video') {
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimeType));
} else {
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimeType));
}
const publicUrl = getImageUrl(minioPath); const publicUrl = getImageUrl(minioPath);
+17 -61
View File
@@ -7,8 +7,7 @@ const http = require('http');
const { URL } = require('url'); const { URL } = require('url');
const { authMiddleware } = require('../auth'); const { authMiddleware } = require('../auth');
const { getBoard, getCollectionMember, createImage } = require('../db'); const { getBoard, getCollectionMember, createImage } = require('../db');
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio'); const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
const { generateLOD } = require('../services/lod-generator');
const router = Router(); const router = Router();
@@ -28,9 +27,9 @@ const VIDEO_MIME_TYPES = [
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES]; const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES];
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB const MAX_FILE_SIZE_LABEL = `${MAX_FILE_SIZE / 1024 / 1024}MB`;
// Multer config: memory storage, 50MB limit, image types only // Multer config: memory storage, configurable size limit
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE }, limits: { fileSize: MAX_FILE_SIZE },
@@ -90,46 +89,17 @@ function classifyMedia(mimeType) {
} }
/** /**
* Upload LOD tiers for an image to MinIO. * Upload a media file (image or video) to MinIO as a single file.
* Stores: {assetKey}/thumb.webp, {assetKey}/medium.webp, {assetKey}/full{ext} * GPU handles all image scaling natively — no LOD tiers needed.
* Returns the minioPath for the full tier (backward compat) and dimensions.
*/ */
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) { async function uploadMedia(boardId, imageId, buffer, mimetype) {
const assetKey = `boards/${boardId}/${imageId}`;
const originalExt = MIME_TO_EXT[mimetype] || '.bin';
// SVG and GIF skip LOD — store single file
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') {
const fullPath = `${assetKey}/full${originalExt}`;
await putBuffer(fullPath, buffer, mimetype);
const dims = await getImageDimensions(buffer, mimetype);
return { assetKey, minioPath: fullPath, ...dims };
}
const lod = await generateLOD(buffer, originalExt);
// Upload all 3 tiers in parallel
await Promise.all([
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
]);
// minioPath points to full tier for backward compat
const minioPath = `${assetKey}/full${lod.full.ext}`;
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
}
/**
* Upload a video file to MinIO (single file, no LOD).
* Returns { assetKey, minioPath, width: null, height: null }.
*/
async function uploadVideo(boardId, imageId, buffer, mimetype) {
const assetKey = `boards/${boardId}/${imageId}`;
const ext = MIME_TO_EXT[mimetype] || '.bin'; const ext = MIME_TO_EXT[mimetype] || '.bin';
const fullPath = `${assetKey}/full${ext}`; const minioPath = `boards/${boardId}/${imageId}${ext}`;
await putBuffer(fullPath, buffer, mimetype); await putBuffer(minioPath, buffer, mimetype);
return { assetKey, minioPath: fullPath, width: null, height: null };
const dims = await getImageDimensions(buffer, mimetype);
// assetKey = minioPath so frontend can build direct URLs
return { assetKey: minioPath, minioPath, width: dims.width, height: dims.height };
} }
/** /**
@@ -149,14 +119,7 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
const { buffer, originalname, mimetype, size } = req.file; const { buffer, originalname, mimetype, size } = req.file;
const mediaType = classifyMedia(mimetype); const mediaType = classifyMedia(mimetype);
let assetKey, minioPath, width, height; const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimetype);
if (mediaType === 'video') {
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimetype));
} else {
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimetype));
}
const publicUrl = getImageUrl(minioPath); const publicUrl = getImageUrl(minioPath);
// Save record // Save record
@@ -188,7 +151,7 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
}); });
} catch (err) { } catch (err) {
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large (max 50MB)' }); return res.status(413).json({ error: `File too large (max ${MAX_FILE_SIZE_LABEL})` });
} }
console.error('[upload] error:', err); console.error('[upload] error:', err);
return res.status(500).json({ error: 'Internal server error' }); return res.status(500).json({ error: 'Internal server error' });
@@ -225,7 +188,7 @@ function downloadImage(imageUrl) {
totalSize += chunk.length; totalSize += chunk.length;
if (totalSize > MAX_FILE_SIZE) { if (totalSize > MAX_FILE_SIZE) {
response.destroy(); response.destroy();
return reject(new Error('Downloaded file too large (max 50MB)')); return reject(new Error(`Downloaded file too large (max ${MAX_FILE_SIZE_LABEL})`));
} }
chunks.push(chunk); chunks.push(chunk);
}); });
@@ -261,14 +224,7 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
const imageId = uuidv4(); const imageId = uuidv4();
const mediaType = classifyMedia(mimeType); const mediaType = classifyMedia(mimeType);
let assetKey, minioPath, width, height; const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType);
if (mediaType === 'video') {
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimeType));
} else {
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimeType));
}
const publicUrl = getImageUrl(minioPath); const publicUrl = getImageUrl(minioPath);
// Save record // Save record
@@ -311,7 +267,7 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
router.use((err, req, res, next) => { router.use((err, req, res, next) => {
if (err instanceof multer.MulterError) { if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large (max 50MB)' }); return res.status(413).json({ error: `File too large (max ${MAX_FILE_SIZE_LABEL})` });
} }
return res.status(400).json({ error: err.message }); return res.status(400).json({ error: err.message });
} }
+18 -49
View File
@@ -12,8 +12,7 @@ const { URL } = require('url');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const sharp = require('sharp'); const sharp = require('sharp');
const { getAllBoardChannelLinks, getImageByMmFileId, createImage, getBoard } = require('../db'); const { getAllBoardChannelLinks, getImageByMmFileId, createImage, getBoard } = require('../db');
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio'); const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
const { generateLOD } = require('./lod-generator');
const MM_URL = process.env.MM_URL; const MM_URL = process.env.MM_URL;
const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN; const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN;
@@ -87,7 +86,7 @@ function mmDownloadFile(fileId) {
const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim(); const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
const chunks = []; const chunks = [];
let totalSize = 0; let totalSize = 0;
const MAX_SIZE = 50 * 1024 * 1024; const MAX_SIZE = MAX_FILE_SIZE;
res.on('data', (chunk) => { res.on('data', (chunk) => {
totalSize += chunk.length; totalSize += chunk.length;
@@ -147,47 +146,23 @@ function classifyMedia(mimeType) {
} }
/** /**
* Upload image with LOD tiers (mirrors upload.js logic). * Upload a media file (image or video) to MinIO as a single file.
* GPU handles all image scaling natively — no LOD tiers needed.
*/ */
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) { async function uploadMedia(boardId, imageId, buffer, mimetype) {
const assetKey = `boards/${boardId}/${imageId}`;
const originalExt = MIME_TO_EXT[mimetype] || '.bin';
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') {
const fullPath = `${assetKey}/full${originalExt}`;
await putBuffer(fullPath, buffer, mimetype);
let width = null, height = null;
if (mimetype !== 'image/svg+xml') {
try {
const meta = await sharp(buffer).metadata();
width = meta.width || null;
height = meta.height || null;
} catch {}
}
return { assetKey, minioPath: fullPath, width, height };
}
const lod = await generateLOD(buffer, originalExt);
await Promise.all([
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
]);
const minioPath = `${assetKey}/full${lod.full.ext}`;
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
}
/**
* Upload a video file (single file, no LOD).
*/
async function uploadVideo(boardId, imageId, buffer, mimetype) {
const assetKey = `boards/${boardId}/${imageId}`;
const ext = MIME_TO_EXT[mimetype] || '.bin'; const ext = MIME_TO_EXT[mimetype] || '.bin';
const fullPath = `${assetKey}/full${ext}`; const minioPath = `boards/${boardId}/${imageId}${ext}`;
await putBuffer(fullPath, buffer, mimetype); await putBuffer(minioPath, buffer, mimetype);
return { assetKey, minioPath: fullPath, width: null, height: null };
let width = null, height = null;
if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) {
try {
const meta = await sharp(buffer).metadata();
width = meta.width || null;
height = meta.height || null;
} catch {}
}
return { assetKey: minioPath, minioPath, width, height };
} }
/** /**
@@ -239,13 +214,7 @@ async function processLink(link) {
const imageId = uuidv4(); const imageId = uuidv4();
const mediaType = classifyMedia(mimeType); const mediaType = classifyMedia(mimeType);
let assetKey, minioPath, width, height; const { assetKey, minioPath, width, height } = await uploadMedia(boardId, imageId, buffer, mimeType);
if (mediaType === 'video') {
({ assetKey, minioPath, width, height } = await uploadVideo(boardId, imageId, buffer, mimeType));
} else {
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(boardId, imageId, buffer, mimeType));
}
const publicUrl = getImageUrl(minioPath); const publicUrl = getImageUrl(minioPath);
+46
View File
@@ -79,6 +79,26 @@ function setupBoardRoom(io, socket) {
}); });
}); });
// ---- Incremental element sync (Excalidraw-style) ----
socket.on('element:update', (data) => {
if (!socket.currentBoardId) return;
const roomName = getRoomName(socket.currentBoardId);
socket.to(roomName).emit('element:update', {
...data,
userId: socket.userId,
});
});
socket.on('element:remove', (data) => {
if (!socket.currentBoardId) return;
const roomName = getRoomName(socket.currentBoardId);
socket.to(roomName).emit('element:remove', {
...data,
userId: socket.userId,
});
});
// ---- Lightweight transform (during drag/resize/rotate) ---- // ---- Lightweight transform (during drag/resize/rotate) ----
socket.on('object:transform', (data) => { socket.on('object:transform', (data) => {
@@ -102,6 +122,32 @@ function setupBoardRoom(io, socket) {
}); });
}); });
// ---- Selection presence (broadcast what items a user has selected) ----
socket.on('selection:update', (data) => {
if (!socket.currentBoardId) return;
const roomName = getRoomName(socket.currentBoardId);
socket.volatile.to(roomName).emit('selection:update', {
...data,
userId: socket.userId,
displayName: socket.userDisplayName,
});
});
// ---- Laser pointer ----
socket.on('laser:move', (data) => {
if (!socket.currentBoardId) return;
const roomName = getRoomName(socket.currentBoardId);
socket.volatile.to(roomName).emit('laser:move', { ...data, userId: socket.userId });
});
socket.on('laser:stop', (data) => {
if (!socket.currentBoardId) return;
const roomName = getRoomName(socket.currentBoardId);
socket.to(roomName).emit('laser:stop', { ...data, userId: socket.userId });
});
// ---- Disconnect cleanup ---- // ---- Disconnect cleanup ----
socket.on('disconnect', () => { socket.on('disconnect', () => {
+2
View File
@@ -2,6 +2,7 @@ const { Server } = require('socket.io');
const { verifyToken } = require('../auth'); const { verifyToken } = require('../auth');
const { getUserById } = require('../db'); const { getUserById } = require('../db');
const { setupBoardRoom } = require('./board-room'); const { setupBoardRoom } = require('./board-room');
const { setupViewportSync } = require('./viewport-sync');
/** /**
* Set up Socket.IO on the given HTTP server. * Set up Socket.IO on the given HTTP server.
@@ -49,6 +50,7 @@ function setupSocket(httpServer) {
// Set up board room handlers // Set up board room handlers
setupBoardRoom(io, socket); setupBoardRoom(io, socket);
setupViewportSync(io, socket);
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
console.log(`[socket] User disconnected: ${socket.userDisplayName} (${reason})`); console.log(`[socket] User disconnected: ${socket.userDisplayName} (${reason})`);
+48
View File
@@ -0,0 +1,48 @@
/**
* Viewport sync relay — broadcasts user viewport positions for follow mode.
* Each user periodically emits their viewport state; this relays it to
* everyone else in the same board room so followers can track in real-time.
*
* Kept separate from board-room.js to avoid conflicts.
*/
function getRoomName(boardId) {
return `board:${boardId}`;
}
function setupViewportSync(io, socket) {
// Relay viewport position to other users in the same board room
socket.on('viewport:sync', (data) => {
if (!socket.currentBoardId) return;
const roomName = getRoomName(socket.currentBoardId);
socket.volatile.to(roomName).emit('viewport:sync', {
...data,
userId: socket.userId,
});
});
// follow:start / follow:stop are informational — the server doesn't need
// to gate viewport:sync relay (all users broadcast always, clients filter).
// These events exist so the UI can show "X is following you" if desired.
socket.on('follow:start', (data) => {
if (!socket.currentBoardId) return;
const roomName = getRoomName(socket.currentBoardId);
socket.to(roomName).emit('follow:start', {
boardId: data.boardId,
followerId: socket.userId,
followerName: socket.userDisplayName,
targetUserId: data.targetUserId,
});
});
socket.on('follow:stop', (data) => {
if (!socket.currentBoardId) return;
const roomName = getRoomName(socket.currentBoardId);
socket.to(roomName).emit('follow:stop', {
boardId: data.boardId,
followerId: socket.userId,
});
});
}
module.exports = { setupViewportSync };
+2 -2
View File
@@ -141,8 +141,8 @@ export class InboxZone extends Container {
sprite.width = sw; sprite.width = sw;
sprite.height = sh; sprite.height = sh;
// Load thumbnail texture // Load texture (GPU handles scaling)
this.textures.load(assetKey, 'thumb').then((tex) => { this.textures.load(assetKey).then((tex) => {
if (!sprite.destroyed) { if (!sprite.destroyed) {
sprite.texture = tex; sprite.texture = tex;
sprite.width = sw; sprite.width = sw;
+202
View File
@@ -0,0 +1,202 @@
import { Graphics } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
interface LaserPoint {
x: number;
y: number;
t: number;
}
export class LaserPointer {
private _gfx: Graphics;
private _viewport: Viewport;
private _points: LaserPoint[] = [];
private _active = false;
private _rafId: number | null = null;
private _color: number;
private _fadeMs = 2000;
private _maxPoints = 200;
// Remote user lasers
private _remoteGfx: Map<string, Graphics> = new Map();
private _remotePoints: Map<string, LaserPoint[]> = new Map();
constructor(viewport: Viewport, color: number = 0xff4444) {
this._viewport = viewport;
this._color = color;
this._gfx = new Graphics();
this._gfx.label = '__laser_local';
viewport.addChild(this._gfx);
}
get isActive(): boolean {
return this._active;
}
/** Begin recording trail, start render loop */
start(): void {
this._active = true;
if (this._rafId === null) {
this._tick();
}
}
/** Stop recording, let trail fade naturally, stop loop when empty */
stop(): void {
this._active = false;
// Render loop continues until all points have faded
}
/** Add a local laser point with current timestamp */
addPoint(worldX: number, worldY: number): void {
this._points.push({ x: worldX, y: worldY, t: performance.now() });
if (this._points.length > this._maxPoints) {
this._points.shift();
}
}
/** Add points from a remote user's laser */
addRemotePoints(
userId: string,
points: { x: number; y: number }[],
color: number,
): void {
const now = performance.now();
if (!this._remoteGfx.has(userId)) {
const gfx = new Graphics();
gfx.label = `__laser_remote_${userId}`;
this._viewport.addChild(gfx);
this._remoteGfx.set(userId, gfx);
}
let arr = this._remotePoints.get(userId);
if (!arr) {
arr = [];
this._remotePoints.set(userId, arr);
}
// Store color on the graphics object for rendering
const gfx = this._remoteGfx.get(userId)!;
(gfx as any)._laserColor = color;
for (const p of points) {
arr.push({ x: p.x, y: p.y, t: now });
}
if (arr.length > this._maxPoints) {
arr.splice(0, arr.length - this._maxPoints);
}
// Ensure render loop is running
if (this._rafId === null) {
this._tick();
}
}
/** Remove a remote user's laser entirely */
removeRemote(userId: string): void {
const gfx = this._remoteGfx.get(userId);
if (gfx) {
gfx.clear();
gfx.destroy();
this._remoteGfx.delete(userId);
}
this._remotePoints.delete(userId);
}
/** rAF-driven render loop */
private _tick = (): void => {
this._render();
// Check if there's anything left to draw
const hasLocal = this._points.length > 0;
let hasRemote = false;
for (const [, pts] of this._remotePoints) {
if (pts.length > 0) {
hasRemote = true;
break;
}
}
if (hasLocal || hasRemote || this._active) {
this._rafId = requestAnimationFrame(this._tick);
} else {
this._rafId = null;
}
};
private _render(): void {
const now = performance.now();
const scale = this._viewport.scale.x || 1;
const lineWidth = 3 / scale;
// --- Local laser ---
this._prunePoints(this._points, now);
this._drawTrail(this._gfx, this._points, this._color, lineWidth, now);
// --- Remote lasers ---
for (const [userId, pts] of this._remotePoints) {
this._prunePoints(pts, now);
const gfx = this._remoteGfx.get(userId);
if (!gfx) continue;
const color = (gfx as any)._laserColor ?? 0xff4444;
this._drawTrail(gfx, pts, color, lineWidth, now);
// Clean up empty remote lasers
if (pts.length === 0) {
gfx.clear();
// Don't destroy — they may send more points
}
}
}
private _prunePoints(points: LaserPoint[], now: number): void {
while (points.length > 0 && now - points[0].t > this._fadeMs) {
points.shift();
}
}
private _drawTrail(
gfx: Graphics,
points: LaserPoint[],
color: number,
lineWidth: number,
now: number,
): void {
gfx.clear();
if (points.length < 2) return;
for (let i = 1; i < points.length; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const age = now - p1.t;
const alpha = Math.max(0, 1 - age / this._fadeMs);
if (alpha <= 0) continue;
gfx.setStrokeStyle({ width: lineWidth, color, alpha, cap: 'round', join: 'round' });
gfx.moveTo(p0.x, p0.y);
gfx.lineTo(p1.x, p1.y);
gfx.stroke();
}
}
/** Clean up all resources */
destroy(): void {
if (this._rafId !== null) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
}
this._active = false;
this._points.length = 0;
this._gfx.clear();
this._gfx.destroy();
for (const [, gfx] of this._remoteGfx) {
gfx.clear();
gfx.destroy();
}
this._remoteGfx.clear();
this._remotePoints.clear();
}
}
+49 -33
View File
@@ -12,10 +12,11 @@ import {
useImperativeHandle, useImperativeHandle,
useRef, useRef,
useCallback, useCallback,
useState,
} from 'react'; } from 'react';
import { Application } from 'pixi.js'; import { Application } from 'pixi.js';
import { Viewport } from 'pixi-viewport'; import { Viewport } from 'pixi-viewport';
import { SceneManager } from './SceneManager'; import { SceneManager, getItemWorldBounds } from './SceneManager';
import { TextureManager } from './TextureManager'; import { TextureManager } from './TextureManager';
import { SpringManager } from './spring'; import { SpringManager } from './spring';
import { convertFabricToV2 } from './scene-format'; import { convertFabricToV2 } from './scene-format';
@@ -28,13 +29,14 @@ import type { SceneData } from './scene-format';
export interface PixiCanvasHandle { export interface PixiCanvasHandle {
getViewport: () => Viewport | null; getViewport: () => Viewport | null;
getScene: () => SceneManager | null; getScene: () => SceneManager | null;
getApp: () => Application | null;
fitAll: () => void; fitAll: () => void;
getZoom: () => number; getZoom: () => number;
setZoom: (zoom: number) => void; setZoom: (zoom: number) => void;
} }
export interface PixiCanvasProps { export interface PixiCanvasProps {
canvasState?: string | null; canvasState?: string | object | null;
currentTool: string; currentTool: string;
boardId?: string; boardId?: string;
onChange?: () => void; onChange?: () => void;
@@ -99,6 +101,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
const initialLoadDone = useRef(false); const initialLoadDone = useRef(false);
const spaceHeld = useRef(false); const spaceHeld = useRef(false);
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
const [pixiReady, setPixiReady] = useState(false);
// Keep onChange ref current without re-running effects // Keep onChange ref current without re-running effects
onChangeRef.current = onChange; onChangeRef.current = onChange;
@@ -121,6 +124,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
antialias: true, antialias: true,
autoDensity: true, autoDensity: true,
resolution: window.devicePixelRatio, resolution: window.devicePixelRatio,
preserveDrawingBuffer: true,
}); });
if (destroyed) { if (destroyed) {
@@ -160,7 +164,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
springs.tick(ticker.deltaMS / 1000); 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; let lastCullCheck = 0;
app.ticker.add((ticker) => { app.ticker.add((ticker) => {
@@ -168,29 +172,18 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
if (lastCullCheck < 200) return; if (lastCullCheck < 200) return;
lastCullCheck = 0; lastCullCheck = 0;
const zoom = viewport.scale.x;
const bounds = viewport.getVisibleBounds(); const bounds = viewport.getVisibleBounds();
const margin = 200; const margin = 200;
for (const item of scene.getAllItems()) { for (const item of scene.getAllItems()) {
const d = item.displayObject; if (item.type === 'video' && 'onVisibilityChange' in item.displayObject) {
const ib = d.getBounds(); const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
const inView = const inView =
ib.x + ib.width > bounds.x - margin && ix + iw > bounds.x - margin &&
ib.x < bounds.x + bounds.width + margin && ix < bounds.x + bounds.width + margin &&
ib.y + ib.height > bounds.y - margin && iy + ih > bounds.y - margin &&
ib.y < bounds.y + bounds.height + margin; iy < bounds.y + bounds.height + margin;
(item.displayObject as any).onVisibilityChange(inView);
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);
} }
} }
}); });
@@ -230,6 +223,18 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
// Store observer for cleanup // Store observer for cleanup
(container as any).__pixiRO = ro; (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 --------------------------------------------------------- // -- Cleanup ---------------------------------------------------------
@@ -243,6 +248,12 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
delete (container as any).__pixiRO; delete (container as any).__pixiRO;
} }
const wheelHandler = (container as any).__pixiWheelHandler;
if (wheelHandler) {
container.removeEventListener('wheel', wheelHandler);
delete (container as any).__pixiWheelHandler;
}
textures.clear(); textures.clear();
if (appRef.current) { if (appRef.current) {
@@ -263,11 +274,16 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
if (initialLoadDone.current) return; if (initialLoadDone.current) return;
if (!canvasState || !sceneRef.current) return; if (!canvasState || !sceneRef.current) return;
// canvasState may be a JSON string or already-parsed object
let parsed: any; let parsed: any;
try { if (typeof canvasState === 'string') {
parsed = JSON.parse(canvasState); try {
} catch { parsed = JSON.parse(canvasState);
return; } catch {
return;
}
} else {
parsed = canvasState;
} }
let sceneData: SceneData; let sceneData: SceneData;
@@ -279,7 +295,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
initialLoadDone.current = true; initialLoadDone.current = true;
sceneRef.current.loadScene(sceneData, false); sceneRef.current.loadScene(sceneData, false);
}, [canvasState]); }, [canvasState, pixiReady]);
// ── Space key for pan mode ──────────────────────────────────────── // ── Space key for pan mode ────────────────────────────────────────
@@ -337,12 +353,11 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
let maxY = -Infinity; let maxY = -Infinity;
for (const item of items) { for (const item of items) {
const obj = item.displayObject; const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
const bounds = obj.getBounds(); if (ix < minX) minX = ix;
if (bounds.x < minX) minX = bounds.x; if (iy < minY) minY = iy;
if (bounds.y < minY) minY = bounds.y; if (ix + iw > maxX) maxX = ix + iw;
if (bounds.x + bounds.width > maxX) maxX = bounds.x + bounds.width; if (iy + ih > maxY) maxY = iy + ih;
if (bounds.y + bounds.height > maxY) maxY = bounds.y + bounds.height;
} }
const padding = 40; const padding = 40;
@@ -368,6 +383,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
() => ({ () => ({
getViewport: () => viewportRef.current, getViewport: () => viewportRef.current,
getScene: () => sceneRef.current, getScene: () => sceneRef.current,
getApp: () => appRef.current,
fitAll, fitAll,
getZoom: () => viewportRef.current?.scale.x ?? 1, getZoom: () => viewportRef.current?.scale.x ?? 1,
setZoom: (zoom: number) => { setZoom: (zoom: number) => {
+172
View File
@@ -0,0 +1,172 @@
/**
* 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();
}
}
+286 -33
View File
@@ -5,16 +5,22 @@
* and spring-animated add/remove operations. * and spring-animated add/remove operations.
*/ */
import { Container, Sprite, Texture, Graphics, Text, TextStyle } from 'pixi.js'; import { Container, Graphics, Text, TextStyle } from 'pixi.js';
import type { Viewport } from 'pixi-viewport'; import type { Viewport } from 'pixi-viewport';
import { TextureManager, LODTier } from './TextureManager'; import { TextureManager } from './TextureManager';
import { ImageSprite } from './sprites/ImageSprite';
import { VideoSprite } from './sprites/VideoSprite';
import { DrawingSprite } from './sprites/DrawingSprite';
import { FrameSprite } from './sprites/FrameSprite';
import { SpringManager, Spring, PRESETS } from './spring'; import { SpringManager, Spring, PRESETS } from './spring';
import { reparentGroupChildren } from './grouping';
import type { import type {
SceneData, SceneData,
AnySceneObject, AnySceneObject,
ImageObject, ImageObject,
VideoObject, VideoObject,
TextObject, TextObject,
DrawingObject,
GroupObject, GroupObject,
SceneObject, SceneObject,
} from './scene-format'; } from './scene-format';
@@ -25,11 +31,106 @@ import type {
export interface SceneItem { export interface SceneItem {
id: string; id: string;
type: 'image' | 'video' | 'text' | 'group'; type: 'image' | 'video' | 'text' | 'drawing' | 'group';
displayObject: Container; displayObject: Container;
data: AnySceneObject; data: AnySceneObject;
} }
/** Set of child IDs that belong to a group — rebuilt when groups change. */
let _groupChildIds: Set<string> | null = null;
/** Rebuild the set of all group-child IDs. Call after group add/remove/load. */
export function rebuildGroupChildSet(scene: SceneManager): void {
_groupChildIds = new Set<string>();
for (const item of scene.items.values()) {
if (item.data.type !== 'group') continue;
const gd = item.data as GroupObject;
for (const cid of gd.children) _groupChildIds.add(cid);
}
}
/** Returns true if the item is a child of a group (not independently selectable). */
export function isGroupChild(id: string): boolean {
return _groupChildIds?.has(id) ?? false;
}
/** Single source of truth for an item's world-space bounding rect.
* Uses data.sx/sy (not obj.scale which may be mid-animation).
* For groups: computes the union of children bounds (w/h on group data may be 0).
* For group children: converts local coords to world using parent group transforms. */
export function getItemWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
if (item.data.type === 'group') {
return _getGroupWorldBounds(item);
}
// If this item is a child of a group, convert local → world
const parent = item.displayObject.parent;
if (parent && parent.label && _groupChildIds?.has(item.id)) {
const px = parent.position.x;
const py = parent.position.y;
const psx = parent.scale.x;
const psy = parent.scale.y;
return {
x: px + item.data.x * psx,
y: py + item.data.y * psy,
w: item.data.w * Math.abs(item.data.sx * psx),
h: item.data.h * Math.abs(item.data.sy * psy),
};
}
return {
x: item.data.x,
y: item.data.y,
w: item.data.w * Math.abs(item.data.sx),
h: item.data.h * Math.abs(item.data.sy),
};
}
/**
* Compute group bounds from its data.w/h (set during grouping) or fall back
* to stored children data. Children store LOCAL x/y relative to group.
*/
function _getGroupWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
const groupX = item.data.x;
const groupY = item.data.y;
const groupW = item.data.w;
const groupH = item.data.h;
// If group has valid w/h (set during creation), apply scale just like regular items
if (groupW > 0 && groupH > 0) {
return {
x: groupX,
y: groupY,
w: groupW * Math.abs(item.data.sx),
h: groupH * Math.abs(item.data.sy),
};
}
// Fallback: shouldn't happen, but compute from children display objects
const container = item.displayObject;
if (container.children.length === 0) {
return { x: groupX, y: groupY, w: 0, h: 0 };
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const child of container.children) {
const cx = child.x;
const cy = child.y;
// Approximate child size from its local bounds
const lb = child.getLocalBounds();
const cw = lb.width;
const ch = lb.height;
minX = Math.min(minX, cx);
minY = Math.min(minY, cy);
maxX = Math.max(maxX, cx + cw);
maxY = Math.max(maxY, cy + ch);
}
if (!isFinite(minX)) return { x: groupX, y: groupY, w: 0, h: 0 };
// Children positions are local to group, so offset by group world position
return { x: groupX + minX, y: groupY + minY, w: maxX - minX, h: maxY - minY };
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SceneManager // SceneManager
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -41,6 +142,7 @@ export class SceneManager {
readonly springs: SpringManager; readonly springs: SpringManager;
private _onChange: (() => void) | null = null; private _onChange: (() => void) | null = null;
private _onItemDimensionsChanged: ((itemId: string) => void) | null = null;
private _zCounter: number = 0; private _zCounter: number = 0;
constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) { constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) {
@@ -55,6 +157,10 @@ export class SceneManager {
this._onChange = fn; this._onChange = fn;
} }
set onItemDimensionsChanged(fn: ((itemId: string) => void) | null) {
this._onItemDimensionsChanged = fn;
}
get onChange(): (() => void) | null { get onChange(): (() => void) | null {
return this._onChange; return this._onChange;
} }
@@ -98,6 +204,21 @@ export class SceneManager {
} }
for (const id of toRemove) { for (const id of toRemove) {
const item = this.items.get(id)!; const item = this.items.get(id)!;
// If removing a group, reparent its PixiJS children to viewport first
// so they survive the container destruction (they may still be in the
// incoming set as ungrouped top-level items).
if (item.data.type === 'group') {
const container = item.displayObject;
// Reparent actual scene children (skip internal frame bg/label)
const toReparent = container.children.filter(
(c) => !c.label?.startsWith('__frame_'),
);
for (const child of toReparent) {
this.viewport.addChild(child);
}
}
item.displayObject.destroy({ children: true }); item.displayObject.destroy({ children: true });
this.items.delete(id); this.items.delete(id);
} }
@@ -121,7 +242,11 @@ export class SceneManager {
await Promise.all(loadPromises); await Promise.all(loadPromises);
// 4. Apply z-ordering // 4. Reparent group children into their group containers
reparentGroupChildren(this);
rebuildGroupChildSet(this);
// 5. Apply z-ordering
this._applyZOrder(); this._applyZOrder();
this._onChange?.(); this._onChange?.();
@@ -136,31 +261,18 @@ export class SceneManager {
switch (data.type) { switch (data.type) {
case 'image': { case 'image': {
const imgData = data as ImageObject; const imgData = data as ImageObject;
const sprite = new Sprite(Texture.EMPTY); displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
sprite.width = imgData.w;
sprite.height = imgData.h;
// Load texture asynchronously at appropriate LOD
const zoom = this.viewport.scale.x;
const tier = this.textures.tierForZoom(zoom);
this.textures.load(imgData.asset, tier).then((tex) => {
if (!sprite.destroyed) {
sprite.texture = tex;
sprite.width = imgData.w;
sprite.height = imgData.h;
}
});
displayObject = sprite;
break; break;
} }
case 'video': { case 'video': {
const vidData = data as VideoObject; const vidData = data as VideoObject;
const gfx = new Graphics(); const videoUrl = this.textures.urlForAsset(vidData.asset);
gfx.rect(0, 0, vidData.w, vidData.h); const videoSprite = new VideoSprite(vidData.asset, vidData.w, vidData.h, videoUrl);
gfx.fill(0x333333); // Auto-correct dimensions when video metadata loads
displayObject = gfx; // NOTE: callback must update item.data (the stored copy), not the original `data` param.
// We wire this after the item is created below (see post-creation video wiring).
displayObject = videoSprite;
break; break;
} }
@@ -175,8 +287,15 @@ export class SceneManager {
break; break;
} }
case 'drawing': {
const drawData = data as DrawingObject;
displayObject = new DrawingSprite(drawData.points, drawData.color, drawData.strokeWidth);
break;
}
case 'group': { case 'group': {
displayObject = new Container(); const gd = data as GroupObject;
displayObject = new FrameSprite(gd.w, gd.h, gd.bgColor, gd.label, gd.padding);
break; break;
} }
@@ -206,23 +325,52 @@ export class SceneManager {
this.items.set(data.id, item); this.items.set(data.id, item);
// Animate entrance: spring scale from 0 → 1.05 → 1.0 with fade-in // Wire video dimension auto-correction to the STORED item.data (not the original param)
if (data.type === 'video' && displayObject instanceof VideoSprite) {
displayObject.onDimensionsKnown = (realW, realH) => {
item.data.w = realW;
item.data.h = realH;
this._onChange?.();
this._onItemDimensionsChanged?.(item.id);
};
}
// Animate entrance: spring scale from center 0 → 1.05 → 1.0 with fade-in
if (animate) { if (animate) {
displayObject.scale.set(0, 0); displayObject.scale.set(0, 0);
displayObject.alpha = 0; displayObject.alpha = 0;
// Scale from center by adjusting position to keep center point fixed
const halfW = data.w * data.sx / 2;
const halfH = data.h * data.sy / 2;
const finalX = data.x;
const finalY = data.y;
const scaleSpring = new Spring(0, 1.05, PRESETS.bounce); const scaleSpring = new Spring(0, 1.05, PRESETS.bounce);
scaleSpring.onUpdate = (v) => { scaleSpring.onUpdate = (v) => {
if (!displayObject.destroyed) { if (!displayObject.destroyed) {
displayObject.scale.set(v * data.sx, v * data.sy); displayObject.scale.set(v * data.sx, v * data.sy);
displayObject.position.set(
finalX + halfW * (1 - v),
finalY + halfH * (1 - v),
);
} }
}; };
scaleSpring.onComplete = () => { scaleSpring.onComplete = () => {
// Second spring: 1.05 → 1.0
const settleSpring = new Spring(1.05, 1.0, PRESETS.snappy); const settleSpring = new Spring(1.05, 1.0, PRESETS.snappy);
settleSpring.onUpdate = (v) => { settleSpring.onUpdate = (v) => {
if (!displayObject.destroyed) { if (!displayObject.destroyed) {
displayObject.scale.set(v * data.sx, v * data.sy); displayObject.scale.set(v * data.sx, v * data.sy);
displayObject.position.set(
finalX + halfW * (1 - v),
finalY + halfH * (1 - v),
);
}
};
settleSpring.onComplete = () => {
if (!displayObject.destroyed) {
displayObject.position.set(finalX, finalY);
displayObject.scale.set(data.sx, data.sy);
} }
}; };
this.springs.add(settleSpring); this.springs.add(settleSpring);
@@ -252,6 +400,21 @@ export class SceneManager {
obj.visible = data.visible; obj.visible = data.visible;
obj.eventMode = data.locked ? 'none' : 'static'; obj.eventMode = data.locked ? 'none' : 'static';
// Type-specific updates
if (data.type === 'drawing' && obj instanceof DrawingSprite) {
const drawData = data as DrawingObject;
obj.setPoints(drawData.points);
}
if (data.type === 'text' && obj instanceof Text) {
const txtData = data as TextObject;
obj.text = txtData.text;
obj.style.fontSize = txtData.fontSize;
obj.style.fill = txtData.fill;
}
if (data.type === 'group' && obj instanceof FrameSprite) {
obj.updateFromData(data as GroupObject);
}
// Update stored data // Update stored data
item.data = { ...data }; item.data = { ...data };
item.type = data.type; item.type = data.type;
@@ -259,16 +422,47 @@ export class SceneManager {
// -- Z-Ordering ---------------------------------------------------------- // -- Z-Ordering ----------------------------------------------------------
/** Sort items by z and reorder children in the viewport. */ /** Sort items by z and reorder children in both viewport and group containers. */
_applyZOrder(): void { _applyZOrder(): void {
const sorted = Array.from(this.items.values()).sort( const sorted = Array.from(this.items.values()).sort(
(a, b) => a.data.z - b.data.z, (a, b) => a.data.z - b.data.z,
); );
for (let i = 0; i < sorted.length; i++) { // Reorder top-level items in the viewport
const child = sorted[i].displayObject; let vpIndex = 0;
for (const item of sorted) {
const child = item.displayObject;
if (child.parent === this.viewport) { if (child.parent === this.viewport) {
this.viewport.setChildIndex(child, i); // Clamp index to valid range (selection overlay etc. may also be viewport children)
const maxIdx = this.viewport.children.length - 1;
const targetIdx = Math.min(vpIndex, maxIdx);
if (this.viewport.getChildIndex(child) !== targetIdx) {
this.viewport.setChildIndex(child, targetIdx);
}
vpIndex++;
}
}
// Reorder children within each group container
for (const item of sorted) {
if (item.data.type !== 'group') continue;
const groupData = item.data as GroupObject;
const container = item.displayObject;
// Sort children by their z value within the group
const childItems = groupData.children
.map((id) => this.items.get(id))
.filter((c): c is SceneItem => !!c)
.sort((a, b) => a.data.z - b.data.z);
for (let i = 0; i < childItems.length; i++) {
const child = childItems[i].displayObject;
if (child.parent === container) {
const maxIdx = container.children.length - 1;
const targetIdx = Math.min(i, maxIdx);
if (container.getChildIndex(child) !== targetIdx) {
container.setChildIndex(child, targetIdx);
}
}
} }
} }
} }
@@ -283,13 +477,27 @@ export class SceneManager {
return Array.from(this.items.values()); return Array.from(this.items.values());
} }
/** Get only top-level items (excludes group children). Used for selection/hit testing. */
getTopLevelItems(): SceneItem[] {
return Array.from(this.items.values()).filter((item) => !isGroupChild(item.id));
}
// -- Remove Item --------------------------------------------------------- // -- Remove Item ---------------------------------------------------------
/** Remove an item, optionally animating scale→0.8 + fade out before destroy. */ /** Remove an item, optionally animating scale→0.8 + fade out before destroy.
* If item is a group, recursively removes all children from the items map. */
removeItem(id: string, animate = true): void { removeItem(id: string, animate = true): void {
const item = this.items.get(id); const item = this.items.get(id);
if (!item) return; if (!item) return;
// If this is a group, remove children from the items map first
if (item.data.type === 'group') {
const groupData = item.data as import('./scene-format').GroupObject;
for (const childId of groupData.children) {
this.items.delete(childId);
}
}
if (!animate) { if (!animate) {
item.displayObject.destroy({ children: true }); item.displayObject.destroy({ children: true });
this.items.delete(id); this.items.delete(id);
@@ -302,10 +510,20 @@ export class SceneManager {
// Remove from map immediately to prevent double-remove // Remove from map immediately to prevent double-remove
this.items.delete(id); this.items.delete(id);
// Scale toward center on removal
const halfW = item.data.w * item.data.sx / 2;
const halfH = item.data.h * item.data.sy / 2;
const startX = item.data.x;
const startY = item.data.y;
const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy); const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy);
scaleSpring.onUpdate = (v) => { scaleSpring.onUpdate = (v) => {
if (!obj.destroyed) { if (!obj.destroyed) {
obj.scale.set(v * (item.data.sx), v * (item.data.sy)); obj.scale.set(v * item.data.sx, v * item.data.sy);
obj.position.set(
startX + halfW * (1 - v),
startY + halfH * (1 - v),
);
} }
}; };
this.springs.add(scaleSpring); this.springs.add(scaleSpring);
@@ -371,6 +589,41 @@ export class SceneManager {
return this.items.get(data.id)!; return this.items.get(data.id)!;
} }
/** Create a VideoObject from an upload and add it to the scene with animation. */
addVideoFromUpload(
assetKey: string,
w: number,
h: number,
x: number,
y: number,
): SceneItem {
const data: VideoObject = {
id: crypto.randomUUID(),
type: 'video',
x,
y,
w,
h,
sx: 1,
sy: 1,
angle: 0,
z: this.nextZ(),
opacity: 1,
locked: false,
name: '',
visible: true,
asset: assetKey,
muted: true,
loop: true,
};
this._createItem(data, true);
this._applyZOrder();
this._onChange?.();
return this.items.get(data.id)!;
}
// -- Group / Ungroup with Spring Animation -------------------------------- // -- Group / Ungroup with Spring Animation --------------------------------
/** /**
+46 -4
View File
@@ -7,8 +7,9 @@
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js'; import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
import type { Viewport } from 'pixi-viewport'; import type { Viewport } from 'pixi-viewport';
import { SceneManager, SceneItem, getItemWorldBounds, isGroupChild } from './SceneManager'; import { SceneManager, SceneItem, getItemWorldBounds } from './SceneManager';
import { TransformBox } from './TransformBox'; import { TransformBox } from './TransformBox';
import { SnapGuides } from './SnapGuides';
import { ImageSprite } from './sprites/ImageSprite'; import { ImageSprite } from './sprites/ImageSprite';
import { VideoSprite } from './sprites/VideoSprite'; import { VideoSprite } from './sprites/VideoSprite';
@@ -31,6 +32,7 @@ const DRAG_THRESHOLD = 5; // px in screen space before object drag activa
export class SelectionManager { export class SelectionManager {
readonly selectedIds: Set<string> = new Set(); readonly selectedIds: Set<string> = new Set();
readonly transformBox: TransformBox; readonly transformBox: TransformBox;
private _snapGuides: SnapGuides;
private _viewport: Viewport; private _viewport: Viewport;
private _scene: SceneManager; private _scene: SceneManager;
@@ -62,6 +64,8 @@ export class SelectionManager {
private _enabled = true; private _enabled = true;
get snapGuides(): SnapGuides { return this._snapGuides; }
constructor(viewport: Viewport, scene: SceneManager) { constructor(viewport: Viewport, scene: SceneManager) {
this._viewport = viewport; this._viewport = viewport;
this._scene = scene; this._scene = scene;
@@ -81,6 +85,9 @@ export class SelectionManager {
this.transformBox.setViewport(viewport); this.transformBox.setViewport(viewport);
this._overlay.addChild(this.transformBox); this._overlay.addChild(this.transformBox);
// Snap guides
this._snapGuides = new SnapGuides(scene, this._overlay);
// Bind events on viewport // Bind events on viewport
viewport.on('pointerdown', this._onPointerDown, this); viewport.on('pointerdown', this._onPointerDown, this);
viewport.on('globalpointermove', this._onPointerMove, this); viewport.on('globalpointermove', this._onPointerMove, this);
@@ -229,17 +236,31 @@ export class SelectionManager {
// Lift shadow + spring scale on all selected image sprites // Lift shadow + spring scale on all selected image sprites
this._applyLift(); this._applyLift();
// Begin snap guide session
this._snapGuides.beginSession(this.selectedIds);
} }
if (this._objectDragging) { if (this._objectDragging) {
const currentWorld = this._viewport.toWorld(e.global.x, e.global.y); const currentWorld = this._viewport.toWorld(e.global.x, e.global.y);
const ddx = currentWorld.x - this._lastDragWorldX; let ddx = currentWorld.x - this._lastDragWorldX;
const ddy = currentWorld.y - this._lastDragWorldY; let ddy = currentWorld.y - this._lastDragWorldY;
this._lastDragWorldX = currentWorld.x; this._lastDragWorldX = currentWorld.x;
this._lastDragWorldY = currentWorld.y; this._lastDragWorldY = currentWorld.y;
// Move all selected items by delta and broadcast transforms // Compute combined bounds of selected items after applying delta
const selected = this.getSelectedItems(); const selected = this.getSelectedItems();
const prospective = this._getSelectionBounds(selected);
prospective.x += ddx;
prospective.y += ddy;
// Snap to alignment guides
const snap = this._snapGuides.computeSnap(prospective, this._viewport);
ddx += snap.dx;
ddy += snap.dy;
this._snapGuides.drawGuides(snap.guides, this._viewport);
// Move all selected items by corrected delta and broadcast transforms
for (const item of selected) { for (const item of selected) {
item.displayObject.x += ddx; item.displayObject.x += ddx;
item.displayObject.y += ddy; item.displayObject.y += ddy;
@@ -274,6 +295,7 @@ export class SelectionManager {
if (this._objectDragging) { if (this._objectDragging) {
// End object drag — drop shadow + spring scale back // End object drag — drop shadow + spring scale back
this._applyDrop(); this._applyDrop();
this._snapGuides.endSession();
this._objectDragging = false; this._objectDragging = false;
// Resume viewport drag // Resume viewport drag
@@ -390,6 +412,26 @@ export class SelectionManager {
this._emitChange(); this._emitChange();
} }
// -- Selection Bounds Helper -----------------------------------------------
/** Compute the combined world bounding rect of the given items. */
private _getSelectionBounds(items: SceneItem[]): { x: number; y: number; w: number; h: number } {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const item of items) {
const { x, y, w, h } = getItemWorldBounds(item);
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + w > maxX) maxX = x + w;
if (y + h > maxY) maxY = y + h;
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
// -- Change Notification -------------------------------------------------- // -- Change Notification --------------------------------------------------
private _emitChange(): void { private _emitChange(): void {
+192
View File
@@ -0,0 +1,192 @@
/**
* SnapGuides — alignment snap guides for drag/resize operations (like Figma).
*
* Shows thin magenta guide lines when edges or centers of dragged items
* align with other items on the canvas, and returns snap corrections.
*/
import { Container, Graphics } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import { SceneManager, getItemWorldBounds } from './SceneManager';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SNAP_THRESHOLD = 4; // pixels in screen space (subtle, not aggressive)
const GUIDE_COLOR = 0xff4081;
const GUIDE_PADDING = 20; // world-space extension beyond bounds
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SnapEdge {
axis: 'x' | 'y';
value: number;
min: number;
max: number;
}
export interface SnapResult {
dx: number;
dy: number;
guides: SnapEdge[];
}
// ---------------------------------------------------------------------------
// SnapGuides
// ---------------------------------------------------------------------------
export class SnapGuides {
private _scene: SceneManager;
private _gfx: Graphics;
/** Cached candidate edges from non-selected items. */
private _candidatesX: { value: number; min: number; max: number }[] = [];
private _candidatesY: { value: number; min: number; max: number }[] = [];
constructor(scene: SceneManager, parent: Container) {
this._scene = scene;
this._gfx = new Graphics();
this._gfx.label = '__snap_guides';
parent.addChild(this._gfx);
}
// -----------------------------------------------------------------------
// Session management
// -----------------------------------------------------------------------
/** Cache candidate edges from all visible, unlocked, non-selected top-level items. */
beginSession(excludeIds: Set<string>): void {
this._candidatesX = [];
this._candidatesY = [];
for (const item of this._scene.getTopLevelItems()) {
if (excludeIds.has(item.id)) continue;
if (item.data.locked || !item.data.visible) continue;
const { x, y, w, h } = getItemWorldBounds(item);
const right = x + w;
const bottom = y + h;
const cx = x + w / 2;
const cy = y + h / 2;
// X-axis edges: left, center, right
this._candidatesX.push({ value: x, min: y, max: bottom });
this._candidatesX.push({ value: cx, min: y, max: bottom });
this._candidatesX.push({ value: right, min: y, max: bottom });
// Y-axis edges: top, center, bottom
this._candidatesY.push({ value: y, min: x, max: right });
this._candidatesY.push({ value: cy, min: x, max: right });
this._candidatesY.push({ value: bottom, min: x, max: right });
}
}
/** Clear cached edges and guide lines. */
endSession(): void {
this._candidatesX = [];
this._candidatesY = [];
this._gfx.clear();
}
// -----------------------------------------------------------------------
// Snap computation
// -----------------------------------------------------------------------
/**
* Compare 6 edges of the selection bounds against cached candidates.
* Returns correction deltas and guide lines to draw.
*/
computeSnap(
bounds: { x: number; y: number; w: number; h: number },
viewport: Viewport,
): SnapResult {
const scale = viewport.scale.x;
const threshold = SNAP_THRESHOLD / scale;
const { x, y, w, h } = bounds;
const right = x + w;
const bottom = y + h;
const cx = x + w / 2;
const cy = y + h / 2;
// Selection edges for each axis
const selEdgesX = [x, cx, right];
const selEdgesY = [y, cy, bottom];
// Selection extent ranges (for guide line extension)
const selMinY = y;
const selMaxY = bottom;
const selMinX = x;
const selMaxX = right;
let bestDx = Infinity;
let bestGuideX: SnapEdge | null = null;
// Find closest X match
for (const selVal of selEdgesX) {
for (const cand of this._candidatesX) {
const diff = cand.value - selVal;
if (Math.abs(diff) < Math.abs(bestDx)) {
bestDx = diff;
const gMin = Math.min(selMinY, cand.min) - GUIDE_PADDING;
const gMax = Math.max(selMaxY, cand.max) + GUIDE_PADDING;
bestGuideX = { axis: 'x', value: cand.value, min: gMin, max: gMax };
}
}
}
let bestDy = Infinity;
let bestGuideY: SnapEdge | null = null;
// Find closest Y match
for (const selVal of selEdgesY) {
for (const cand of this._candidatesY) {
const diff = cand.value - selVal;
if (Math.abs(diff) < Math.abs(bestDy)) {
bestDy = diff;
const gMin = Math.min(selMinX, cand.min) - GUIDE_PADDING;
const gMax = Math.max(selMaxX, cand.max) + GUIDE_PADDING;
bestGuideY = { axis: 'y', value: cand.value, min: gMin, max: gMax };
}
}
}
const guides: SnapEdge[] = [];
const dx = Math.abs(bestDx) <= threshold && bestGuideX ? bestDx : 0;
const dy = Math.abs(bestDy) <= threshold && bestGuideY ? bestDy : 0;
if (dx !== 0 && bestGuideX) guides.push(bestGuideX);
if (dy !== 0 && bestGuideY) guides.push(bestGuideY);
return { dx, dy, guides };
}
// -----------------------------------------------------------------------
// Drawing
// -----------------------------------------------------------------------
/** Draw guide lines; line width compensates for viewport zoom. */
drawGuides(guides: SnapEdge[], viewport: Viewport): void {
this._gfx.clear();
if (guides.length === 0) return;
const lineWidth = 1 / viewport.scale.x;
for (const g of guides) {
this._gfx.moveTo(
g.axis === 'x' ? g.value : g.min,
g.axis === 'x' ? g.min : g.value,
);
this._gfx.lineTo(
g.axis === 'x' ? g.value : g.max,
g.axis === 'x' ? g.max : g.value,
);
}
this._gfx.stroke({ color: GUIDE_COLOR, width: lineWidth });
}
}
+20 -47
View File
@@ -1,67 +1,42 @@
import { Texture, Assets } from "pixi.js"; import { Texture, Assets } from "pixi.js";
export type LODTier = "thumb" | "medium" | "full";
interface TextureEntry { interface TextureEntry {
texture: Texture; texture: Texture;
tier: LODTier;
lastUsed: number; lastUsed: number;
memoryEstimate: number; memoryEstimate: number;
} }
/**
* TextureManager — GPU texture cache with LRU eviction and memory budget.
* No LOD tiers — PixiJS GPU handles scaling natively.
* Just loads the full-res image and caches it.
*/
export class TextureManager { export class TextureManager {
private cache = new Map<string, TextureEntry>(); private cache = new Map<string, TextureEntry>();
private budget = 512 * 1024 * 1024; // 512 MB private budget = 512 * 1024 * 1024; // 512 MB
private currentUsage = 0; private currentUsage = 0;
/** Map zoom level to the appropriate LOD tier. */ /** Build the URL for a given asset. */
tierForZoom(zoom: number): LODTier { urlForAsset(assetKey: string): string {
if (zoom < 0.3) return "thumb"; if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) {
if (zoom > 1.5) return "full"; return assetKey;
return "medium";
}
/**
* Detect whether an asset key is a new LOD-format key (no extension)
* or an old pre-migration path (has file extension).
*/
private _isLegacyAsset(assetKey: string): boolean {
// New LOD keys look like: boards/{boardId}/{imageId} (no extension)
// Old keys look like: boards/{boardId}/{imageId}.png or full URLs
return /\.\w{2,5}$/.test(assetKey) || assetKey.startsWith('http') || assetKey.startsWith('/api/');
}
/** Build the URL for a given asset + tier. */
urlForAsset(assetKey: string, tier: LODTier): string {
if (this._isLegacyAsset(assetKey)) {
// Legacy: load original file directly (GPU handles scaling)
if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) {
return assetKey;
}
return `/api/images/${assetKey}`;
} }
// New LOD format: boards/{boardId}/{imageId}/thumb.webp return `/api/images/${assetKey}`;
return `/api/images/${assetKey}/${tier}.webp`;
} }
/** /**
* Load a texture for the given asset and LOD tier. * Load a texture for the given asset.
* For legacy assets (pre-migration), loads the original file directly — * Returns cached texture if available, otherwise fetches and caches.
* the GPU handles downscaling naturally. * GPU handles all scaling — no LOD tiers needed.
* For new assets, loads the appropriate LOD tier with fallback.
*/ */
async load(assetKey: string, tier: LODTier): Promise<Texture> { async load(assetKey: string): Promise<Texture> {
// For legacy assets, all tiers resolve to the same URL const existing = this.cache.get(assetKey);
const effectiveTier = this._isLegacyAsset(assetKey) ? 'full' : tier;
const key = `${assetKey}:${effectiveTier}`;
const existing = this.cache.get(key);
if (existing) { if (existing) {
existing.lastUsed = performance.now(); existing.lastUsed = performance.now();
return existing.texture; return existing.texture;
} }
const url = this.urlForAsset(assetKey, effectiveTier); const url = this.urlForAsset(assetKey);
let texture: Texture; let texture: Texture;
try { try {
texture = await Assets.load(url); texture = await Assets.load(url);
@@ -76,9 +51,8 @@ export class TextureManager {
this.currentUsage += memoryEstimate; this.currentUsage += memoryEstimate;
this.cache.set(key, { this.cache.set(assetKey, {
texture, texture,
tier,
lastUsed: performance.now(), lastUsed: performance.now(),
memoryEstimate, memoryEstimate,
}); });
@@ -91,14 +65,13 @@ export class TextureManager {
} }
/** Remove a specific texture from the cache and destroy it. */ /** Remove a specific texture from the cache and destroy it. */
unload(assetKey: string, tier: LODTier): void { unload(assetKey: string): void {
const key = `${assetKey}:${tier}`; const entry = this.cache.get(assetKey);
const entry = this.cache.get(key);
if (!entry) return; if (!entry) return;
this.currentUsage -= entry.memoryEstimate; this.currentUsage -= entry.memoryEstimate;
entry.texture.destroy(true); entry.texture.destroy(true);
this.cache.delete(key); this.cache.delete(assetKey);
} }
/** Evict the least-recently-used cache entry. */ /** Evict the least-recently-used cache entry. */
+197 -111
View File
@@ -6,9 +6,10 @@
* Each handle is draggable and applies scale/rotation transforms to the items. * Each handle is draggable and applies scale/rotation transforms to the items.
*/ */
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js'; import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pixi.js';
import type { Viewport } from 'pixi-viewport'; import type { Viewport } from 'pixi-viewport';
import type { SceneItem } from './SceneManager'; import { type SceneItem, getItemWorldBounds } from './SceneManager';
import type { SnapGuides } from './SnapGuides';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -19,11 +20,8 @@ const BORDER_WIDTH = 1.5;
const HANDLE_SIZE = 8; const HANDLE_SIZE = 8;
const HANDLE_FILL = 0xffffff; const HANDLE_FILL = 0xffffff;
const HANDLE_STROKE = 0x4a90d9; const HANDLE_STROKE = 0x4a90d9;
const ROTATE_COLOR = 0x55aa55;
const ROTATE_OFFSET = 25;
const ROTATE_RADIUS = 5;
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br' | 'rot'; type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
const HANDLE_CURSORS: Record<HandleId, string> = { const HANDLE_CURSORS: Record<HandleId, string> = {
tl: 'nwse-resize', tl: 'nwse-resize',
@@ -34,7 +32,6 @@ const HANDLE_CURSORS: Record<HandleId, string> = {
bc: 'ns-resize', bc: 'ns-resize',
ml: 'ew-resize', ml: 'ew-resize',
mr: 'ew-resize', mr: 'ew-resize',
rot: 'grab',
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -45,6 +42,7 @@ interface DragState {
handleId: HandleId; handleId: HandleId;
startX: number; startX: number;
startY: number; startY: number;
origBounds: { x: number; y: number; w: number; h: number };
origTransforms: Map<string, { sx: number; sy: number; angle: number; x: number; y: number }>; origTransforms: Map<string, { sx: number; sy: number; angle: number; x: number; y: number }>;
} }
@@ -55,11 +53,22 @@ interface DragState {
export class TransformBox extends Container { export class TransformBox extends Container {
private _border: Graphics; private _border: Graphics;
private _handles: Map<HandleId, Graphics> = new Map(); private _handles: Map<HandleId, Graphics> = new Map();
private _rotateLine: Graphics;
private _items: SceneItem[] = []; private _items: SceneItem[] = [];
private _bounds = { x: 0, y: 0, w: 0, h: 0 }; private _bounds = { x: 0, y: 0, w: 0, h: 0 };
private _drag: DragState | null = null; private _drag: DragState | null = null;
private _viewport: Viewport | null = null; private _viewport: Viewport | null = null;
private _onItemTransform: ((item: SceneItem) => void) | null = null;
private _snapGuides: SnapGuides | null = null;
private _dimLabel!: Text;
private _dimLabelBg!: Graphics;
set onItemTransform(fn: (item: SceneItem) => void) {
this._onItemTransform = fn;
}
setSnapGuides(sg: SnapGuides): void {
this._snapGuides = sg;
}
constructor() { constructor() {
super(); super();
@@ -70,12 +79,29 @@ export class TransformBox extends Container {
this._border = new Graphics(); this._border = new Graphics();
this.addChild(this._border); this.addChild(this._border);
// Rotate stem line
this._rotateLine = new Graphics(); // Dimension label (shown during resize)
this.addChild(this._rotateLine); this._dimLabel = new Text({
text: '',
style: new TextStyle({
fontSize: 11,
fontFamily: 'system-ui, -apple-system, sans-serif',
fill: '#ffffff',
fontWeight: '500',
}),
});
this._dimLabel.visible = false;
this._dimLabel.label = '__dim_label';
this._dimLabelBg = new Graphics();
this._dimLabelBg.visible = false;
this._dimLabelBg.label = '__dim_label_bg';
this.addChild(this._dimLabelBg);
this.addChild(this._dimLabel);
// Create all handles // Create all handles
const ids: HandleId[] = ['tl', 'tc', 'tr', 'ml', 'mr', 'bl', 'bc', 'br', 'rot']; const ids: HandleId[] = ['tl', 'tc', 'tr', 'ml', 'mr', 'bl', 'bc', 'br'];
for (const id of ids) { for (const id of ids) {
const handle = new Graphics(); const handle = new Graphics();
handle.eventMode = 'static'; handle.eventMode = 'static';
@@ -103,23 +129,25 @@ export class TransformBox extends Container {
update(items: SceneItem[]): void { update(items: SceneItem[]): void {
this._items = items; this._items = items;
// Hide dimension label when not actively dragging
if (!this._drag) {
this._dimLabel.visible = false;
this._dimLabelBg.visible = false;
}
if (items.length === 0) { if (items.length === 0) {
this.visible = false; this.visible = false;
return; return;
} }
// Compute combined bounding rect in WORLD space using item data // Compute combined bounding rect via canonical getItemWorldBounds()
// (not getBounds() which returns screen-space and causes offset)
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
let maxY = -Infinity; let maxY = -Infinity;
for (const item of items) { for (const item of items) {
const ix = item.data.x; const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
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);
if (ix < minX) minX = ix; if (ix < minX) minX = ix;
if (iy < minY) minY = iy; if (iy < minY) minY = iy;
if (ix + iw > maxX) maxX = ix + iw; if (ix + iw > maxX) maxX = ix + iw;
@@ -141,12 +169,6 @@ export class TransformBox extends Container {
this._border.rect(x, y, w, h); this._border.rect(x, y, w, h);
this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH }); this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH });
// Rotate line (from top-center up to rotate handle)
this._rotateLine.clear();
this._rotateLine.moveTo(x + w / 2, y);
this._rotateLine.lineTo(x + w / 2, y - ROTATE_OFFSET);
this._rotateLine.stroke({ color: BORDER_COLOR, width: 1 });
// Position handles // Position handles
const cx = x + w / 2; const cx = x + w / 2;
const cy = y + h / 2; const cy = y + h / 2;
@@ -160,25 +182,16 @@ export class TransformBox extends Container {
bl: { px: x, py: y + h }, bl: { px: x, py: y + h },
bc: { px: cx, py: y + h }, bc: { px: cx, py: y + h },
br: { px: x + w, py: y + h }, br: { px: x + w, py: y + h },
rot: { px: cx, py: y - ROTATE_OFFSET },
}; };
for (const [id, handle] of this._handles) { for (const [id, handle] of this._handles) {
const pos = positions[id]; const pos = positions[id];
handle.clear(); handle.clear();
if (id === 'rot') { const half = HANDLE_SIZE / 2;
// Green circle for rotation handle.rect(-half, -half, HANDLE_SIZE, HANDLE_SIZE);
handle.circle(0, 0, ROTATE_RADIUS); handle.fill(HANDLE_FILL);
handle.fill(ROTATE_COLOR); handle.stroke({ color: HANDLE_STROKE, width: 1 });
handle.stroke({ color: HANDLE_STROKE, width: 1 });
} else {
// White square with blue stroke for resize
const half = HANDLE_SIZE / 2;
handle.rect(-half, -half, HANDLE_SIZE, HANDLE_SIZE);
handle.fill(HANDLE_FILL);
handle.stroke({ color: HANDLE_STROKE, width: 1 });
}
handle.position.set(pos.px, pos.py); handle.position.set(pos.px, pos.py);
} }
@@ -204,108 +217,181 @@ export class TransformBox extends Container {
handleId: id, handleId: id,
startX: e.global.x, startX: e.global.x,
startY: e.global.y, startY: e.global.y,
origBounds: { ...this._bounds },
origTransforms, origTransforms,
}; };
// Begin snap session excluding current items
const itemIds = new Set(this._items.map((it) => it.id));
this._snapGuides?.beginSession(itemIds);
} }
private _onHandleMove(e: FederatedPointerEvent): void { private _onHandleMove(e: FederatedPointerEvent): void {
if (!this._drag) return; if (!this._drag) return;
// Convert screen-space deltas to world-space by dividing by viewport zoom // Convert mouse position to world space
const zoom = this._viewport?.scale.x ?? 1; const world = this._viewport!.toWorld(e.global.x, e.global.y);
const dx = (e.global.x - this._drag.startX) / zoom; const { handleId, origBounds: ob, origTransforms } = this._drag;
const dy = (e.global.y - this._drag.startY) / zoom;
const { handleId, origTransforms } = this._drag;
// Use bounding box size as reference for scale sensitivity // Compute scale factors: new size / original size
const { w: bw, h: bh } = this._bounds; // Each handle has a fixed edge — the opposite side stays put
const refSize = Math.max(bw, bh, 100); // avoid division by tiny numbers const MIN = 0.05;
let fx = 1; // horizontal scale multiplier
let fy = 1; // vertical scale multiplier
switch (handleId) {
// --- Corner handles (proportional) ---
case 'br': {
// Fixed edge: top-left. New size = mouse - top-left.
fx = Math.max(MIN, (world.x - ob.x) / ob.w);
fy = Math.max(MIN, (world.y - ob.y) / ob.h);
// Proportional: use average
const f = (fx + fy) / 2;
fx = f; fy = f;
break;
}
case 'tl': {
// Fixed edge: bottom-right
const right = ob.x + ob.w;
const bottom = ob.y + ob.h;
fx = Math.max(MIN, (right - world.x) / ob.w);
fy = Math.max(MIN, (bottom - world.y) / ob.h);
const f = (fx + fy) / 2;
fx = f; fy = f;
break;
}
case 'tr': {
// Fixed edge: bottom-left
const bottom = ob.y + ob.h;
fx = Math.max(MIN, (world.x - ob.x) / ob.w);
fy = Math.max(MIN, (bottom - world.y) / ob.h);
const f = (fx + fy) / 2;
fx = f; fy = f;
break;
}
case 'bl': {
// Fixed edge: top-right
const right = ob.x + ob.w;
fx = Math.max(MIN, (right - world.x) / ob.w);
fy = Math.max(MIN, (world.y - ob.y) / ob.h);
const f = (fx + fy) / 2;
fx = f; fy = f;
break;
}
// --- Edge handles (proportional by default, hold Shift for free-form) ---
case 'mr': {
fx = Math.max(MIN, (world.x - ob.x) / ob.w);
if (!e.shiftKey) fy = fx;
break;
}
case 'ml': {
const right = ob.x + ob.w;
fx = Math.max(MIN, (right - world.x) / ob.w);
if (!e.shiftKey) fy = fx;
break;
}
case 'bc': {
fy = Math.max(MIN, (world.y - ob.y) / ob.h);
if (!e.shiftKey) fx = fy;
break;
}
case 'tc': {
const bottom = ob.y + ob.h;
fy = Math.max(MIN, (bottom - world.y) / ob.h);
if (!e.shiftKey) fx = fy;
break;
}
}
for (const item of this._items) { for (const item of this._items) {
const orig = origTransforms.get(item.id); const orig = origTransforms.get(item.id);
if (!orig) continue; if (!orig) continue;
// Apply scale factors relative to original
item.data.sx = orig.sx * fx;
item.data.sy = orig.sy * fy;
// Reposition to keep fixed edge in place
switch (handleId) { switch (handleId) {
case 'br': { case 'tl':
// Proportional scale — drag distance relative to object size item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
const factor = 1 + (dx + dy) / refSize;
const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
break;
}
case 'mr': {
const factor = 1 + dx / (bw || refSize);
item.data.sx = orig.sx * Math.max(0.05, factor);
break;
}
case 'bc': {
const factor = 1 + dy / (bh || refSize);
item.data.sy = orig.sy * Math.max(0.05, factor);
break;
}
case 'tl': {
// Proportional scale + reposition (bottom-right stays fixed)
const factor = 1 - (dx + dy) / refSize;
const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
const dw = (item.data.sx - orig.sx) * item.data.w;
const dh = (item.data.sy - orig.sy) * item.data.h;
item.data.x = orig.x - dw;
item.data.y = orig.y - dh;
break;
}
case 'rot': {
// Rotation: 1 world pixel = ~0.3 degrees
item.data.angle = orig.angle + dx * 0.3;
break;
}
case 'tr': {
const factor = 1 + (dx - dy) / refSize;
const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
// Top-right: left edge stays fixed
item.data.y = orig.y - (item.data.sy - orig.sy) * item.data.h;
break;
}
case 'bl': {
const factor = 1 + (-dx + dy) / refSize;
const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
// Bottom-left: right edge stays fixed
item.data.x = orig.x - (item.data.sx - orig.sx) * item.data.w;
break;
}
case 'tc': {
const factor = 1 - dy / (bh || refSize);
item.data.sy = orig.sy * Math.max(0.05, factor);
// Top-center: bottom edge stays fixed
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h; item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
break; break;
} case 'tc':
case 'ml': { item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
const factor = 1 - dx / (bw || refSize); break;
item.data.sx = orig.sx * Math.max(0.05, factor); case 'tr':
// Middle-left: right edge stays fixed item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
break;
case 'ml':
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w; item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
break; break;
} case 'bl':
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
break;
// br, mr, bc: top-left is fixed, no reposition needed
} }
// Apply to display object
item.displayObject.scale.set(item.data.sx, item.data.sy); item.displayObject.scale.set(item.data.sx, item.data.sy);
item.displayObject.angle = item.data.angle;
item.displayObject.position.set(item.data.x, item.data.y); item.displayObject.position.set(item.data.x, item.data.y);
this._onItemTransform?.(item);
} }
// Redraw transform box around new bounds
this.update(this._items); this.update(this._items);
// Snap guides during resize
if (this._snapGuides && this._viewport) {
const snap = this._snapGuides.computeSnap(this._bounds, this._viewport);
if (snap.dx !== 0 || snap.dy !== 0) {
// Apply snap correction to all items
for (const item of this._items) {
item.data.x += snap.dx;
item.data.y += snap.dy;
item.displayObject.position.set(item.data.x, item.data.y);
this._onItemTransform?.(item);
}
this.update(this._items);
}
this._snapGuides.drawGuides(snap.guides, this._viewport);
}
// Show dimension label
const bounds = this._bounds;
const zoom = this._viewport?.scale.x ?? 1;
const w = Math.round(bounds.w);
const h = Math.round(bounds.h);
this._dimLabel.text = `${w} \u00d7 ${h}`;
this._dimLabel.scale.set(1 / zoom); // Stay fixed screen size
// Position below bottom-right corner with offset
const labelX = bounds.x + bounds.w;
const labelY = bounds.y + bounds.h + 12 / zoom;
this._dimLabel.anchor.set(1, 0); // right-aligned
this._dimLabel.position.set(labelX, labelY);
// Background pill
const pad = 4 / zoom;
const textW = this._dimLabel.width;
const textH = this._dimLabel.height;
this._dimLabelBg.clear();
this._dimLabelBg.roundRect(
labelX - textW - pad,
labelY - pad / 2,
textW + pad * 2,
textH + pad,
3 / zoom,
);
this._dimLabelBg.fill({ color: 0x1a1a1a, alpha: 0.9 });
this._dimLabel.visible = true;
this._dimLabelBg.visible = true;
} }
private _onHandleUp(): void { private _onHandleUp(): void {
this._drag = null; this._drag = null;
this._dimLabel.visible = false;
this._dimLabelBg.visible = false;
this._snapGuides?.endSession();
} }
} }
+178
View File
@@ -0,0 +1,178 @@
/**
* Clipboard module — copy canvas to system clipboard and paste images from it.
*
* Copy approach: render ONLY the selected items at native resolution using
* PixiJS generateTexture + extract. No viewport cropping — independent of
* zoom/pan state. Items are temporarily reparented into a clean container,
* rendered, then restored.
*/
import { Container, Rectangle } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import type { Application } from 'pixi.js';
import type { SceneManager, SceneItem } from './SceneManager';
import { getItemWorldBounds } from './SceneManager';
import { uploadImage } from '../api';
/**
* Write selected items (or full viewport) to system clipboard as PNG.
* Selected items are rendered at their native size, not affected by zoom.
*/
export async function writeCanvasToClipboard(
app: Application | null,
viewport: Viewport | null,
items?: SceneItem[],
): Promise<void> {
if (!viewport) throw new Error('Viewport not available');
if (!app?.renderer?.extract) throw new Error('Renderer not available');
let outputCanvas: HTMLCanvasElement;
if (items && items.length > 0) {
// --- Render selected items at native resolution ---
// 1. Compute world-space bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const item of items) {
const { x, y, w, h } = getItemWorldBounds(item);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + h);
}
const pad = 10;
const totalW = Math.ceil(maxX - minX) + pad * 2;
const totalH = Math.ceil(maxY - minY) + pad * 2;
// 2. Save original parent/transform for each item, reparent into temp container
const tempContainer = new Container();
const saved = new Map<string, {
parent: Container;
x: number;
y: number;
sx: number;
sy: number;
}>();
// Pre-compute world bounds for each item (handles group children with local coords)
const worldBoundsMap = new Map<string, { x: number; y: number; w: number; h: number }>();
for (const item of items) {
worldBoundsMap.set(item.id, getItemWorldBounds(item));
}
for (const item of items) {
const obj = item.displayObject;
saved.set(item.id, {
parent: obj.parent as Container,
x: obj.x,
y: obj.y,
sx: obj.scale.x,
sy: obj.scale.y,
});
// Remove from current parent
obj.parent?.removeChild(obj);
// Position using world bounds (correct for both top-level and group children)
const wb = worldBoundsMap.get(item.id)!;
obj.position.set(wb.x - minX + pad, wb.y - minY + pad);
// Use data scale (not viewport-affected scale)
obj.scale.set(item.data.sx, item.data.sy);
tempContainer.addChild(obj);
}
// 3. Generate texture at 1x resolution (native pixel size)
let texture;
let extractedCanvas: HTMLCanvasElement;
try {
texture = app.renderer.generateTexture({
target: tempContainer,
resolution: 1,
frame: new Rectangle(0, 0, totalW, totalH),
});
extractedCanvas = app.renderer.extract.canvas(texture) as HTMLCanvasElement;
} finally {
// 4. Restore items to their original parents and transforms (even on error)
for (const item of items) {
const s = saved.get(item.id)!;
tempContainer.removeChild(item.displayObject);
s.parent.addChild(item.displayObject);
item.displayObject.position.set(s.x, s.y);
item.displayObject.scale.set(s.sx, s.sy);
}
tempContainer.destroy();
texture?.destroy(true);
}
// 6. Add opaque background
outputCanvas = document.createElement('canvas');
outputCanvas.width = extractedCanvas.width;
outputCanvas.height = extractedCanvas.height;
const ctx2d = outputCanvas.getContext('2d')!;
ctx2d.fillStyle = '#1e1e1e';
ctx2d.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
ctx2d.drawImage(extractedCanvas, 0, 0);
} else {
// Full viewport screenshot
const extractedCanvas = app.renderer.extract.canvas(viewport) as HTMLCanvasElement;
outputCanvas = document.createElement('canvas');
outputCanvas.width = extractedCanvas.width;
outputCanvas.height = extractedCanvas.height;
const ctx2d = outputCanvas.getContext('2d')!;
ctx2d.fillStyle = '#1e1e1e';
ctx2d.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
ctx2d.drawImage(extractedCanvas, 0, 0);
}
const blob = await new Promise<Blob>((resolve, reject) => {
outputCanvas.toBlob((b) => {
if (b) resolve(b);
else reject(new Error('toBlob returned null'));
}, 'image/png');
});
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
}
/**
* Paste an image from the system clipboard into the scene.
* Reads the clipboard, uploads the first image found, and adds it at the viewport center.
*/
export async function pasteFromSystemClipboard(
scene: SceneManager,
viewport: Viewport,
boardId: string,
onChange: () => void,
): Promise<string> {
const clipItems = await navigator.clipboard.read();
for (const clipItem of clipItems) {
for (const type of clipItem.types) {
if (!type.startsWith('image/')) continue;
const blob = await clipItem.getType(type);
const file = new File([blob], `paste.${type.split('/')[1]}`, { type });
const res = await uploadImage(boardId, file);
const imgData = res.data.image || res.data;
const assetKey = imgData.asset_key;
const w = imgData.width || 400;
const h = imgData.height || 300;
const maxDim = 600;
let fw = w, fh = h;
if (w > maxDim || h > maxDim) {
const s = maxDim / Math.max(w, h);
fw = Math.round(w * s);
fh = Math.round(h * s);
}
if (assetKey) {
const center = viewport.center;
scene.addImageFromUpload(assetKey, fw, fh, center.x - fw / 2, center.y - fh / 2);
onChange();
return 'Pasted image';
}
}
}
return 'No image in clipboard';
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Context menu builder — assembles menu items from scene/selection state.
*/
import type { SceneManager, SceneItem } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
import { getItemWorldBounds } from './SceneManager';
import type { Viewport } from 'pixi-viewport';
import type { GroupObject } from './scene-format';
import { FrameSprite } from './sprites/FrameSprite';
import * as ops from './operations';
export interface MenuItem {
label: string;
shortcut: string;
onClick: () => void | Promise<void>;
disabled?: boolean;
divider?: boolean;
danger?: boolean;
}
interface MenuContext {
scene: SceneManager | null;
selection: SelectionManager | null;
viewport: Viewport | null;
clipboardRef: React.MutableRefObject<SceneItem[]>;
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
onChange: () => void;
refreshLayers: () => void;
handleGroup: () => void;
handleUngroup: () => void;
fitAll: () => void;
}
export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
const { scene, selection, viewport } = ctx;
const selected = selection ? selection.getSelectedItems() : [];
const hasSel = selected.length > 0;
const multiSel = selected.length >= 2;
return [
// -- Clipboard --
{ label: 'Copy', shortcut: 'Ctrl+C', onClick: () => {
if (hasSel) { ctx.clipboardRef.current = [...selected]; ctx.writeCanvasToClipboard(selected); }
else ctx.writeCanvasToClipboard();
} },
{ label: 'Cut', shortcut: 'Ctrl+X', onClick: () => {
if (!scene || !hasSel || !selection) return;
ctx.clipboardRef.current = [...selected];
for (const item of selected) scene.removeItem(item.id, true);
selection.clear();
ctx.onChange();
}, disabled: !hasSel },
{ label: 'Paste', shortcut: 'Ctrl+V', onClick: async () => {
if (!scene || ctx.clipboardRef.current.length === 0) return;
const cloned = _cloneWithGroupSupport(ctx.clipboardRef.current, scene);
const sorted = [...cloned].sort((a, b) =>
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
for (const newData of sorted) {
await scene._createItem(newData, true);
}
scene._applyZOrder();
ctx.onChange();
}, disabled: ctx.clipboardRef.current.length === 0 },
{ label: 'Duplicate', shortcut: 'Ctrl+D', onClick: async () => {
if (!scene || !hasSel) return;
const allItems = _collectGroupChildren(selected, scene);
const cloned = _cloneWithGroupSupport(allItems, scene);
const sorted = [...cloned].sort((a, b) =>
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
for (const newData of sorted) {
await scene._createItem(newData, true);
}
if (selection) selection.clear();
scene._applyZOrder();
ctx.onChange();
}, disabled: !hasSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Alignment --
{ label: 'Align Left', shortcut: 'Ctrl+\u2190', onClick: () => { ops.alignLeft(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Align Right', shortcut: 'Ctrl+\u2192', onClick: () => { ops.alignRight(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Align Top', shortcut: 'Ctrl+\u2191', onClick: () => { ops.alignTop(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Align Bottom', shortcut: 'Ctrl+\u2193', onClick: () => { ops.alignBottom(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Distribute H', shortcut: '', onClick: () => { ops.distributeHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: selected.length < 3 },
{ label: 'Distribute V', shortcut: '', onClick: () => { ops.distributeVertical(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: selected.length < 3 },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Layer ordering --
{ label: 'Bring Forward', shortcut: ']', onClick: () => {
if (!scene) return;
const all = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
const selectedIds = new Set(selected.map((s) => s.id));
for (let i = all.length - 2; i >= 0; i--) {
if (selectedIds.has(all[i].id) && !selectedIds.has(all[i + 1].id)) {
const tmp = all[i].data.z;
all[i].data.z = all[i + 1].data.z;
all[i + 1].data.z = tmp;
}
}
scene._applyZOrder();
ctx.onChange();
}, disabled: !hasSel },
{ label: 'Send Backward', shortcut: '[', onClick: () => {
if (!scene) return;
const all = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
const selectedIds = new Set(selected.map((s) => s.id));
for (let i = 1; i < all.length; i++) {
if (selectedIds.has(all[i].id) && !selectedIds.has(all[i - 1].id)) {
const tmp = all[i].data.z;
all[i].data.z = all[i - 1].data.z;
all[i - 1].data.z = tmp;
}
}
scene._applyZOrder();
ctx.onChange();
}, disabled: !hasSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Group --
{ label: 'Group', shortcut: 'Ctrl+G', onClick: ctx.handleGroup, disabled: !multiSel },
{ label: 'Ungroup', shortcut: 'Ctrl+Shift+G', onClick: ctx.handleUngroup,
disabled: selected.length !== 1 || selected[0]?.data.type !== 'group' },
// Frame color (for selected group/frame)
...(selected.length === 1 && selected[0]?.data.type === 'group' ? [
{ label: 'Frame: Blue', shortcut: '', onClick: () => _setFrameColor(selected[0], '#4a90d9', ctx) },
{ label: 'Frame: Green', shortcut: '', onClick: () => _setFrameColor(selected[0], '#69db7c', ctx) },
{ label: 'Frame: Red', shortcut: '', onClick: () => _setFrameColor(selected[0], '#ff6b6b', ctx) },
{ label: 'Frame: Purple', shortcut: '', onClick: () => _setFrameColor(selected[0], '#7950f2', ctx) },
{ label: 'Frame: Cyan', shortcut: '', onClick: () => _setFrameColor(selected[0], '#38d9a9', ctx) },
{ label: 'Frame: Orange', shortcut: '', onClick: () => _setFrameColor(selected[0], '#ffa94d', ctx) },
{ label: 'Frame: None', shortcut: '', onClick: () => _setFrameColor(selected[0], '', ctx) },
] as MenuItem[] : []),
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Arrangement --
{ label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => { ops.arrangeOptimal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Arrange Grid', shortcut: '', onClick: () => { ops.arrangeGrid(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Arrange Row', shortcut: '', onClick: () => { ops.arrangeRow(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Arrange Column', shortcut: '', onClick: () => { ops.arrangeColumn(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: () => { ops.stackObjects(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Normalize --
{ label: 'Normalize Size', shortcut: '', onClick: () => { ops.normalizeSize(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Normalize Width', shortcut: '', onClick: () => { ops.normalizeWidth(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Normalize Height', shortcut: '', onClick: () => { ops.normalizeHeight(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Image --
{ label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => { ops.flipHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
{ label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => { ops.flipVertical(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
{ label: 'Reset Transform', shortcut: 'Ctrl+Shift+T', onClick: () => { ops.resetTransform(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- View --
{ label: 'Select All', shortcut: 'Ctrl+A', onClick: () => { if (selection) selection.selectAll(); } },
{ label: 'Fit All', shortcut: 'Ctrl+0', onClick: ctx.fitAll },
{ label: 'Fit Selection', shortcut: 'F', onClick: () => {
if (!selection || !viewport) return;
const items = selection.getSelectedItems();
if (items.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const item of items) {
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 pad = 60;
const cw = maxX - minX + pad * 2;
const ch = maxY - minY + pad * 2;
const s = Math.min(viewport.screenWidth / cw, viewport.screenHeight / ch, 5);
viewport.animate({ position: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }, scale: s, time: 300, ease: 'easeOutQuad' });
}, disabled: !hasSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Delete --
{ label: 'Delete', shortcut: 'Del', onClick: () => {
if (!scene || !selection) return;
for (const item of selected) scene.removeItem(item.id, true);
selection.clear();
ctx.onChange();
}, disabled: !hasSel, danger: true },
];
}
/** Collect selected items + their group children (for deep clone). */
function _collectGroupChildren(items: SceneItem[], scene: SceneManager): SceneItem[] {
const all: SceneItem[] = [];
const seen = new Set<string>();
for (const item of items) {
if (seen.has(item.id)) continue;
seen.add(item.id);
all.push(item);
if (item.data.type === 'group') {
const gd = item.data as GroupObject;
for (const cid of gd.children) {
if (seen.has(cid)) continue;
seen.add(cid);
const child = scene.getById(cid);
if (child) all.push(child);
}
}
}
return all;
}
/** Clone items with new IDs, remapping group children references. */
function _cloneWithGroupSupport(items: SceneItem[], scene: SceneManager): any[] {
// Expand: include group children that aren't explicitly in the list
const expanded = _collectGroupChildren(items, scene);
const idMap = new Map<string, string>();
for (const item of expanded) {
idMap.set(item.data.id, crypto.randomUUID());
}
const clones: any[] = [];
for (const item of expanded) {
const newId = idMap.get(item.data.id)!;
const newData = {
...item.data,
id: newId,
x: item.data.x + 20,
y: item.data.y + 20,
z: scene.nextZ(),
};
if (newData.type === 'group' && Array.isArray(newData.children)) {
newData.children = newData.children
.map((cid: string) => idMap.get(cid))
.filter((c): c is string => !!c);
}
clones.push(newData);
}
return clones;
}
/** Set the background color of a group/frame. */
function _setFrameColor(item: SceneItem, color: string, ctx: MenuContext): void {
const gd = item.data as GroupObject;
gd.bgColor = color;
if (item.displayObject instanceof FrameSprite) {
item.displayObject.setBgColor(color);
}
ctx.onChange();
}
+197
View File
@@ -0,0 +1,197 @@
/**
* Grouping module — group/ungroup operations for scene items.
*
* Data model:
* - Group children store LOCAL x/y (relative to group origin) in data.x/y
* - The group's data.x/y is the world-space top-left of the bounding box
* - On serialize, children's data.x/y are local coords → correct on reload
* - On ungroup, children's data.x/y are converted back to world coords
*/
import type { Viewport } from 'pixi-viewport';
import type { SceneManager, SceneItem } from './SceneManager';
import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
import type { GroupObject } from './scene-format';
import { randomFrameColor } from './sprites/FrameSprite';
/**
* Group selected items into a single group container.
* Requires at least 2 selected items.
*/
export function groupItems(
scene: SceneManager,
selection: SelectionManager,
onChange: () => void,
): void {
const selected = selection.getSelectedItems();
if (selected.length < 2) return;
// Allow nested groups — groups can contain other groups
// Compute the bounding box of all selected items
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const item of selected) {
const b = getItemWorldBounds(item);
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w);
maxY = Math.max(maxY, b.y + b.h);
}
const groupX = minX;
const groupY = minY;
const groupW = maxX - minX;
const groupH = maxY - minY;
const groupData: GroupObject = {
id: crypto.randomUUID(),
type: 'group',
x: groupX,
y: groupY,
w: groupW,
h: groupH,
sx: 1,
sy: 1,
angle: 0,
z: scene.nextZ(),
opacity: 1,
locked: false,
name: '',
visible: true,
children: selected.map((s) => s.id),
bgColor: randomFrameColor(),
label: '',
padding: 12,
};
// Create the group container
scene._createItem(groupData, false);
const groupItem = scene.getById(groupData.id);
if (!groupItem) return;
// Reparent each selected item into the group container
for (const child of selected) {
const obj = child.displayObject;
// Convert world position to local (relative to group origin)
const localX = child.data.x - groupX;
const localY = child.data.y - groupY;
// Update DATA to store local coords (critical for serialization)
child.data.x = localX;
child.data.y = localY;
// Reparent display object
obj.parent?.removeChild(obj);
obj.position.set(localX, localY);
groupItem.displayObject.addChild(obj);
}
selection.clear();
selection.selectOnly(groupItem.id);
rebuildGroupChildSet(scene);
scene._applyZOrder();
onChange();
}
/**
* Ungroup the selected group, reparenting children back to the viewport.
* Propagates group's scale, angle, and opacity to each child so visual
* appearance is preserved after ungrouping.
* Requires exactly 1 selected item of type 'group'.
*/
export function ungroupItems(
scene: SceneManager,
selection: SelectionManager,
viewport: Viewport,
onChange: () => void,
): void {
const selected = selection.getSelectedItems();
if (selected.length !== 1) return;
const groupItem = selected[0];
if (groupItem.data.type !== 'group') return;
const groupData = groupItem.data as GroupObject;
const groupX = groupData.x;
const groupY = groupData.y;
const groupSx = groupData.sx;
const groupSy = groupData.sy;
const groupAngle = groupData.angle;
const childIds: string[] = [];
// Reparent each child back to the viewport, propagating group transforms
for (const childId of groupData.children) {
const childItem = scene.getById(childId);
if (!childItem) continue;
const obj = childItem.displayObject;
// Convert local position to world space, accounting for group scale
const worldX = groupX + childItem.data.x * groupSx;
const worldY = groupY + childItem.data.y * groupSy;
// Propagate group scale to child
childItem.data.sx *= groupSx;
childItem.data.sy *= groupSy;
// Propagate group angle to child
childItem.data.angle = (childItem.data.angle || 0) + groupAngle;
// Update DATA to store world coords
childItem.data.x = worldX;
childItem.data.y = worldY;
// Reparent display object
obj.parent?.removeChild(obj);
viewport.addChild(obj);
obj.position.set(worldX, worldY);
obj.scale.set(childItem.data.sx, childItem.data.sy);
obj.angle = childItem.data.angle;
childIds.push(childId);
}
// Remove the group item itself (don't destroy children — they're reparented)
const groupObj = groupItem.displayObject;
groupObj.parent?.removeChild(groupObj);
groupObj.destroy();
scene.items.delete(groupItem.id);
// Select the ungrouped children
selection.clear();
for (const id of childIds) {
selection.selectedIds.add(id);
}
selection.transformBox.update(selection.getSelectedItems());
rebuildGroupChildSet(scene);
scene._applyZOrder();
onChange();
}
/**
* After loading a scene, reparent group children into their group containers.
* Called by SceneManager.loadScene() after all items are created.
*/
export function reparentGroupChildren(scene: SceneManager): void {
for (const item of scene.items.values()) {
if (item.data.type !== 'group') continue;
const groupData = item.data as GroupObject;
const groupContainer = item.displayObject;
for (const childId of groupData.children) {
const childItem = scene.getById(childId);
if (!childItem) continue;
const obj = childItem.displayObject;
// Only reparent if currently in viewport (not already in a group)
if (obj.parent !== groupContainer) {
obj.parent?.removeChild(obj);
// data.x/y are already local coords (saved that way)
obj.position.set(childItem.data.x, childItem.data.y);
groupContainer.addChild(obj);
}
}
}
}
+20 -7
View File
@@ -1,7 +1,6 @@
import { Graphics } from 'pixi.js'; import { Graphics } from 'pixi.js';
import type { Viewport } from 'pixi-viewport'; import type { Viewport } from 'pixi-viewport';
import type { SceneManager } from './SceneManager'; import type { SceneManager } from './SceneManager';
import { VideoSprite } from './sprites/VideoSprite';
import { uploadImage, uploadImageFromUrl } from '../api'; import { uploadImage, uploadImageFromUrl } from '../api';
type OnChange = () => void; type OnChange = () => void;
@@ -55,10 +54,7 @@ function handleUploadResult(
} }
if (mediaType === 'video' && assetKey) { if (mediaType === 'video' && assetKey) {
const url = imgData.public_url; sceneManager.addVideoFromUpload(assetKey, finalW, finalH, x, y);
const video = new VideoSprite(assetKey, finalW, finalH, url);
video.position.set(x, y);
viewport.addChild(video);
} else if (assetKey) { } else if (assetKey) {
sceneManager.addImageFromUpload(assetKey, finalW, finalH, x, y); sceneManager.addImageFromUpload(assetKey, finalW, finalH, x, y);
} else { } else {
@@ -122,18 +118,23 @@ export function setupDragDrop(
return; return;
} }
let dropIndex = 0;
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) continue; if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) continue;
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top); const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
const placeholder = createPlaceholder(viewport, world.x, world.y); // Offset each subsequent file so they don't overlap
const offsetX = dropIndex * 30;
const offsetY = dropIndex * 30;
const placeholder = createPlaceholder(viewport, world.x + offsetX, world.y + offsetY);
dropIndex++;
try { try {
const res = await uploadImage(boardId, file); const res = await uploadImage(boardId, file);
removePlaceholder(viewport, placeholder); removePlaceholder(viewport, placeholder);
handleUploadResult(res, viewport, sceneManager, world.x, world.y, onChange); handleUploadResult(res, viewport, sceneManager, world.x + offsetX, world.y + offsetY, onChange);
} catch (err) { } catch (err) {
console.error('Image upload failed:', err); console.error('Image upload failed:', err);
removePlaceholder(viewport, placeholder); removePlaceholder(viewport, placeholder);
@@ -141,10 +142,22 @@ export function setupDragDrop(
} }
} }
// Prevent browser default (opening file in new tab) on the whole document
function onDocDragOver(e: DragEvent) {
e.preventDefault();
}
function onDocDrop(e: DragEvent) {
e.preventDefault();
}
document.addEventListener('dragover', onDocDragOver);
document.addEventListener('drop', onDocDrop);
container.addEventListener('dragover', onDragOver); container.addEventListener('dragover', onDragOver);
container.addEventListener('drop', onDrop); container.addEventListener('drop', onDrop);
return () => { return () => {
document.removeEventListener('dragover', onDocDragOver);
document.removeEventListener('drop', onDocDrop);
container.removeEventListener('dragover', onDragOver); container.removeEventListener('dragover', onDragOver);
container.removeEventListener('drop', onDrop); container.removeEventListener('drop', onDrop);
}; };
+88
View File
@@ -350,6 +350,94 @@ export function toggleLocked(objects: SceneItem[]) {
}); });
} }
// ─── Nudge ───
export function nudge(objects: SceneItem[], dx: number, dy: number) {
objects.forEach((item) => {
item.data.x += dx;
item.data.y += dy;
syncPosition(item);
});
}
// ─── Scale (relative) ───
export function scaleBy(objects: SceneItem[], factor: number) {
objects.forEach((item) => {
const cx = item.data.x + scaledW(item) / 2;
const cy = item.data.y + scaledH(item) / 2;
item.data.sx *= factor;
item.data.sy *= factor;
// Keep center in place
item.data.x = cx - scaledW(item) / 2;
item.data.y = cy - scaledH(item) / 2;
syncTransform(item);
});
}
// ─── Rotate (quick 90° snap) ───
export function rotate90(objects: SceneItem[], clockwise: boolean) {
objects.forEach((item) => {
item.data.angle = ((item.data.angle + (clockwise ? 90 : -90)) % 360 + 360) % 360;
item.displayObject.angle = item.data.angle;
});
}
// ─── Set Opacity ───
export function setOpacity(objects: SceneItem[], opacity: number) {
const clamped = Math.max(0, Math.min(1, opacity));
objects.forEach((item) => {
item.data.opacity = clamped;
item.displayObject.alpha = clamped;
});
}
// ─── Align Center ───
export function alignCenterH(objects: SceneItem[]) {
if (objects.length < 2) return;
const avgCX = objects.reduce((s, item) => s + item.data.x + scaledW(item) / 2, 0) / objects.length;
objects.forEach((item) => {
item.data.x = avgCX - scaledW(item) / 2;
syncPosition(item);
});
}
export function alignCenterV(objects: SceneItem[]) {
if (objects.length < 2) return;
const avgCY = objects.reduce((s, item) => s + item.data.y + scaledH(item) / 2, 0) / objects.length;
objects.forEach((item) => {
item.data.y = avgCY - scaledH(item) / 2;
syncPosition(item);
});
}
// ─── Equal Spacing ───
export function equalSpacingH(objects: SceneItem[], gap = 20) {
if (objects.length < 2) return;
const sorted = [...objects].sort((a, b) => a.data.x - b.data.x);
let x = sorted[0].data.x + scaledW(sorted[0]) + gap;
for (let i = 1; i < sorted.length; i++) {
sorted[i].data.x = x;
syncPosition(sorted[i]);
x += scaledW(sorted[i]) + gap;
}
}
export function equalSpacingV(objects: SceneItem[], gap = 20) {
if (objects.length < 2) return;
const sorted = [...objects].sort((a, b) => a.data.y - b.data.y);
let y = sorted[0].data.y + scaledH(sorted[0]) + gap;
for (let i = 1; i < sorted.length; i++) {
sorted[i].data.y = y;
syncPosition(sorted[i]);
y += scaledH(sorted[i]) + gap;
}
}
// ─── Overlay / Compare ─── // ─── Overlay / Compare ───
export function overlayCompare(objects: SceneItem[]) { export function overlayCompare(objects: SceneItem[]) {
+12 -2
View File
@@ -4,7 +4,7 @@
export interface SceneObject { export interface SceneObject {
id: string; id: string;
type: 'image' | 'video' | 'text' | 'group'; type: 'image' | 'video' | 'text' | 'group' | 'drawing';
x: number; x: number;
y: number; y: number;
w: number; w: number;
@@ -42,12 +42,22 @@ export interface TextObject extends SceneObject {
fontFamily: string; fontFamily: string;
} }
export interface DrawingObject extends SceneObject {
type: 'drawing';
points: number[]; // flat array: [x0, y0, x1, y1, ...]
color: string;
strokeWidth: number;
}
export interface GroupObject extends SceneObject { export interface GroupObject extends SceneObject {
type: 'group'; type: 'group';
children: string[]; children: string[];
bgColor?: string; // frame background color (e.g. '#2a2a3a'), empty/undefined = transparent
label?: string; // frame title label
padding?: number; // inner padding around children (default 12)
} }
export type AnySceneObject = ImageObject | VideoObject | TextObject | GroupObject; export type AnySceneObject = ImageObject | VideoObject | TextObject | DrawingObject | GroupObject;
export interface SceneData { export interface SceneData {
v: 2; v: 2;
+299 -122
View File
@@ -13,9 +13,103 @@
* - Added Ctrl+S (prevent browser save-as) * - Added Ctrl+S (prevent browser save-as)
*/ */
import { ShortcutDef } from './shortcuts'; import { ShortcutDef, ShortcutContext } from './shortcuts';
import type { SceneItem, SceneManager } from './SceneManager';
import type { GroupObject } from './scene-format';
import * as ops from './operations'; import * as ops from './operations';
// Tracks when the last internal copy happened so paste can decide
// whether to use internal clipboard (just copied) vs system clipboard (external app).
let _lastInternalCopyTime = 0;
/** Mark that an internal copy just happened. Called by copy/cut handlers. */
export function markInternalCopy(): void {
_lastInternalCopyTime = Date.now();
}
/** Collect selected items + their group children (for deep clone). */
function _collectGroupChildren(items: SceneItem[], scene: SceneManager): SceneItem[] {
const all: SceneItem[] = [];
const seen = new Set<string>();
for (const item of items) {
if (seen.has(item.id)) continue;
seen.add(item.id);
all.push(item);
if (item.data.type === 'group') {
const gd = item.data as GroupObject;
for (const cid of gd.children) {
if (seen.has(cid)) continue;
seen.add(cid);
const child = scene.getById(cid);
if (child) all.push(child);
}
}
}
return all;
}
/** Deep-clone a list of scene items, remapping group children IDs. */
function _cloneItemsWithOffset(
items: { data: any }[],
scene: { nextZ: () => number },
offsetX = 20,
offsetY = 20,
): any[] {
// First pass: build old→new ID mapping for all items
const idMap = new Map<string, string>();
for (const item of items) {
idMap.set(item.data.id, crypto.randomUUID());
}
// Second pass: clone data with new IDs and remapped children
const clones: any[] = [];
for (const item of items) {
const newId = idMap.get(item.data.id)!;
const newData = {
...item.data,
id: newId,
x: item.data.x + offsetX,
y: item.data.y + offsetY,
z: scene.nextZ(),
};
// Remap group children references
if (newData.type === 'group' && Array.isArray(newData.children)) {
newData.children = newData.children
.map((cid: string) => idMap.get(cid) ?? cid)
.filter((cid: string) => idMap.has(cid) || items.some((i) => i.data.id === cid));
}
clones.push(newData);
}
return clones;
}
/** Paste from internal clipboard — duplicates scene items with offset. */
async function _pasteInternal(ctx: ShortcutContext): Promise<void> {
const clones = _cloneItemsWithOffset(ctx.clipboardRef.current, ctx.scene);
const newItems: typeof ctx.clipboardRef.current = [];
// Create children first, then groups (so children exist when group is created)
const sorted = [...clones].sort((a, b) =>
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
for (const newData of sorted) {
await ctx.scene._createItem(newData, true);
const newItem = ctx.scene.getById(newData.id);
if (newItem) newItems.push(newItem);
}
ctx.clipboardRef.current = newItems;
ctx.scene._applyZOrder();
ctx.onChange();
}
/** Run op on selected items → update transform box → fire onChange. */
function _opUpdate(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void {
const items = ctx.selection.getSelectedItems();
op(items);
ctx.selection.transformBox.update(items);
ctx.onChange();
}
export const shortcuts: ShortcutDef[] = [ export const shortcuts: ShortcutDef[] = [
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -25,34 +119,22 @@ export const shortcuts: ShortcutDef[] = [
{ {
id: 'align-left', keys: { key: 'arrowleft', ctrl: true }, id: 'align-left', keys: { key: 'arrowleft', ctrl: true },
category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2, category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.alignLeft),
ops.alignLeft(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'align-right', keys: { key: 'arrowright', ctrl: true }, id: 'align-right', keys: { key: 'arrowright', ctrl: true },
category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2, category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.alignRight),
ops.alignRight(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'align-top', keys: { key: 'arrowup', ctrl: true }, id: 'align-top', keys: { key: 'arrowup', ctrl: true },
category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2, category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.alignTop),
ops.alignTop(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true }, id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true },
category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2, category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.alignBottom),
ops.alignBottom(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -62,18 +144,12 @@ export const shortcuts: ShortcutDef[] = [
{ {
id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true }, id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true },
category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3, category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.distributeHorizontal),
ops.distributeHorizontal(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true }, id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true },
category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3, category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.distributeVertical),
ops.distributeVertical(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -83,34 +159,22 @@ export const shortcuts: ShortcutDef[] = [
{ {
id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true }, id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2, category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.normalizeSize),
ops.normalizeSize(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true }, id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2, category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.normalizeScale),
ops.normalizeScale(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true }, id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2, category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.normalizeHeight),
ops.normalizeHeight(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true }, id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2, category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.normalizeWidth),
ops.normalizeWidth(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -120,42 +184,27 @@ export const shortcuts: ShortcutDef[] = [
{ {
id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true }, id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true },
category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.arrangeOptimal),
ops.arrangeOptimal(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true }, id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true },
category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.arrangeByName),
ops.arrangeByName(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true }, id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true },
category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.arrangeByZOrder),
ops.arrangeByZOrder(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true }, id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true },
category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.arrangeRandomly),
ops.arrangeRandomly(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'stack', keys: { key: 's', ctrl: true, alt: true }, id: 'stack', keys: { key: 's', ctrl: true, alt: true },
category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.stackObjects),
ops.stackObjects(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -165,26 +214,17 @@ export const shortcuts: ShortcutDef[] = [
{ {
id: 'flip-h', keys: { key: 'h', alt: true, shift: true }, id: 'flip-h', keys: { key: 'h', alt: true, shift: true },
category: 'image', description: 'Flip horizontal', needsSelection: true, category: 'image', description: 'Flip horizontal', needsSelection: true,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.flipHorizontal),
ops.flipHorizontal(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'flip-v', keys: { key: 'v', alt: true, shift: true }, id: 'flip-v', keys: { key: 'v', alt: true, shift: true },
category: 'image', description: 'Flip vertical', needsSelection: true, category: 'image', description: 'Flip vertical', needsSelection: true,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.flipVertical),
ops.flipVertical(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true }, id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true },
category: 'image', description: 'Reset transform', needsSelection: true, category: 'image', description: 'Reset transform', needsSelection: true,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.resetTransform),
ops.resetTransform(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'toggle-grayscale', keys: { key: 'g', alt: true }, id: 'toggle-grayscale', keys: { key: 'g', alt: true },
@@ -199,16 +239,95 @@ export const shortcuts: ShortcutDef[] = [
category: 'image', description: 'Toggle locked', needsSelection: true, category: 'image', description: 'Toggle locked', needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
ops.toggleLocked(ctx.selection.getSelectedItems()); ops.toggleLocked(ctx.selection.getSelectedItems());
ctx.onChange();
ctx.refreshLayers(); ctx.refreshLayers();
}, },
}, },
{ {
id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true }, id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true },
category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2, category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => _opUpdate(ctx, ops.overlayCompare),
ops.overlayCompare(ctx.selection.getSelectedItems()); },
ctx.onChange();
}, // ═══════════════════════════════════════
// NUDGE (Arrow keys with selection)
// ═══════════════════════════════════════
// Shift+Arrow = 10px nudge (more specific, checked first)
{
id: 'nudge-left-10', keys: { key: 'arrowleft', shift: true },
category: 'arrangement', description: 'Nudge left 10px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, -10, 0)),
},
{
id: 'nudge-right-10', keys: { key: 'arrowright', shift: true },
category: 'arrangement', description: 'Nudge right 10px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 10, 0)),
},
{
id: 'nudge-up-10', keys: { key: 'arrowup', shift: true },
category: 'arrangement', description: 'Nudge up 10px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -10)),
},
{
id: 'nudge-down-10', keys: { key: 'arrowdown', shift: true },
category: 'arrangement', description: 'Nudge down 10px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 10)),
},
// ═══════════════════════════════════════
// SCALE & ROTATE (selection)
// ═══════════════════════════════════════
{
id: 'scale-up', keys: { key: '=', alt: true },
category: 'image', description: 'Scale up 10%', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1.1)),
},
{
id: 'scale-down', keys: { key: '-', alt: true },
category: 'image', description: 'Scale down 10%', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1 / 1.1)),
},
{
id: 'rotate-cw', keys: { key: 'r' },
category: 'image', description: 'Rotate 90° clockwise', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, true)),
},
{
id: 'rotate-ccw', keys: { key: 'r', shift: true },
category: 'image', description: 'Rotate 90° counter-clockwise', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, false)),
},
// ═══════════════════════════════════════
// ALIGN CENTER
// ═══════════════════════════════════════
{
id: 'align-center-h', keys: { key: 'arrowleft', ctrl: true, shift: true },
category: 'alignment', description: 'Align center horizontal', needsSelection: true, minSelection: 2,
handler: (ctx) => _opUpdate(ctx, ops.alignCenterH),
},
{
id: 'align-center-v', keys: { key: 'arrowup', ctrl: true, shift: true },
category: 'alignment', description: 'Align center vertical', needsSelection: true, minSelection: 2,
handler: (ctx) => _opUpdate(ctx, ops.alignCenterV),
},
// ═══════════════════════════════════════
// EQUAL SPACING
// ═══════════════════════════════════════
{
id: 'equal-spacing-h', keys: { key: 'h', ctrl: true, shift: true },
category: 'arrangement', description: 'Equal horizontal spacing', needsSelection: true, minSelection: 2,
handler: (ctx) => _opUpdate(ctx, ops.equalSpacingH),
},
{
id: 'equal-spacing-v', keys: { key: 'v', ctrl: true, shift: true },
category: 'arrangement', description: 'Equal vertical spacing', needsSelection: true, minSelection: 2,
handler: (ctx) => _opUpdate(ctx, ops.equalSpacingV),
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -227,32 +346,55 @@ export const shortcuts: ShortcutDef[] = [
category: 'navigation', description: 'Fit all in view', category: 'navigation', description: 'Fit all in view',
handler: (ctx) => ctx.fitAll(), handler: (ctx) => ctx.fitAll(),
}, },
// Bare arrow Left/Right only cycle when nothing is selected.
// When something IS selected, they're no-ops (prevent accidental cycling).
// Layer ordering uses ] / [ to avoid arrow conflicts.
{ {
id: 'cycle-next', keys: { key: 'arrowright' }, id: 'focus-selection', keys: { key: 'f' },
category: 'navigation', description: 'Select next object', category: 'navigation', description: 'Fit selection in view', needsSelection: true,
handler: (ctx) => ctx.fitSelection(),
},
{
id: 'toggle-focus-mode', keys: { key: 'tab' },
category: 'view', description: 'Toggle focus mode (hide UI)',
handler: (ctx) => ctx.toggleFocusMode(),
},
// Bare arrows: nudge 1px if selection, cycle if no selection
{
id: 'nudge-or-cycle-right', keys: { key: 'arrowright' },
category: 'navigation', description: 'Nudge 1px / Select next',
handler: (ctx) => { handler: (ctx) => {
if (ctx.selection.selectedIds.size > 0) return; if (ctx.selection.selectedIds.size > 0) {
const all = ctx.scene.getAllItems(); _opUpdate(ctx, (items) => ops.nudge(items, 1, 0));
if (all.length === 0) return; } else {
// Sort by z to get consistent order const all = ctx.scene.getAllItems();
all.sort((a, b) => a.data.z - b.data.z); if (all.length === 0) return;
ctx.selection.selectOnly(all[0].id); all.sort((a, b) => a.data.z - b.data.z);
ctx.selection.selectOnly(all[0].id);
}
}, },
}, },
{ {
id: 'cycle-prev', keys: { key: 'arrowleft' }, id: 'nudge-or-cycle-left', keys: { key: 'arrowleft' },
category: 'navigation', description: 'Select previous object', category: 'navigation', description: 'Nudge 1px / Select prev',
handler: (ctx) => { handler: (ctx) => {
if (ctx.selection.selectedIds.size > 0) return; if (ctx.selection.selectedIds.size > 0) {
const all = ctx.scene.getAllItems(); _opUpdate(ctx, (items) => ops.nudge(items, -1, 0));
if (all.length === 0) return; } else {
all.sort((a, b) => a.data.z - b.data.z); const all = ctx.scene.getAllItems();
ctx.selection.selectOnly(all[all.length - 1].id); if (all.length === 0) return;
all.sort((a, b) => a.data.z - b.data.z);
ctx.selection.selectOnly(all[all.length - 1].id);
}
}, },
}, },
{
id: 'nudge-up', keys: { key: 'arrowup' },
category: 'navigation', description: 'Nudge up 1px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -1)),
},
{
id: 'nudge-down', keys: { key: 'arrowdown' },
category: 'navigation', description: 'Nudge down 1px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 1)),
},
// Layer ordering: ] brings forward, [ sends backward // Layer ordering: ] brings forward, [ sends backward
{ {
id: 'send-to-front', keys: { key: ']' }, id: 'send-to-front', keys: { key: ']' },
@@ -311,7 +453,9 @@ export const shortcuts: ShortcutDef[] = [
handler: (ctx) => { handler: (ctx) => {
const selected = ctx.selection.getSelectedItems(); const selected = ctx.selection.getSelectedItems();
if (selected.length > 0) { if (selected.length > 0) {
ctx.clipboardRef.current = [...selected]; // Include group children in clipboard for proper paste
ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene);
markInternalCopy();
ctx.writeCanvasToClipboard(selected); ctx.writeCanvasToClipboard(selected);
ctx.showToast('Copied'); ctx.showToast('Copied');
} else { } else {
@@ -332,24 +476,36 @@ export const shortcuts: ShortcutDef[] = [
id: 'paste', keys: { key: 'v', ctrl: true }, id: 'paste', keys: { key: 'v', ctrl: true },
category: 'editing', description: 'Paste', category: 'editing', description: 'Paste',
handler: async (ctx) => { handler: async (ctx) => {
if (ctx.clipboardRef.current.length === 0) return; // Strategy: Check system clipboard for images first.
const newItems: typeof ctx.clipboardRef.current = []; // - If system clipboard has an image AND we did NOT just do an internal copy
for (const original of ctx.clipboardRef.current) { // (or it's been a while), paste from system clipboard (external image).
// Clone: duplicate the item data with new ID and offset position // - If we just did an internal copy (lastInternalCopyTime is recent),
const newData = { // use internal clipboard to duplicate scene items (preserves vector data).
...original.data, // - If system clipboard has no images, fall back to internal clipboard.
id: crypto.randomUUID(),
x: original.data.x + 20, const timeSinceInternalCopy = Date.now() - _lastInternalCopyTime;
y: original.data.y + 20, const hasInternalItems = ctx.clipboardRef.current.length > 0;
z: ctx.scene.nextZ(), const recentInternalCopy = hasInternalItems && timeSinceInternalCopy < 500;
};
await ctx.scene._createItem(newData, true); // If we JUST did an internal copy (<500ms ago), use internal clipboard
const newItem = ctx.scene.getById(newData.id); // (the system clipboard image is just the rasterized version of what we copied)
if (newItem) newItems.push(newItem); if (recentInternalCopy) {
await _pasteInternal(ctx);
return;
}
// Try system clipboard first
try {
const result = await ctx.pasteFromSystemClipboard();
if (result === 'Pasted image') return;
} catch {
// Clipboard API denied or unavailable — fall through
}
// Fall back to internal clipboard
if (hasInternalItems) {
await _pasteInternal(ctx);
} }
ctx.clipboardRef.current = newItems;
ctx.scene._applyZOrder();
ctx.onChange();
}, },
}, },
{ {
@@ -358,7 +514,8 @@ export const shortcuts: ShortcutDef[] = [
handler: (ctx) => { handler: (ctx) => {
const selected = ctx.selection.getSelectedItems(); const selected = ctx.selection.getSelectedItems();
if (selected.length === 0) return; if (selected.length === 0) return;
ctx.clipboardRef.current = [...selected]; ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene);
markInternalCopy();
ctx.writeCanvasToClipboard(selected); ctx.writeCanvasToClipboard(selected);
for (const item of selected) { for (const item of selected) {
ctx.scene.removeItem(item.id, true); ctx.scene.removeItem(item.id, true);
@@ -374,14 +531,13 @@ export const shortcuts: ShortcutDef[] = [
handler: async (ctx) => { handler: async (ctx) => {
const selected = ctx.selection.getSelectedItems(); const selected = ctx.selection.getSelectedItems();
if (selected.length === 0) return; if (selected.length === 0) return;
for (const original of selected) {
const newData = { // Collect group children and clone with remapped IDs
...original.data, const allItems = _collectGroupChildren(selected, ctx.scene);
id: crypto.randomUUID(), const clones = _cloneItemsWithOffset(allItems, ctx.scene);
x: original.data.x + 20, const sorted = [...clones].sort((a: any, b: any) =>
y: original.data.y + 20, (a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
z: ctx.scene.nextZ(), for (const newData of sorted) {
};
await ctx.scene._createItem(newData, true); await ctx.scene._createItem(newData, true);
} }
ctx.selection.clear(); ctx.selection.clear();
@@ -455,6 +611,27 @@ export const shortcuts: ShortcutDef[] = [
category: 'editing', description: 'Ungroup', needsSelection: true, category: 'editing', description: 'Ungroup', needsSelection: true,
handler: (ctx) => ctx.handleUngroup(), handler: (ctx) => ctx.handleUngroup(),
}, },
// Opacity: [ and ] with Alt
{
id: 'opacity-down', keys: { key: '[', alt: true },
category: 'image', description: 'Decrease opacity 10%', needsSelection: true,
handler: (ctx) => {
const items = ctx.selection.getSelectedItems();
const current = items[0]?.data.opacity ?? 1;
ops.setOpacity(items, Math.max(0.1, current - 0.1));
ctx.onChange();
},
},
{
id: 'opacity-up', keys: { key: ']', alt: true },
category: 'image', description: 'Increase opacity 10%', needsSelection: true,
handler: (ctx) => {
const items = ctx.selection.getSelectedItems();
const current = items[0]?.data.opacity ?? 1;
ops.setOpacity(items, Math.min(1, current + 0.1));
ctx.onChange();
},
},
// Block Ctrl+S from opening browser save-as dialog // Block Ctrl+S from opening browser save-as dialog
{ {
id: 'save', keys: { key: 's', ctrl: true }, id: 'save', keys: { key: 's', ctrl: true },
+3
View File
@@ -43,7 +43,10 @@ export interface ShortcutContext {
handleUngroup: () => void; handleUngroup: () => void;
toggleGrid: () => void; toggleGrid: () => void;
toggleShowHelp: () => void; toggleShowHelp: () => void;
toggleFocusMode: () => void;
fitSelection: () => void;
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>; writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
pasteFromSystemClipboard: () => Promise<string>;
} }
/** /**
@@ -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 { Container, Sprite, Texture, Graphics } from "pixi.js";
import { DropShadowFilter } from "pixi-filters"; import { TextureManager } from "../TextureManager";
import { TextureManager, LODTier } from "../TextureManager";
/** /**
* A Sprite subclass that manages LOD tier switching and lazy loading. * A Container holding a shadow graphic + sprite with lazy texture loading.
* Shows a placeholder shimmer rect until the first texture tier loads, * Uses a lightweight Graphics shadow instead of DropShadowFilter (GPU-heavy).
* then swaps textures as zoom level changes.
*/ */
// Shadow defaults (resting state) // 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) // 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 assetKey: string;
readonly shadow: DropShadowFilter;
private textures: TextureManager; private textures: TextureManager;
private currentTier: LODTier | null = null; private loaded = false;
private loading = false; private loading = false;
private placeholder: Graphics | null = null; private placeholder: Graphics | null = null;
private _sprite: Sprite;
private _shadow: Graphics;
private _naturalWidth: number; private _naturalWidth: number;
private _naturalHeight: number; private _naturalHeight: number;
@@ -29,24 +28,23 @@ export class ImageSprite extends Sprite {
h: number, h: number,
textures: TextureManager, textures: TextureManager,
) { ) {
super(Texture.EMPTY); super();
this.assetKey = assetKey; this.assetKey = assetKey;
this.textures = textures; this.textures = textures;
this._naturalWidth = w; this._naturalWidth = w;
this._naturalHeight = h; this._naturalHeight = h;
this.width = w; // Shadow: simple dark rect behind the sprite (cheap, no GPU filter)
this.height = h; this._shadow = new Graphics();
this._drawShadow(SHADOW_REST);
this.addChild(this._shadow);
// Drop shadow for photos-on-a-desk feel // Main sprite
this.shadow = new DropShadowFilter({ this._sprite = new Sprite(Texture.EMPTY);
offset: { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY }, this._sprite.width = w;
blur: SHADOW_REST.blur, this._sprite.height = h;
alpha: SHADOW_REST.alpha, this.addChild(this._sprite);
color: 0x000000,
});
this.filters = [this.shadow];
// Create placeholder: dark rect shown until first texture loads // Create placeholder: dark rect shown until first texture loads
const placeholder = new Graphics(); const placeholder = new Graphics();
@@ -54,40 +52,39 @@ export class ImageSprite extends Sprite {
this.placeholder = placeholder; this.placeholder = placeholder;
this.addChild(placeholder); this.addChild(placeholder);
// Immediately start loading the thumbnail tier // Immediately start loading the full texture
this.loadTier("thumb"); 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. */ /** Expand shadow for drag-lift effect. */
liftShadow(): void { liftShadow(): void {
this.shadow.offset = { x: SHADOW_LIFT.offsetX, y: SHADOW_LIFT.offsetY }; this._drawShadow(SHADOW_LIFT);
this.shadow.blur = SHADOW_LIFT.blur;
this.shadow.alpha = SHADOW_LIFT.alpha;
} }
/** Restore shadow to resting state. */ /** Restore shadow to resting state. */
dropShadow(): void { dropShadow(): void {
this.shadow.offset = { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY }; this._drawShadow(SHADOW_REST);
this.shadow.blur = SHADOW_REST.blur;
this.shadow.alpha = SHADOW_REST.alpha;
} }
/** /** Load the full-res texture. GPU handles scaling natively. */
* Load a specific LOD tier texture for this sprite. async loadTexture(): Promise<void> {
* Skips if already at the requested tier or currently loading. if (this.loaded || this.loading) return;
*/
async loadTier(tier: LODTier): Promise<void> {
if (this.currentTier === tier || this.loading) return;
this.loading = true; this.loading = true;
try { try {
const tex = await this.textures.load(this.assetKey, tier); const tex = await this.textures.load(this.assetKey);
this.texture = tex; this._sprite.texture = tex;
this.width = this._naturalWidth; this._sprite.width = this._naturalWidth;
this.height = this._naturalHeight; this._sprite.height = this._naturalHeight;
this.currentTier = tier; this.loaded = true;
// Remove placeholder after first successful load // Remove placeholder after successful load
if (this.placeholder) { if (this.placeholder) {
this.removeChild(this.placeholder); this.removeChild(this.placeholder);
this.placeholder.destroy(); this.placeholder.destroy();
@@ -95,29 +92,11 @@ export class ImageSprite extends Sprite {
} }
} catch (err) { } catch (err) {
console.warn( console.warn(
`[ImageSprite] Failed to load tier "${tier}" for "${this.assetKey}":`, `[ImageSprite] Failed to load "${this.assetKey}":`,
err, err,
); );
} finally { } finally {
this.loading = false; 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;
}
} }
+196 -51
View File
@@ -1,47 +1,128 @@
import type { Socket } from 'socket.io-client'; import type { Socket } from 'socket.io-client';
import type { SceneManager, SceneItem } from './SceneManager'; import type { SceneManager, SceneItem } from './SceneManager';
import type { SceneData } from './scene-format'; import type { SceneData, AnySceneObject } from './scene-format';
/** /**
* Full-scene sync (v2 format, PixiJS / SceneManager): * Sync protocol v3 — Excalidraw-inspired incremental element sync.
* *
* 1. On any change → broadcast serialized SceneData (throttled 300ms) * Three event tiers:
* 2. On receive → sceneManager.loadScene() (diff-based, no flicker) * 1. scene:update — full scene snapshot. Sent on join (INIT) and periodically
* 3. During drag → lightweight transform events (throttled 50ms) * to correct any drift. Receiver does full reconciliation via loadScene().
* 2. element:update — array of changed elements only. Sent on any element
* change (add, modify, draw). Receiver merges incrementally — no full scene
* diff needed. Each element carries a `_v` version; receiver skips stale.
* 3. element:remove — array of removed element IDs. Receiver deletes them.
* 4. object:transform — ephemeral position/scale during drag (unchanged).
* *
* Diff-based loading doesn't trigger change events for unchanged objects, * During freehand drawing, only the single drawing element is sent via
* so no suppress/resume logic is needed. * element:update every ~60ms (one frame) — tiny payload, no lag.
*/ */
export interface SyncHandle {
cleanup: () => void;
broadcastTransform: (item: SceneItem) => void;
/** Force an immediate full scene broadcast (call after structural changes). */
broadcastSceneNow: () => void;
/** Broadcast only specific changed elements (lightweight). */
broadcastElements: (ids: string[]) => void;
}
export interface SyncOptions {
onRemoteTransform?: (item: SceneItem) => void;
}
export function setupSync( export function setupSync(
sceneManager: SceneManager, sceneManager: SceneManager,
socket: Socket, socket: Socket,
boardId: string, boardId: string,
): () => void { options?: SyncOptions,
let sceneTimer: ReturnType<typeof setTimeout> | null = null; ): SyncHandle {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let moveTimer: ReturnType<typeof setTimeout> | null = null; let moveTimer: ReturnType<typeof setTimeout> | null = null;
let receiving = false; // true while applying a remote scene/transform let receiving = false;
const SCENE_THROTTLE = 300; // ms let localVersion = 0;
const MOVE_THROTTLE = 50; // ms let remoteVersion = 0;
const DEBOUNCE_MS = 500;
const MOVE_THROTTLE = 50;
// ---- BROADCAST: full scene (throttled) -------------------------------- // Track broadcasted element versions (like Excalidraw's broadcastedElementVersions)
const broadcastedVersions: Map<string, number> = new Map();
function broadcastScene() { // Per-element version counter — bumped whenever an element changes locally
if (receiving) return; const elementVersions: Map<string, number> = new Map();
if (sceneTimer) clearTimeout(sceneTimer);
sceneTimer = setTimeout(() => { function bumpElementVersion(id: string): number {
sceneTimer = null; const v = (elementVersions.get(id) ?? 0) + 1;
if (receiving) return; elementVersions.set(id, v);
const scene = sceneManager.serialize(); return v;
socket.emit('scene:update', { boardId, scene });
}, SCENE_THROTTLE);
} }
// ---- BROADCAST: lightweight transform during drag --------------------- // ---- BROADCAST: incremental element update --------------------------------
/** Send only the specified elements. Skips if element hasn't changed since last broadcast. */
function broadcastElements(ids: string[]) {
if (receiving) return;
const elements: (AnySceneObject & { _v: number })[] = [];
for (const id of ids) {
const item = sceneManager.getById(id);
if (!item) continue;
const v = elementVersions.get(id) ?? 0;
const lastBroadcasted = broadcastedVersions.get(id) ?? -1;
if (v <= lastBroadcasted) continue;
elements.push({ ...item.data, _v: v });
broadcastedVersions.set(id, v);
}
if (elements.length === 0) return;
socket.emit('element:update', { boardId, elements });
}
/** Broadcast all elements that changed since last broadcast. */
function broadcastChangedElements() {
if (receiving) return;
const ids: string[] = [];
for (const [id, v] of elementVersions) {
if (v > (broadcastedVersions.get(id) ?? -1)) {
ids.push(id);
}
}
if (ids.length > 0) broadcastElements(ids);
}
// ---- BROADCAST: full scene (for INIT / periodic resync) -------------------
function broadcastSceneNow() {
if (receiving) return;
if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; }
localVersion++;
const scene = sceneManager.serialize();
// Update all broadcasted versions
for (const obj of scene.objects) {
const v = elementVersions.get(obj.id) ?? 0;
broadcastedVersions.set(obj.id, v);
}
socket.emit('scene:update', { boardId, scene, version: localVersion });
}
function broadcastSceneDebounced() {
if (receiving) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
broadcastSceneNow();
}, DEBOUNCE_MS);
}
// ---- BROADCAST: lightweight transform during drag -------------------------
function broadcastTransform(item: SceneItem) { function broadcastTransform(item: SceneItem) {
if (receiving) return; if (receiving) return;
if (moveTimer) return; // throttled if (moveTimer) return;
const { id, data } = item; const { id, data } = item;
socket.emit('object:transform', { socket.emit('object:transform', {
boardId, boardId,
@@ -52,22 +133,34 @@ export function setupSync(
sy: data.sy, sy: data.sy,
angle: data.angle, angle: data.angle,
}); });
moveTimer = setTimeout(() => { moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE);
moveTimer = null; broadcastSceneDebounced();
}, MOVE_THROTTLE);
} }
// ---- RECEIVE: full scene ---------------------------------------------- // ---- RECEIVE: full scene --------------------------------------------------
function onSceneReceived(payload: any) { function onSceneReceived(payload: any) {
if (payload.boardId !== boardId) return; if (payload.boardId !== boardId) return;
const scene: SceneData = payload.scene; const scene: SceneData = payload.scene;
if (!scene || scene.v !== 2) return; if (!scene || scene.v !== 2) return;
const incomingVersion = payload.version ?? 0;
if (incomingVersion > 0 && incomingVersion <= remoteVersion) return;
remoteVersion = incomingVersion;
if (incomingVersion >= localVersion) localVersion = incomingVersion;
receiving = true; receiving = true;
sceneManager sceneManager
.loadScene(scene) .loadScene(scene)
.then(() => { .then(() => {
// Update element versions from received data
for (const obj of scene.objects) {
const v = (obj as any)._v;
if (typeof v === 'number') {
const current = elementVersions.get(obj.id) ?? 0;
if (v > current) elementVersions.set(obj.id, v);
}
}
receiving = false; receiving = false;
}) })
.catch((err: any) => { .catch((err: any) => {
@@ -76,42 +169,91 @@ export function setupSync(
}); });
} }
// ---- RECEIVE: lightweight transform ----------------------------------- // ---- RECEIVE: incremental element update ----------------------------------
function onElementUpdate(payload: any) {
if (payload.boardId !== boardId) return;
const elements: (AnySceneObject & { _v?: number })[] = payload.elements;
if (!Array.isArray(elements) || elements.length === 0) return;
for (const data of elements) {
const incomingV = data._v ?? 0;
// Clean the _v field before storing
const cleanData = { ...data };
delete (cleanData as any)._v;
const existing = sceneManager.getById(data.id);
if (existing) {
// Update existing — apply incremental merge
sceneManager._updateItem(existing, cleanData);
} else {
// New element — create it
sceneManager._createItem(cleanData, false);
}
// Track the version
if (incomingV > 0) {
const current = elementVersions.get(data.id) ?? 0;
if (incomingV > current) elementVersions.set(data.id, incomingV);
}
}
sceneManager._applyZOrder();
}
// ---- RECEIVE: element removal ---------------------------------------------
function onElementRemove(payload: any) {
if (payload.boardId !== boardId) return;
const ids: string[] = payload.ids;
if (!Array.isArray(ids)) return;
for (const id of ids) {
sceneManager.removeItem(id, true);
elementVersions.delete(id);
broadcastedVersions.delete(id);
}
}
// ---- RECEIVE: lightweight transform ---------------------------------------
function onTransformReceived(payload: any) { function onTransformReceived(payload: any) {
if (payload.boardId !== boardId) return; if (payload.boardId !== boardId) return;
const item = sceneManager.getById(payload.objectId); const item = sceneManager.getById(payload.objectId);
if (!item) return; if (!item) return;
// Update data model
item.data.x = payload.x; item.data.x = payload.x;
item.data.y = payload.y; item.data.y = payload.y;
item.data.sx = payload.sx; item.data.sx = payload.sx;
item.data.sy = payload.sy; item.data.sy = payload.sy;
item.data.angle = payload.angle; item.data.angle = payload.angle;
// Update display object
const obj = item.displayObject; const obj = item.displayObject;
obj.position.set(payload.x, payload.y); obj.position.set(payload.x, payload.y);
obj.scale.set(payload.sx, payload.sy); obj.scale.set(payload.sx, payload.sy);
obj.angle = payload.angle; obj.angle = payload.angle;
// No onChange — this is a remote update
options?.onRemoteTransform?.(item);
} }
// ---- Wire up SceneManager onChange → broadcastScene -------------------- // ---- Wire up SceneManager onChange → debounced full sync -------------------
const prevOnChange = sceneManager.onChange; const prevOnChange = sceneManager.onChange;
sceneManager.onChange = () => { sceneManager.onChange = () => {
prevOnChange?.(); prevOnChange?.();
broadcastScene(); // Structural change — schedule a full scene sync (debounced).
// Incremental element sync is handled explicitly by tools via broadcastElements().
broadcastSceneDebounced();
}; };
// ---- Bind socket events ----------------------------------------------- // ---- Bind socket events ---------------------------------------------------
socket.on('scene:update', onSceneReceived); socket.on('scene:update', onSceneReceived);
socket.on('element:update', onElementUpdate);
socket.on('element:remove', onElementRemove);
socket.on('object:transform', onTransformReceived); socket.on('object:transform', onTransformReceived);
// ---- Join room -------------------------------------------------------- // ---- Join room ------------------------------------------------------------
socket.emit('board:join', { boardId }, (response: any) => { socket.emit('board:join', { boardId }, (response: any) => {
if (response?.users) { if (response?.users) {
@@ -119,21 +261,24 @@ export function setupSync(
} }
}); });
// ---- Cleanup ---------------------------------------------------------- // ---- Return handle --------------------------------------------------------
return () => { return {
// Restore previous onChange broadcastTransform,
sceneManager.onChange = prevOnChange; broadcastSceneNow,
broadcastElements(ids: string[]) {
socket.off('scene:update', onSceneReceived); for (const id of ids) bumpElementVersion(id);
socket.off('object:transform', onTransformReceived); broadcastElements(ids);
},
socket.emit('board:leave', { boardId }); cleanup: () => {
sceneManager.onChange = prevOnChange;
if (sceneTimer) clearTimeout(sceneTimer); socket.off('scene:update', onSceneReceived);
if (moveTimer) clearTimeout(moveTimer); socket.off('element:update', onElementUpdate);
socket.off('element:remove', onElementRemove);
socket.off('object:transform', onTransformReceived);
socket.emit('board:leave', { boardId });
if (debounceTimer) clearTimeout(debounceTimer);
if (moveTimer) clearTimeout(moveTimer);
},
}; };
} }
// Note: broadcastTransform for drag events will be wired via Editor integration.
// SelectionManager / TransformBox will call it directly once connected.
+138 -2
View File
@@ -11,6 +11,8 @@ import type { Viewport } from 'pixi-viewport';
import type { SceneManager } from './SceneManager'; import type { SceneManager } from './SceneManager';
import type { SelectionManager } from './SelectionManager'; import type { SelectionManager } from './SelectionManager';
import { Text, TextStyle } from 'pixi.js'; import { Text, TextStyle } from 'pixi.js';
import { DrawingSprite } from './sprites/DrawingSprite';
import type { DrawingObject } from './scene-format';
export enum ToolType { export enum ToolType {
SELECT = 'SELECT', SELECT = 'SELECT',
@@ -40,6 +42,8 @@ export interface ToolContext {
selection: SelectionManager; selection: SelectionManager;
container: HTMLElement; container: HTMLElement;
onChange: () => void; onChange: () => void;
/** Broadcast only specific changed elements (lightweight, for live drawing). */
broadcastElements?: (ids: string[]) => void;
} }
export function activateTool( export function activateTool(
@@ -53,6 +57,9 @@ export function activateTool(
// Reset cursor // Reset cursor
container.style.cursor = ''; container.style.cursor = '';
// Enable/disable SelectionManager based on tool
selection.setEnabled(tool === ToolType.SELECT);
switch (tool) { switch (tool) {
case ToolType.SELECT: { case ToolType.SELECT: {
container.style.cursor = 'default'; container.style.cursor = 'default';
@@ -96,6 +103,7 @@ export function activateTool(
scene._createItem(textData, true); scene._createItem(textData, true);
scene._applyZOrder(); scene._applyZOrder();
ctx.broadcastElements?.([textData.id]);
ctx.onChange(); ctx.onChange();
// Remove handler after placing text // Remove handler after placing text
@@ -110,12 +118,17 @@ export function activateTool(
case ToolType.ERASER: { case ToolType.ERASER: {
container.style.cursor = 'crosshair'; container.style.cursor = 'crosshair';
// Clear any existing selection/transform box when switching to eraser
selection.clear();
selection.transformBox.update([]);
const onClick = (e: PointerEvent) => { const onClick = (e: PointerEvent) => {
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top); const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
const hit = selection._hitTest(world.x, world.y); const hit = selection._hitTest(world.x, world.y);
if (hit) { if (hit) {
selection.selectedIds.delete(hit.id);
selection.transformBox.update([]);
scene.removeItem(hit.id, true); scene.removeItem(hit.id, true);
ctx.onChange(); ctx.onChange();
} }
@@ -129,8 +142,131 @@ export function activateTool(
case ToolType.PEN: { case ToolType.PEN: {
container.style.cursor = 'crosshair'; container.style.cursor = 'crosshair';
// Drawing mode stub — will be implemented later
return null; let drawing = false;
let currentSprite: DrawingSprite | null = null;
let originX = 0;
let originY = 0;
let itemId = '';
let syncTimer: ReturnType<typeof setTimeout> | null = null;
const SYNC_INTERVAL = 100; // ~10fps — lightweight element-only sync
const scheduleLiveSync = () => {
if (syncTimer) return;
syncTimer = setTimeout(() => {
syncTimer = null;
if (!drawing) return;
// Copy current points into item.data for serialization
const item = scene.getById(itemId);
if (item && currentSprite) {
(item.data as DrawingObject).points = [...currentSprite.points];
}
// Send only the drawing element, not the entire scene
ctx.broadcastElements?.([itemId]);
}, SYNC_INTERVAL);
};
const onDown = (e: PointerEvent) => {
if (e.button !== 0) return;
drawing = true;
const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
originX = world.x;
originY = world.y;
itemId = crypto.randomUUID();
const drawData: DrawingObject = {
id: itemId,
type: 'drawing',
x: originX,
y: originY,
w: 1,
h: 1,
sx: 1,
sy: 1,
angle: 0,
z: scene.nextZ(),
opacity: 1,
locked: false,
name: '',
visible: true,
points: [0, 0],
color: opts.color!,
strokeWidth: opts.strokeWidth!,
};
scene._createItem(drawData, false);
const item = scene.getById(itemId);
if (item && item.displayObject instanceof DrawingSprite) {
currentSprite = item.displayObject as DrawingSprite;
}
container.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!drawing || !currentSprite) return;
const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
currentSprite.addPoint(world.x - originX, world.y - originY);
scheduleLiveSync();
};
const onUp = () => {
if (!drawing) return;
drawing = false;
if (syncTimer) { clearTimeout(syncTimer); syncTimer = null; }
// Compute bounding box and normalize points so origin = top-left of stroke
const item = scene.getById(itemId);
if (item && currentSprite) {
const pts = currentSprite.points;
if (pts.length < 4) {
scene.removeItem(itemId, false);
} else {
// Find bounds of all points
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i < pts.length; i += 2) {
minX = Math.min(minX, pts[i]);
minY = Math.min(minY, pts[i + 1]);
maxX = Math.max(maxX, pts[i]);
maxY = Math.max(maxY, pts[i + 1]);
}
const sw = opts.strokeWidth!;
// Shift all points so min = strokeWidth/2 (padding for stroke)
const normalized: number[] = [];
for (let i = 0; i < pts.length; i += 2) {
normalized.push(pts[i] - minX + sw / 2);
normalized.push(pts[i + 1] - minY + sw / 2);
}
// Update origin to account for the shift
item.data.x = originX + minX - sw / 2;
item.data.y = originY + minY - sw / 2;
item.data.w = Math.max(maxX - minX + sw, 1);
item.data.h = Math.max(maxY - minY + sw, 1);
(item.data as DrawingObject).points = normalized;
// Redraw with normalized points and reposition
currentSprite.setPoints(normalized);
item.displayObject.position.set(item.data.x, item.data.y);
}
}
currentSprite = null;
scene._applyZOrder();
ctx.onChange();
};
container.addEventListener('pointerdown', onDown);
container.addEventListener('pointermove', onMove);
container.addEventListener('pointerup', onUp);
return () => {
if (syncTimer) clearTimeout(syncTimer);
container.removeEventListener('pointerdown', onDown);
container.removeEventListener('pointermove', onMove);
container.removeEventListener('pointerup', onUp);
};
} }
default: default:
+199
View File
@@ -0,0 +1,199 @@
import React, { useRef, useEffect, useCallback } from 'react';
interface MinimapItem {
x: number;
y: number;
w: number;
h: number;
type: string;
id: string;
}
interface Bounds {
x: number;
y: number;
w: number;
h: number;
}
interface MinimapProps {
items: MinimapItem[];
viewportBounds: Bounds; // world-space visible area
contentBounds: Bounds; // world-space all-content bounds
onNavigate: (worldX: number, worldY: number) => void;
}
const MAP_W = 180;
const MAP_H = 120;
const PADDING_RATIO = 0.1;
const TYPE_COLORS: Record<string, string> = {
image: '#4a9eff',
video: '#ff6b6b',
text: '#69db7c',
drawing: '#ffd43b',
group: '#7950f2',
};
function getColor(type: string): string {
return TYPE_COLORS[type] ?? '#888';
}
/** Compute the union of two bounding boxes. */
function unionBounds(a: Bounds, b: Bounds): Bounds {
const x1 = Math.min(a.x, b.x);
const y1 = Math.min(a.y, b.y);
const x2 = Math.max(a.x + a.w, b.x + b.w);
const y2 = Math.max(a.y + a.h, b.y + b.h);
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
export default function Minimap({ items, viewportBounds, contentBounds, onNavigate }: MinimapProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const draggingRef = useRef(false);
const rafRef = useRef<number | null>(null);
// Compute the mapping from world space to minimap pixel space.
// Returns { offsetX, offsetY, scale } so that:
// minimapX = (worldX - offsetX) * scale
// minimapY = (worldY - offsetY) * scale
const getMapping = useCallback(() => {
const scene = unionBounds(contentBounds, viewportBounds);
// Add 10% padding
const padX = scene.w * PADDING_RATIO;
const padY = scene.h * PADDING_RATIO;
const padded: Bounds = {
x: scene.x - padX,
y: scene.y - padY,
w: scene.w + padX * 2,
h: scene.h + padY * 2,
};
// Avoid division by zero
if (padded.w === 0 || padded.h === 0) {
return { offsetX: padded.x, offsetY: padded.y, scale: 1 };
}
const scale = Math.min(MAP_W / padded.w, MAP_H / padded.h);
return { offsetX: padded.x, offsetY: padded.y, scale };
}, [contentBounds, viewportBounds]);
// Convert minimap pixel coords to world coords
const minimapToWorld = useCallback((mx: number, my: number): { wx: number; wy: number } => {
const { offsetX, offsetY, scale } = getMapping();
return {
wx: mx / scale + offsetX,
wy: my / scale + offsetY,
};
}, [getMapping]);
// Handle pointer interaction (click / drag to navigate)
const handlePointerEvent = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
e.stopPropagation();
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const { wx, wy } = minimapToWorld(mx, my);
onNavigate(wx, wy);
}, [minimapToWorld, onNavigate]);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
draggingRef.current = true;
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
handlePointerEvent(e);
}, [handlePointerEvent]);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
if (!draggingRef.current) return;
handlePointerEvent(e);
}, [handlePointerEvent]);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
draggingRef.current = false;
(e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
}, []);
// Draw minimap
useEffect(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = MAP_W * dpr;
canvas.height = MAP_H * dpr;
ctx.scale(dpr, dpr);
// Clear
ctx.clearRect(0, 0, MAP_W, MAP_H);
const { offsetX, offsetY, scale } = getMapping();
const toX = (wx: number) => (wx - offsetX) * scale;
const toY = (wy: number) => (wy - offsetY) * scale;
// Draw items
for (const item of items) {
const rx = toX(item.x);
const ry = toY(item.y);
const rw = Math.max(item.w * scale, 2);
const rh = Math.max(item.h * scale, 2);
ctx.fillStyle = getColor(item.type);
ctx.fillRect(rx, ry, rw, rh);
}
// Draw viewport frustum
const vx = toX(viewportBounds.x);
const vy = toY(viewportBounds.y);
const vw = viewportBounds.w * scale;
const vh = viewportBounds.h * scale;
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.fillRect(vx, vy, vw, vh);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(vx + 0.5, vy + 0.5, vw, vh);
});
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [items, viewportBounds, contentBounds, getMapping]);
return (
<canvas
ref={canvasRef}
style={{
position: 'fixed',
bottom: 12,
right: 12,
width: MAP_W,
height: MAP_H,
background: 'rgba(20, 20, 20, 0.9)',
border: '1px solid #333',
borderRadius: 8,
cursor: 'crosshair',
pointerEvents: 'auto',
zIndex: 100,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
@@ -0,0 +1,200 @@
import React from 'react';
interface SelectionToolbarProps {
/** Screen-space position of the selection's top-center */
x: number;
y: number;
count: number;
onAlignLeft: () => void;
onAlignCenterH: () => void;
onAlignRight: () => void;
onAlignTop: () => void;
onAlignCenterV: () => void;
onAlignBottom: () => void;
onDistributeH: () => void;
onDistributeV: () => void;
onPack: () => void;
onGrid: () => void;
onRow: () => void;
onColumn: () => void;
onStack: () => void;
onFlipH: () => void;
onFlipV: () => void;
onGroup: () => void;
onNormSize: () => void;
}
// Tiny SVG icons for each action
function IcoAlignL() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="2" y1="1" x2="2" y2="13" /><rect x="4" y="3" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="4" y="8" width="5" height="3" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
}
function IcoAlignCH() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="7" y1="1" x2="7" y2="13" strokeDasharray="1.5 1.5" /><rect x="2" y="3" width="10" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="3.5" y="8" width="7" height="3" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
}
function IcoAlignR() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="12" y1="1" x2="12" y2="13" /><rect x="2" y="3" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="5" y="8" width="5" height="3" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
}
function IcoAlignT() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="1" y1="2" x2="13" y2="2" /><rect x="3" y="4" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="8" y="4" width="3" height="5" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
}
function IcoAlignCV() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="1" y1="7" x2="13" y2="7" strokeDasharray="1.5 1.5" /><rect x="3" y="2" width="3" height="10" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="8" y="3.5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
}
function IcoAlignB() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="1" y1="12" x2="13" y2="12" /><rect x="3" y="2" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="8" y="5" width="3" height="5" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
}
function IcoDistH() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="3" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="5.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="10" y="3" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><path d="M4.5 7h1M9 7h1" strokeWidth="1" /></svg>;
}
function IcoDistV() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="3" y="1" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="3" y="5.5" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="3" y="10" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><path d="M7 4.5v1M7 9v1" strokeWidth="1" /></svg>;
}
function IcoPack() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="1" width="5" height="6" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="7" y="1" width="6" height="4" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="1" y="8" width="4" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="6" y="6" width="7" height="7" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
}
function IcoGrid() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="1" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="8" y="1" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="1" y="8" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="8" y="8" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
}
function IcoRow() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="4" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="5.5" y="4" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="10" y="4" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
}
function IcoCol() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="4" y="1" width="6" height="3" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="4" y="5.5" width="6" height="3" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="4" y="10" width="6" height="3" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
}
function IcoStack() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="2" y="2" width="10" height="10" rx="0.5" fill="currentColor" opacity="0.15" /><rect x="3" y="3" width="8" height="8" rx="0.5" fill="currentColor" opacity="0.15" /><rect x="4" y="4" width="6" height="6" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
}
function IcoFlipH() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3"><line x1="7" y1="1" x2="7" y2="13" strokeDasharray="2 1" /><path d="M5 4H2L5 10V4Z" fill="currentColor" opacity="0.3" /><path d="M9 4H12L9 10V4Z" fill="currentColor" opacity="0.15" /></svg>;
}
function IcoFlipV() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3"><line x1="1" y1="7" x2="13" y2="7" strokeDasharray="2 1" /><path d="M4 5V2L10 5H4Z" fill="currentColor" opacity="0.3" /><path d="M4 9V12L10 9H4Z" fill="currentColor" opacity="0.15" /></svg>;
}
function IcoGroup() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="1" width="12" height="12" rx="1.5" strokeDasharray="2 1.5" /><rect x="3" y="3" width="4" height="4" rx="0.5" fill="currentColor" opacity="0.25" /><rect x="7" y="7" width="4" height="4" rx="0.5" fill="currentColor" opacity="0.25" /></svg>;
}
function IcoNormSize() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="4" width="5" height="8" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="8" y="2" width="5" height="10" rx="0.5" fill="currentColor" opacity="0.2" /><path d="M3.5 1v2M10.5 1v2" strokeLinecap="round" /><line x1="3.5" y1="1.5" x2="10.5" y2="1.5" strokeLinecap="round" /></svg>;
}
interface BtnDef {
icon: React.FC;
label: string;
shortcut: string;
onClick: () => void;
minItems?: number;
}
export default function SelectionToolbar(props: SelectionToolbarProps) {
const { x, y, count } = props;
const groups: { label: string; items: BtnDef[] }[] = [
{
label: 'Align',
items: [
{ icon: IcoAlignL, label: 'Align left', shortcut: 'Ctrl+\u2190', onClick: props.onAlignLeft },
{ icon: IcoAlignCH, label: 'Align center H', shortcut: 'Ctrl+Alt+H', onClick: props.onAlignCenterH },
{ icon: IcoAlignR, label: 'Align right', shortcut: 'Ctrl+\u2192', onClick: props.onAlignRight },
{ icon: IcoAlignT, label: 'Align top', shortcut: 'Ctrl+\u2191', onClick: props.onAlignTop },
{ icon: IcoAlignCV, label: 'Align center V', shortcut: 'Ctrl+Alt+V', onClick: props.onAlignCenterV },
{ icon: IcoAlignB, label: 'Align bottom', shortcut: 'Ctrl+\u2193', onClick: props.onAlignBottom },
],
},
{
label: 'Distribute',
items: [
{ icon: IcoDistH, label: 'Distribute H', shortcut: 'Ctrl+Shift+H', onClick: props.onDistributeH, minItems: 3 },
{ icon: IcoDistV, label: 'Distribute V', shortcut: 'Ctrl+Shift+V', onClick: props.onDistributeV, minItems: 3 },
],
},
{
label: 'Arrange',
items: [
{ icon: IcoPack, label: 'Pack', shortcut: 'Ctrl+Shift+P', onClick: props.onPack },
{ icon: IcoGrid, label: 'Grid', shortcut: '', onClick: props.onGrid },
{ icon: IcoRow, label: 'Row', shortcut: '', onClick: props.onRow },
{ icon: IcoCol, label: 'Column', shortcut: '', onClick: props.onColumn },
{ icon: IcoStack, label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: props.onStack },
],
},
{
label: 'Transform',
items: [
{ icon: IcoFlipH, label: 'Flip H', shortcut: 'Alt+Shift+H', onClick: props.onFlipH },
{ icon: IcoFlipV, label: 'Flip V', shortcut: 'Alt+Shift+V', onClick: props.onFlipV },
{ icon: IcoGroup, label: 'Group', shortcut: 'Ctrl+G', onClick: props.onGroup },
{ icon: IcoNormSize, label: 'Same size', shortcut: '', onClick: props.onNormSize },
],
},
];
return (
<div
style={{
position: 'absolute',
left: x,
top: y - 8,
transform: 'translate(-50%, -100%)',
display: 'flex',
gap: '1px',
padding: '3px',
background: 'rgba(22, 22, 22, 0.96)',
border: '1px solid #333',
borderRadius: '10px',
backdropFilter: 'blur(12px)',
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
zIndex: 100,
pointerEvents: 'auto',
}}
onPointerDown={(e) => e.stopPropagation()}
>
{groups.map((group, gi) => (
<React.Fragment key={group.label}>
{gi > 0 && (
<div style={{ width: '1px', background: '#333', margin: '2px 2px', flexShrink: 0 }} />
)}
<div style={{ display: 'flex', gap: '1px' }}>
{group.items.map((btn) => {
const disabled = (btn.minItems ?? 2) > count;
const Icon = btn.icon;
return (
<button
key={btn.label}
onClick={btn.onClick}
disabled={disabled}
title={btn.shortcut ? `${btn.label} (${btn.shortcut})` : btn.label}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '26px',
height: '26px',
background: 'transparent',
border: 'none',
borderRadius: '5px',
color: disabled ? '#444' : '#999',
cursor: disabled ? 'default' : 'pointer',
padding: 0,
transition: 'all 0.1s',
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.background = '#333';
e.currentTarget.style.color = '#fff';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = disabled ? '#444' : '#999';
}}
>
<Icon />
</button>
);
})}
</div>
</React.Fragment>
))}
</div>
);
}
+12 -3
View File
@@ -26,6 +26,8 @@ interface ToolbarProps {
onUndo: () => void; onUndo: () => void;
onRedo: () => void; onRedo: () => void;
onlineUsers: OnlineUser[]; onlineUsers: OnlineUser[];
onUserClick?: (userId: string, displayName: string) => void;
followingUserId?: string | null;
onShareClick?: () => void; onShareClick?: () => void;
onToggleLayers?: () => void; onToggleLayers?: () => void;
showLayers?: boolean; showLayers?: boolean;
@@ -139,6 +141,8 @@ export default function Toolbar({
onUndo, onUndo,
onRedo, onRedo,
onlineUsers, onlineUsers,
onUserClick,
followingUserId,
onShareClick, onShareClick,
onToggleLayers, onToggleLayers,
showLayers, showLayers,
@@ -306,11 +310,16 @@ export default function Toolbar({
background: `linear-gradient(135deg, ${u.color}, ${u.color}dd)`, background: `linear-gradient(135deg, ${u.color}, ${u.color}dd)`,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '10px', fontWeight: 700, color: '#fff', fontSize: '10px', fontWeight: 700, color: '#fff',
border: '2px solid #1a1a1a', border: followingUserId === u.userId ? '2px solid #4a9eff' : '2px solid #1a1a1a',
marginLeft: i > 0 ? '-6px' : '0', marginLeft: i > 0 ? '-6px' : '0',
zIndex: 5 - i, zIndex: 5 - i,
boxShadow: '0 1px 3px rgba(0,0,0,0.3)', boxShadow: followingUserId === u.userId ? '0 0 6px rgba(74,144,217,0.6)' : '0 1px 3px rgba(0,0,0,0.3)',
}}> cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onClick={() => onUserClick?.(u.userId, u.displayName)}
title={`${u.displayName}${followingUserId === u.userId ? ' (following)' : ' — click to follow'}`}
>
{(u.displayName || '?')[0].toUpperCase()} {(u.displayName || '?')[0].toUpperCase()}
</div> </div>
))} ))}
+5 -4
View File
@@ -91,10 +91,11 @@ export default function UserCursors({ socket, boardId, canvasTransform }: UserCu
key={cursor.userId} key={cursor.userId}
style={{ style={{
position: 'absolute', position: 'absolute',
left: screenX, left: 0,
top: screenY, top: 0,
transform: 'translate(-2px, -2px)', transform: `translate(${screenX - 2}px, ${screenY - 2}px)`,
transition: 'left 0.1s, top 0.1s', transition: 'transform 50ms linear',
willChange: 'transform',
}} }}
> >
<svg width="16" height="20" viewBox="0 0 16 20" fill="none"> <svg width="16" height="20" viewBox="0 0 16 20" fill="none">
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { getBoard } from '../api';
interface BoardLoaderResult {
boardData: any;
loading: boolean;
error: string;
}
/**
* Loads board data by ID with cancellation support.
*/
export function useBoardLoader(boardId: string | undefined): BoardLoaderResult {
const [boardData, setBoardData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
async function load() {
try {
if (!boardId) {
setError('No board specified');
setLoading(false);
return;
}
const res = await getBoard(boardId);
if (!cancelled) {
setBoardData(res.data);
setLoading(false);
}
} catch (err: any) {
if (!cancelled) {
setError(err.response?.data?.error || 'Failed to load board');
setLoading(false);
}
}
}
load();
return () => { cancelled = true; };
}, [boardId]);
return { boardData, loading, error };
}
+67
View File
@@ -6,6 +6,8 @@ import { setupSync, SyncHandle } from '../canvas/sync';
import { setupDragDrop, setupPaste } from '../canvas/image-drop'; import { setupDragDrop, setupPaste } from '../canvas/image-drop';
import { UndoManager } from '../canvas/history'; import { UndoManager } from '../canvas/history';
import { InboxZone } from '../canvas/InboxZone'; import { InboxZone } from '../canvas/InboxZone';
import { LaserPointer } from '../canvas/LaserPointer';
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
import { connectSocket, disconnectSocket } from '../socket'; import { connectSocket, disconnectSocket } from '../socket';
interface OnlineUser { interface OnlineUser {
@@ -57,6 +59,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const dropCleanupRef = useRef<(() => void) | null>(null); const dropCleanupRef = useRef<(() => void) | null>(null);
const pasteCleanupRef = useRef<(() => void) | null>(null); const pasteCleanupRef = useRef<(() => void) | null>(null);
const laserCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => { useEffect(() => {
if (!boardData || !resolvedBoardId) return; if (!boardData || !resolvedBoardId) return;
@@ -70,6 +73,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const selection = new SelectionManager(viewport, scene); const selection = new SelectionManager(viewport, scene);
selectionRef.current = selection; selectionRef.current = selection;
// Wire snap guides to transform box
selection.transformBox.setSnapGuides(selection.snapGuides);
// Wire selection change to update layer panel state // Wire selection change to update layer panel state
selection.onSelectionChange = (ids: string[]) => { selection.onSelectionChange = (ids: string[]) => {
setSelectedLayerIds(ids); setSelectedLayerIds(ids);
@@ -176,6 +182,65 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
cursorTimer = setTimeout(() => { cursorTimer = null; }, 33); cursorTimer = setTimeout(() => { cursorTimer = null; }, 33);
}; };
viewport.on('pointermove', onPointerMove); viewport.on('pointermove', onPointerMove);
// ---- Laser pointer (hold L key) ----
const laserColorHex = userColor(user?.id || '');
const laserColorNum = parseInt(laserColorHex.replace('#', ''), 16);
const laser = new LaserPointer(viewport, laserColorNum);
let laserTimer: ReturnType<typeof setTimeout> | null = null;
const onLaserMove = (e: any) => {
if (!laser.isActive) return;
const world = viewport.toWorld(e.global.x, e.global.y);
laser.addPoint(world.x, world.y);
// Throttle broadcast to ~50ms
if (laserTimer) return;
socket.volatile.emit('laser:move', {
boardId: resolvedBoardId,
points: [{ x: world.x, y: world.y }],
});
laserTimer = setTimeout(() => { laserTimer = null; }, 50);
};
viewport.on('pointermove', onLaserMove);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'l' || e.key === 'L') {
if (laser.isActive) return;
laser.start();
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'l' || e.key === 'L') {
laser.stop();
socket.emit('laser:stop', { boardId: resolvedBoardId });
}
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
laserCleanupRef.current = () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
laser.destroy();
};
// Listen for remote laser events
socket.on('laser:move', (data: any) => {
if (data.boardId !== resolvedBoardId) return;
const uid = data.userId;
if (!uid) return;
const color = parseInt(userColor(uid).replace('#', ''), 16);
laser.addRemotePoints(uid, data.points, color);
});
socket.on('laser:stop', (_data: any) => {
// Remote user stopped — points will fade naturally
});
socket.on('user:left', (data: any) => {
const uid = data.userId || data.id;
if (uid) {
laser.removeRemote(uid);
}
});
} }
// Setup drag/drop and paste // Setup drag/drop and paste
@@ -208,6 +273,8 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
inboxZoneRef.current = null; inboxZoneRef.current = null;
} }
syncRef.current?.cleanup(); syncRef.current?.cleanup();
laserCleanupRef.current?.();
laserCleanupRef.current = null;
dropCleanupRef.current?.(); dropCleanupRef.current?.();
pasteCleanupRef.current?.(); pasteCleanupRef.current?.();
disconnectSocket(); disconnectSocket();
+127
View File
@@ -0,0 +1,127 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { Socket } from 'socket.io-client';
import type { Viewport } from 'pixi-viewport';
interface FollowState {
followingUserId: string | null;
followingDisplayName: string | null;
}
interface UseFollowModeProps {
socket: Socket | null;
boardId: string | undefined;
getViewport: () => Viewport | null;
}
interface UseFollowModeReturn {
followingUserId: string | null;
followingDisplayName: string | null;
startFollowing: (userId: string, displayName: string) => void;
stopFollowing: () => void;
}
/**
* Follow mode click a user's avatar to track their viewport in real-time.
* Broadcasts own viewport position so others can follow us.
* Pan/zoom manually to break free.
*/
export function useFollowMode({ socket, boardId, getViewport }: UseFollowModeProps): UseFollowModeReturn {
const [state, setState] = useState<FollowState>({
followingUserId: null,
followingDisplayName: null,
});
const followRef = useRef<string | null>(null);
const ignoreNextMove = useRef(false);
// Broadcast own viewport position (always, so others can follow us)
useEffect(() => {
if (!socket || !boardId) return;
const timer = setInterval(() => {
const vp = getViewport();
if (!vp) return;
socket.volatile.emit('viewport:sync', {
boardId,
x: vp.center.x,
y: vp.center.y,
scale: vp.scale.x,
});
}, 150);
return () => clearInterval(timer);
}, [socket, boardId, getViewport]);
// Listen for viewport:sync from followed user and animate to match
useEffect(() => {
if (!socket) return;
const onViewportSync = (data: { boardId: string; userId: string; x: number; y: number; scale: number }) => {
if (data.boardId !== boardId) return;
const targetId = followRef.current;
if (!targetId || data.userId !== targetId) return;
const vp = getViewport();
if (!vp) return;
ignoreNextMove.current = true;
vp.animate({
time: 200,
position: { x: data.x, y: data.y },
scale: data.scale,
ease: 'easeOutQuad',
callbackOnComplete: () => {
// Brief delay before re-enabling break-free detection
setTimeout(() => { ignoreNextMove.current = false; }, 50);
},
});
};
socket.on('viewport:sync', onViewportSync);
return () => { socket.off('viewport:sync', onViewportSync); };
}, [socket, boardId, getViewport]);
// Detect manual pan/zoom to break free from follow mode
useEffect(() => {
const vp = getViewport();
if (!vp) return;
const onMoved = () => {
if (ignoreNextMove.current) return;
if (followRef.current) {
followRef.current = null;
setState({ followingUserId: null, followingDisplayName: null });
socket?.emit('follow:stop', { boardId });
}
};
vp.on('moved-end', onMoved);
return () => { vp.off('moved-end', onMoved); };
}, [getViewport, socket, boardId]);
const startFollowing = useCallback((userId: string, displayName: string) => {
// Toggle: if already following this user, stop
if (followRef.current === userId) {
followRef.current = null;
setState({ followingUserId: null, followingDisplayName: null });
socket?.emit('follow:stop', { boardId });
return;
}
followRef.current = userId;
setState({ followingUserId: userId, followingDisplayName: displayName });
socket?.emit('follow:start', { boardId, targetUserId: userId });
}, [socket, boardId]);
const stopFollowing = useCallback(() => {
followRef.current = null;
setState({ followingUserId: null, followingDisplayName: null });
socket?.emit('follow:stop', { boardId });
}, [socket, boardId]);
return {
followingUserId: state.followingUserId,
followingDisplayName: state.followingDisplayName,
startFollowing,
stopFollowing,
};
}
+161
View File
@@ -0,0 +1,161 @@
import { useCallback, useRef } from 'react';
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
import type { SelectionManager } from '../canvas/SelectionManager';
import type { SceneItem } from '../canvas/SceneManager';
import type { GroupObject } from '../canvas/scene-format';
interface LayerItem {
id: string;
name: string;
type: string;
visible: boolean;
locked: boolean;
isGroup: boolean;
children?: LayerItem[];
}
interface LayerPanelDeps {
canvasRef: React.RefObject<PixiCanvasHandle | null>;
selectionRef: React.RefObject<SelectionManager | null>;
onCanvasChange: () => void;
}
function autoName(item: SceneItem, i: number, counters: Record<string, number>): string {
if (item.data.name) return item.data.name;
const type = item.data.type;
if (type === 'image') {
counters.image = (counters.image || 0) + 1;
return `Image ${counters.image}`;
}
if (type === 'text') {
const textData = item.data as any;
return (textData.text || 'Text').slice(0, 20);
}
if (type === 'group') {
const groupData = item.data as GroupObject;
return `Group (${groupData.children?.length || 0})`;
}
return `${type} ${i + 1}`;
}
function buildLayerItem(
item: SceneItem,
i: number,
counters: Record<string, number>,
getScene: () => ReturnType<PixiCanvasHandle['getScene']>,
): LayerItem {
const isGroup = item.data.type === 'group';
let children: LayerItem[] | undefined;
if (isGroup) {
const groupData = item.data as GroupObject;
const scene = getScene();
if (scene && groupData.children) {
children = groupData.children.map((childId, ci) => {
const childItem = scene.getById(childId);
if (childItem) return buildLayerItem(childItem, ci, counters, getScene);
return { id: childId, name: `Missing ${childId.slice(0, 6)}`, type: 'unknown', visible: true, locked: false, isGroup: false };
});
}
}
return {
id: item.id,
name: autoName(item, i, counters),
type: item.data.type,
visible: item.data.visible,
locked: item.data.locked,
isGroup,
children,
};
}
/**
* Layer panel handlers provides refresh function and all layer panel callbacks.
*/
export function useLayerPanel(deps: LayerPanelDeps) {
const { canvasRef, selectionRef, onCanvasChange } = deps;
const nameCounters = useRef<Record<string, number>>({});
const refreshLayers = useCallback((): LayerItem[] => {
const scene = canvasRef.current?.getScene();
if (!scene) return [];
nameCounters.current = {};
const items = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
return items.map((item, i) =>
buildLayerItem(item, i, nameCounters.current, () => canvasRef.current?.getScene() ?? null),
);
}, [canvasRef]);
const onSelect = useCallback((id: string) => {
selectionRef.current?.selectOnly(id);
}, [selectionRef]);
const onToggleVisible = useCallback((id: string) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
const item = scene.getById(id);
if (item) {
item.data.visible = !item.data.visible;
item.displayObject.visible = item.data.visible;
onCanvasChange();
}
}, [canvasRef, onCanvasChange]);
const onToggleLock = useCallback((id: string) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
const item = scene.getById(id);
if (item) {
item.data.locked = !item.data.locked;
item.displayObject.eventMode = item.data.locked ? 'none' : 'static';
}
}, [canvasRef]);
const onReorder = useCallback((from: number, to: number) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
const items = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
if (from < 0 || from >= items.length || to < 0 || to >= items.length) return;
const tmp = items[from].data.z;
items[from].data.z = items[to].data.z;
items[to].data.z = tmp;
scene._applyZOrder();
onCanvasChange();
}, [canvasRef, onCanvasChange]);
const onDelete = useCallback((id: string) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
scene.removeItem(id, true);
selectionRef.current?.clear();
onCanvasChange();
}, [canvasRef, selectionRef, onCanvasChange]);
const onRename = useCallback((id: string, name: string) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
const item = scene.getById(id);
if (item) {
item.data.name = name;
// Also update frame label for groups
if (item.data.type === 'group') {
(item.data as any).label = name;
if (item.displayObject && 'setLabel' in item.displayObject) {
(item.displayObject as any).setLabel(name);
}
}
onCanvasChange(); // Broadcast rename to other clients
}
}, [canvasRef, onCanvasChange]);
return {
refreshLayers,
layerHandlers: {
onSelect,
onToggleVisible,
onToggleLock,
onReorder,
onDelete,
onRename,
},
};
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useRef } from 'react';
import { saveCanvas } from '../api';
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
import type { SaveStatus } from '../components/StatusBar';
interface SaveManagerOptions {
resolvedBoardId: string | undefined;
isPublicView?: boolean;
canvasRef: React.RefObject<PixiCanvasHandle | null>;
setSaveStatus: (s: SaveStatus) => void;
}
/**
* Debounced save with thumbnail generation from PixiJS renderer.
*/
export function useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSaveStatus }: SaveManagerOptions) {
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleSave = useCallback(() => {
if (!resolvedBoardId || isPublicView) return;
setSaveStatus('unsaved');
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
setSaveStatus('saving');
try {
const state = JSON.stringify(scene.serialize());
// Generate thumbnail from PixiJS renderer
let thumbnail: string | undefined;
try {
const app = canvasRef.current?.getApp();
const viewport = canvasRef.current?.getViewport();
if (app?.renderer?.extract && viewport) {
const fullCanvas = app.renderer.extract.canvas(viewport) as HTMLCanvasElement;
const thumbMax = 400;
const sw = fullCanvas.width;
const sh = fullCanvas.height;
if (sw > 0 && sh > 0) {
const ratio = Math.min(thumbMax / sw, thumbMax / sh, 1);
const tw = Math.round(sw * ratio);
const th = Math.round(sh * ratio);
const offscreen = document.createElement('canvas');
offscreen.width = tw;
offscreen.height = th;
const ctx = offscreen.getContext('2d');
if (ctx) {
ctx.drawImage(fullCanvas, 0, 0, tw, th);
thumbnail = offscreen.toDataURL('image/webp', 0.6);
}
}
}
} catch (thumbErr) {
console.warn('Thumbnail generation failed:', thumbErr);
}
await saveCanvas(resolvedBoardId, state, thumbnail);
setSaveStatus('saved');
} catch {
setSaveStatus('unsaved');
}
}, 2000);
}, [resolvedBoardId, isPublicView, canvasRef, setSaveStatus]);
return { scheduleSave, saveTimerRef };
}
+153
View File
@@ -0,0 +1,153 @@
import { useEffect } from 'react';
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
import type { SelectionManager } from '../canvas/SelectionManager';
import type { UndoManager } from '../canvas/history';
import type { SceneItem } from '../canvas/SceneManager';
import { getItemWorldBounds } from '../canvas/SceneManager';
import { ToolType, toolShortcuts } from '../canvas/tools';
import { ShortcutContext, matchesShortcut, sortBySpecificity } from '../canvas/shortcuts';
import { shortcuts as shortcutDefs } from '../canvas/shortcut-definitions';
import { writeCanvasToClipboard, pasteFromSystemClipboard } from '../canvas/clipboard';
// Pre-sort shortcuts by specificity (most modifiers first) for correct matching
const sortedShortcuts = sortBySpecificity(shortcutDefs);
interface ShortcutHandlerDeps {
canvasRef: React.RefObject<PixiCanvasHandle | null>;
selectionRef: React.RefObject<SelectionManager | null>;
undoRef: React.RefObject<UndoManager | null>;
clipboardRef: React.MutableRefObject<SceneItem[]>;
resolvedBoardId: string | undefined;
onCanvasChange: () => void;
showToast: (msg: string) => void;
refreshLayers: () => void;
handleGroup: () => void;
handleUngroup: () => void;
setActiveTool: (t: ToolType) => void;
setCanUndo: (v: boolean) => void;
setCanRedo: (v: boolean) => void;
setZoom: (z: number) => void;
setShowGrid: React.Dispatch<React.SetStateAction<boolean>>;
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>;
setFocusMode: React.Dispatch<React.SetStateAction<boolean>>;
}
/**
* Keyboard shortcut handler registry-based, delegates to shortcut-definitions.
*/
export function useShortcutHandler(deps: ShortcutHandlerDeps) {
const {
canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId,
onCanvasChange, showToast, refreshLayers,
handleGroup, handleUngroup,
setActiveTool, setCanUndo, setCanRedo, setZoom,
setShowGrid, setShowHelp, setFocusMode,
} = deps;
useEffect(() => {
async function onKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const scene = canvasRef.current?.getScene();
const selection = selectionRef.current;
const viewport = canvasRef.current?.getViewport();
if (!scene || !selection || !viewport) return;
// Tool shortcuts (bare keys 1-5, v/h/p/t/e -- no modifiers)
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
const tool = toolShortcuts[e.key.toLowerCase()];
if (tool) {
setActiveTool(tool);
return;
}
}
// Build context for shortcut handlers
const ctx: ShortcutContext = {
scene,
selection,
history: undoRef.current!,
viewport,
onChange: onCanvasChange,
clipboardRef,
writeCanvasToClipboard: async (items?: SceneItem[]) => {
try {
const app = canvasRef.current?.getApp() ?? null;
await writeCanvasToClipboard(app, viewport, items);
} catch (err: any) {
showToast('Copy failed: ' + (err.message || 'clipboard not available'));
}
},
showToast,
fitAll: () => canvasRef.current?.fitAll(),
setActiveTool,
setCanUndo,
setCanRedo,
refreshLayers,
handleGroup,
handleUngroup,
toggleGrid: () => setShowGrid((v) => !v),
toggleShowHelp: () => setShowHelp((v) => !v),
toggleFocusMode: () => setFocusMode((v) => !v),
pasteFromSystemClipboard: async (): Promise<string> => {
if (!resolvedBoardId) return 'No board';
const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange);
if (msg !== 'No image in clipboard') showToast(msg);
return msg;
},
fitSelection: () => {
const vp = canvasRef.current?.getViewport();
if (!vp || !selection) return;
const items = selection.getSelectedItems();
if (items.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const item of items) {
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 = 60;
const contentW = maxX - minX + padding * 2;
const contentH = maxY - minY + padding * 2;
const scaleX = vp.screenWidth / contentW;
const scaleY = vp.screenHeight / contentH;
const targetScale = Math.min(scaleX, scaleY, 5);
vp.animate({
position: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
scale: targetScale,
time: 300,
ease: 'easeOutQuad',
});
},
};
// Match against sorted registry (most-specific first)
for (const def of sortedShortcuts) {
if (!matchesShortcut(e, def)) continue;
// Check selection requirements
const activeCount = selection.selectedIds.size;
if (def.needsSelection && activeCount === 0) continue;
if (def.minSelection && activeCount < def.minSelection) continue;
e.preventDefault();
await def.handler(ctx);
// Update zoom display after any action
setZoom(canvasRef.current?.getZoom() ?? 1);
return;
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [
canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId,
onCanvasChange, showToast, refreshLayers,
handleGroup, handleUngroup,
setActiveTool, setCanUndo, setCanRedo, setZoom,
setShowGrid, setShowHelp, setFocusMode,
]);
}
+168 -75
View File
@@ -22,6 +22,7 @@ import SelectionToolbar from '../components/SelectionToolbar';
import VideoControls from '../components/VideoControls'; import VideoControls from '../components/VideoControls';
import ShortcutsHelp from '../components/ShortcutsHelp'; import ShortcutsHelp from '../components/ShortcutsHelp';
import MattermostImport from '../components/MattermostImport'; import MattermostImport from '../components/MattermostImport';
import Minimap from '../components/Minimap';
import { InboxZone } from '../canvas/InboxZone'; import { InboxZone } from '../canvas/InboxZone';
import { getItemWorldBounds } from '../canvas/SceneManager'; import { getItemWorldBounds } from '../canvas/SceneManager';
import { VideoSprite } from '../canvas/sprites/VideoSprite'; import { VideoSprite } from '../canvas/sprites/VideoSprite';
@@ -33,6 +34,7 @@ import { useSaveManager } from '../hooks/useSaveManager';
import { useCanvasSetup } from '../hooks/useCanvasSetup'; import { useCanvasSetup } from '../hooks/useCanvasSetup';
import { useShortcutHandler } from '../hooks/useShortcutHandler'; import { useShortcutHandler } from '../hooks/useShortcutHandler';
import { useLayerPanel } from '../hooks/useLayerPanel'; import { useLayerPanel } from '../hooks/useLayerPanel';
import { useFollowMode } from '../hooks/useFollowMode';
interface EditorProps { interface EditorProps {
isPublicView?: boolean; isPublicView?: boolean;
@@ -73,7 +75,7 @@ export default function Editor({ isPublicView }: EditorProps) {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]); const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]);
const [showLayers, setShowLayers] = useState(false); const [showLayers, setShowLayers] = useState(false);
const [showGrid, setShowGrid] = useState(false); const [showGrid, setShowGrid] = useState(true);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [showMmImport, setShowMmImport] = useState(false); const [showMmImport, setShowMmImport] = useState(false);
const [focusMode, setFocusMode] = useState(false); const [focusMode, setFocusMode] = useState(false);
@@ -81,6 +83,10 @@ export default function Editor({ isPublicView }: EditorProps) {
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]); const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null); const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null); const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
const [showMinimap, setShowMinimap] = useState(true);
const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({
items: [], viewportBounds: { x: 0, y: 0, w: 1, h: 1 }, contentBounds: { x: 0, y: 0, w: 1, h: 1 },
});
// Derived // Derived
const { boardData, loading, error } = useBoardLoader(boardId); const { boardData, loading, error } = useBoardLoader(boardId);
@@ -112,6 +118,14 @@ export default function Editor({ isPublicView }: EditorProps) {
if (vp) setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]); if (vp) setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]);
}, [scheduleSave]); }, [scheduleSave]);
// Follow mode
const getViewport = useCallback(() => canvasRef.current?.getViewport() ?? null, []);
const { followingUserId, followingDisplayName, startFollowing, stopFollowing } = useFollowMode({
socket: getSocket(),
boardId: resolvedBoardId,
getViewport,
});
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox) // Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox)
useCanvasSetup({ useCanvasSetup({
boardData, resolvedBoardId, user, isPublicView, boardData, resolvedBoardId, user, isPublicView,
@@ -156,11 +170,8 @@ export default function Editor({ isPublicView }: EditorProps) {
}, [refreshLayerData]); }, [refreshLayerData]);
useEffect(() => { useEffect(() => {
if (!showLayers) return; if (showLayers) refreshLayers();
refreshLayers(); }, [showLayers, refreshLayers, selectedLayerIds]);
const interval = setInterval(refreshLayers, 500);
return () => clearInterval(interval);
}, [showLayers, refreshLayers]);
// Grouping // Grouping
const handleGroup = useCallback(() => { const handleGroup = useCallback(() => {
@@ -240,38 +251,36 @@ export default function Editor({ isPublicView }: EditorProps) {
return () => window.removeEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [resolvedBoardId]); }, [resolvedBoardId]);
// Zoom poll + selection toolbar position tracking // Update UI overlays (selection toolbar, video controls, minimap) on demand
useEffect(() => { const updateOverlays = useCallback(() => {
const interval = setInterval(() => { const selection = selectionRef.current;
setZoom(canvasRef.current?.getZoom() ?? 1); const vp = canvasRef.current?.getViewport();
if (!selection || !vp) { setSelToolbar(null); setVideoCtrl(null); return; }
const items = selection.getSelectedItems();
// Update selection toolbar position setZoom(canvasRef.current?.getZoom() ?? 1);
const selection = selectionRef.current; setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]);
const vp = canvasRef.current?.getViewport();
if (!selection || !vp) { setSelToolbar(null); setVideoCtrl(null); return; }
const items = selection.getSelectedItems();
// Video controls: show when exactly 1 video is selected // Video controls: show when exactly 1 video is selected
if (items.length === 1 && items[0].type === 'video' && items[0].displayObject instanceof VideoSprite) { if (items.length === 1 && items[0].type === 'video' && items[0].displayObject instanceof VideoSprite) {
const vs = items[0].displayObject as VideoSprite; const vs = items[0].displayObject as VideoSprite;
const b = getItemWorldBounds(items[0]); const b = getItemWorldBounds(items[0]);
const screenTL = vp.toScreen(b.x, b.y); const screenTL = vp.toScreen(b.x, b.y);
const screenBR = vp.toScreen(b.x + b.w, b.y + b.h); const screenBR = vp.toScreen(b.x + b.w, b.y + b.h);
setVideoCtrl({ setVideoCtrl({
videoSprite: vs, videoSprite: vs,
screenRect: { screenRect: {
x: screenTL.x, x: screenTL.x,
y: screenTL.y, y: screenTL.y,
w: screenBR.x - screenTL.x, w: screenBR.x - screenTL.x,
h: screenBR.y - screenTL.y, h: screenBR.y - screenTL.y,
}, },
}); });
} else { } else {
setVideoCtrl(null); setVideoCtrl(null);
} }
if (items.length < 2) { setSelToolbar(null); return; }
if (items.length < 2) { setSelToolbar(null); } else {
// Compute world bounding box of selection // Compute world bounding box of selection
let minX = Infinity, minY = Infinity, maxX = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity;
for (const item of items) { for (const item of items) {
@@ -280,17 +289,65 @@ export default function Editor({ isPublicView }: EditorProps) {
if (b.y < minY) minY = b.y; if (b.y < minY) minY = b.y;
if (b.x + b.w > maxX) maxX = b.x + b.w; if (b.x + b.w > maxX) maxX = b.x + b.w;
} }
// Convert to screen
const screenTL = vp.toScreen(minX, minY); const screenTL = vp.toScreen(minX, minY);
const screenTR = vp.toScreen(maxX, minY); const screenTR = vp.toScreen(maxX, minY);
const sx = (screenTL.x + screenTR.x) / 2; setSelToolbar({ x: (screenTL.x + screenTR.x) / 2, y: screenTL.y, count: items.length });
const sy = screenTL.y; }
setSelToolbar({ x: sx, y: sy, count: items.length });
}, 100); // Minimap data
return () => clearInterval(interval); const scene = canvasRef.current?.getScene();
if (scene) {
const allItems = scene.getTopLevelItems();
const mapItems = allItems.map((it) => {
const b = getItemWorldBounds(it);
return { x: b.x, y: b.y, w: b.w, h: b.h, type: it.type, id: it.id };
});
const topLeft = vp.toWorld(0, 0);
const botRight = vp.toWorld(vp.screenWidth, vp.screenHeight);
const vpBounds = { x: topLeft.x, y: topLeft.y, w: botRight.x - topLeft.x, h: botRight.y - topLeft.y };
let cMinX = Infinity, cMinY = Infinity, cMaxX = -Infinity, cMaxY = -Infinity;
for (const it of mapItems) {
if (it.x < cMinX) cMinX = it.x;
if (it.y < cMinY) cMinY = it.y;
if (it.x + it.w > cMaxX) cMaxX = it.x + it.w;
if (it.y + it.h > cMaxY) cMaxY = it.y + it.h;
}
if (mapItems.length === 0) { cMinX = 0; cMinY = 0; cMaxX = 1; cMaxY = 1; }
setMinimapData({
items: mapItems,
viewportBounds: vpBounds,
contentBounds: { x: cMinX, y: cMinY, w: cMaxX - cMinX, h: cMaxY - cMinY },
});
}
}, []); }, []);
// Listen to viewport moved event for overlay updates (throttled)
// Depends on objectCount so it re-runs after canvas init (viewport becomes available)
useEffect(() => {
const vp = canvasRef.current?.getViewport();
if (!vp) return;
let rafId: number | null = null;
const onMoved = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => { rafId = null; updateOverlays(); });
};
vp.on('moved', onMoved);
// Also listen to wheel events directly for immediate zoom feedback
const onWheel = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => { rafId = null; updateOverlays(); });
};
vp.on('wheel-scroll', onWheel);
return () => { vp.off('moved', onMoved); vp.off('wheel-scroll', onWheel); if (rafId) cancelAnimationFrame(rafId); };
}, [updateOverlays, objectCount]);
// Also update overlays when selection or scene changes
useEffect(() => {
updateOverlays();
}, [selectedLayerIds, updateOverlays]);
// (PresenceOverlay removed — selection broadcast no longer needed)
// -- Render -- // -- Render --
if (loading) { if (loading) {
@@ -399,6 +456,8 @@ export default function Editor({ isPublicView }: EditorProps) {
setZoom(z); setZoom(z);
}} }}
onlineUsers={onlineUsers} onlineUsers={onlineUsers}
onUserClick={startFollowing}
followingUserId={followingUserId}
onToggleLayers={() => setShowLayers((v) => !v)} onToggleLayers={() => setShowLayers((v) => !v)}
showLayers={showLayers} showLayers={showLayers}
onToggleHelp={() => setShowHelp((v) => !v)} onToggleHelp={() => setShowHelp((v) => !v)}
@@ -421,23 +480,32 @@ export default function Editor({ isPublicView }: EditorProps) {
canvasTransform={canvasTransform} canvasTransform={canvasTransform}
/> />
{/* Grid overlay */} {/* Dot grid overlay — adapts spacing at zoom levels like Figma */}
{showGrid && ( {showGrid && (() => {
<svg const scale = canvasTransform[0] || 1;
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 1 }} const tx = canvasTransform[4] || 0;
width="100%" height="100%" const ty = canvasTransform[5] || 0;
> // Adaptive spacing: base 20px, doubles when dots get too dense, halves when too sparse
<defs> let spacing = 20;
<pattern id="grid50" width={50 * (canvasTransform[0] || 1)} height={50 * (canvasTransform[3] || 1)} patternUnits="userSpaceOnUse" while (spacing * scale < 12) spacing *= 2;
x={(canvasTransform[4] || 0) % (50 * (canvasTransform[0] || 1))} while (spacing * scale > 50) spacing /= 2;
y={(canvasTransform[5] || 0) % (50 * (canvasTransform[3] || 1))}> const screenSpacing = spacing * scale;
<line x1="0" y1="0" x2={50 * (canvasTransform[0] || 1)} y2="0" stroke="rgba(255,255,255,0.04)" strokeWidth="1" /> const dotR = Math.max(0.5, Math.min(1.2, scale * 0.6));
<line x1="0" y1="0" x2="0" y2={50 * (canvasTransform[3] || 1)} stroke="rgba(255,255,255,0.04)" strokeWidth="1" /> const ox = tx % screenSpacing;
</pattern> const oy = ty % screenSpacing;
</defs> // Subtle dot: brighter at high zoom, dimmer at low zoom
<rect width="100%" height="100%" fill="url(#grid50)" /> const alpha = Math.max(0.08, Math.min(0.25, scale * 0.12));
</svg> return (
)} <svg style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 1 }} width="100%" height="100%">
<defs>
<pattern id="dotgrid" width={screenSpacing} height={screenSpacing} patternUnits="userSpaceOnUse" x={ox} y={oy}>
<circle cx={dotR} cy={dotR} r={dotR} fill={`rgba(255,255,255,${alpha})`} />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#dotgrid)" />
</svg>
);
})()}
{/* Empty canvas guide */} {/* Empty canvas guide */}
{objectCount === 0 && ( {objectCount === 0 && (
@@ -459,23 +527,23 @@ export default function Editor({ isPublicView }: EditorProps) {
x={selToolbar.x} x={selToolbar.x}
y={selToolbar.y} y={selToolbar.y}
count={selToolbar.count} count={selToolbar.count}
onAlignLeft={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignLeft(s); onCanvasChange(); } }} onAlignLeft={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignLeft(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onAlignCenterH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterH(s); onCanvasChange(); } }} onAlignCenterH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterH(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onAlignRight={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignRight(s); onCanvasChange(); } }} onAlignRight={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignRight(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onAlignTop={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignTop(s); onCanvasChange(); } }} onAlignTop={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignTop(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onAlignCenterV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterV(s); onCanvasChange(); } }} onAlignCenterV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterV(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onAlignBottom={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignBottom(s); onCanvasChange(); } }} onAlignBottom={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignBottom(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onDistributeH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeHorizontal(s); onCanvasChange(); } }} onDistributeH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onDistributeV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeVertical(s); onCanvasChange(); } }} onDistributeV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onPack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeOptimal(s); onCanvasChange(); } }} onPack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeOptimal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onGrid={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeGrid(s); onCanvasChange(); } }} onGrid={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeGrid(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onRow={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeRow(s); onCanvasChange(); } }} onRow={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeRow(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onColumn={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeColumn(s); onCanvasChange(); } }} onColumn={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeColumn(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onStack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.stackObjects(s); onCanvasChange(); } }} onStack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.stackObjects(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onFlipH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipHorizontal(s); onCanvasChange(); } }} onFlipH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onFlipV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipVertical(s); onCanvasChange(); } }} onFlipV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
onGroup={handleGroup} onGroup={handleGroup}
onNormSize={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.normalizeSize(s); onCanvasChange(); } }} onNormSize={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.normalizeSize(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
/> />
)} )}
@@ -487,6 +555,31 @@ export default function Editor({ isPublicView }: EditorProps) {
/> />
)} )}
{/* Minimap */}
{showMinimap && !focusMode && objectCount > 0 && (
<Minimap
items={minimapData.items}
viewportBounds={minimapData.viewportBounds}
contentBounds={minimapData.contentBounds}
onNavigate={(wx, wy) => {
const vp = canvasRef.current?.getViewport();
if (vp) vp.animate({ time: 200, position: { x: wx, y: wy }, ease: 'easeOutQuad' });
}}
/>
)}
{/* Follow mode banner */}
{followingDisplayName && (
<div style={{
position: 'absolute', top: '8px', left: '50%', transform: 'translateX(-50%)',
padding: '4px 14px', background: 'rgba(74, 144, 217, 0.9)', borderRadius: '8px',
color: '#fff', fontSize: '12px', fontWeight: 500, zIndex: 200,
cursor: 'pointer', pointerEvents: 'auto',
}} onClick={stopFollowing}>
Following {followingDisplayName} click to stop
</div>
)}
{/* Context menu */} {/* Context menu */}
{contextMenu && ( {contextMenu && (
<ContextMenu <ContextMenu