diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a9ae42c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +**/node_modules +**/.git +**/.env diff --git a/backend/minio.js b/backend/minio.js index f804ee7..af4aff9 100644 --- a/backend/minio.js +++ b/backend/minio.js @@ -116,6 +116,8 @@ function getImageUrl(minioPath) { return `/api/images/${minioPath}`; } +const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE_MB || '200', 10) * 1024 * 1024; + module.exports = { minioClient, initBucket, @@ -126,4 +128,5 @@ module.exports = { getImageUrl, MINIO_BUCKET, MIME_TO_EXT, + MAX_FILE_SIZE, }; diff --git a/backend/routes/mattermost-bridge.js b/backend/routes/mattermost-bridge.js index 289e735..f5c295e 100644 --- a/backend/routes/mattermost-bridge.js +++ b/backend/routes/mattermost-bridge.js @@ -7,7 +7,6 @@ const { createImage, getImageByMmFileId, } = require('../db'); const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio'); -const { generateLOD } = require('../services/lod-generator'); const sharp = require('sharp'); 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) { - const assetKey = `boards/${boardId}/${imageId}`; - const originalExt = MIME_TO_EXT[mimetype] || '.bin'; +async function uploadMedia(boardId, imageId, buffer, mimetype) { + const ext = MIME_TO_EXT[mimetype] || '.bin'; + const minioPath = `boards/${boardId}/${imageId}${ext}`; + await putBuffer(minioPath, buffer, mimetype); - if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') { - const fullPath = `${assetKey}/full${originalExt}`; - await putBuffer(fullPath, buffer, mimetype); - let width = null, height = null; + let width = null, height = null; + if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) { try { const metadata = await sharp(buffer).metadata(); width = metadata.width || null; height = metadata.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 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 }; + return { assetKey: minioPath, minioPath, width, height }; } // --------------------- @@ -287,13 +265,7 @@ router.post('/:boardId/mm-pull', async (req, res) => { const imageId = uuidv4(); const mediaType = classifyMedia(mimeType); - let assetKey, minioPath, width, height; - - 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 { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType); const publicUrl = getImageUrl(minioPath); diff --git a/backend/routes/upload.js b/backend/routes/upload.js index c72a500..76ccfdd 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -7,8 +7,7 @@ const http = require('http'); const { URL } = require('url'); const { authMiddleware } = require('../auth'); const { getBoard, getCollectionMember, createImage } = require('../db'); -const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio'); -const { generateLOD } = require('../services/lod-generator'); +const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio'); const router = Router(); @@ -28,9 +27,9 @@ const 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({ storage: multer.memoryStorage(), limits: { fileSize: MAX_FILE_SIZE }, @@ -90,46 +89,17 @@ function classifyMedia(mimeType) { } /** - * Upload LOD tiers for an image to MinIO. - * Stores: {assetKey}/thumb.webp, {assetKey}/medium.webp, {assetKey}/full{ext} - * Returns the minioPath for the full tier (backward compat) and dimensions. + * 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) { - 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}`; +async function uploadMedia(boardId, imageId, buffer, mimetype) { 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 }; + const minioPath = `boards/${boardId}/${imageId}${ext}`; + await putBuffer(minioPath, buffer, mimetype); + + 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 mediaType = classifyMedia(mimetype); - let assetKey, minioPath, width, height; - - 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 { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimetype); const publicUrl = getImageUrl(minioPath); // Save record @@ -188,7 +151,7 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) }); } catch (err) { 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); return res.status(500).json({ error: 'Internal server error' }); @@ -225,7 +188,7 @@ function downloadImage(imageUrl) { totalSize += chunk.length; if (totalSize > MAX_FILE_SIZE) { 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); }); @@ -261,14 +224,7 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => { const imageId = uuidv4(); const mediaType = classifyMedia(mimeType); - let assetKey, minioPath, width, height; - - 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 { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType); const publicUrl = getImageUrl(minioPath); // Save record @@ -311,7 +267,7 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => { router.use((err, req, res, next) => { if (err instanceof multer.MulterError) { 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 }); } diff --git a/backend/services/mm-watcher.js b/backend/services/mm-watcher.js index 58cd4dc..246a97b 100644 --- a/backend/services/mm-watcher.js +++ b/backend/services/mm-watcher.js @@ -12,8 +12,7 @@ const { URL } = require('url'); const { v4: uuidv4 } = require('uuid'); const sharp = require('sharp'); const { getAllBoardChannelLinks, getImageByMmFileId, createImage, getBoard } = require('../db'); -const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio'); -const { generateLOD } = require('./lod-generator'); +const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio'); const MM_URL = process.env.MM_URL; 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 chunks = []; let totalSize = 0; - const MAX_SIZE = 50 * 1024 * 1024; + const MAX_SIZE = MAX_FILE_SIZE; res.on('data', (chunk) => { 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) { - 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}`; +async function uploadMedia(boardId, imageId, buffer, mimetype) { 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 }; + const minioPath = `boards/${boardId}/${imageId}${ext}`; + await putBuffer(minioPath, buffer, mimetype); + + 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 mediaType = classifyMedia(mimeType); - let assetKey, minioPath, width, height; - - 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 { assetKey, minioPath, width, height } = await uploadMedia(boardId, imageId, buffer, mimeType); const publicUrl = getImageUrl(minioPath); diff --git a/backend/socket/board-room.js b/backend/socket/board-room.js index 154cb57..31c176a 100644 --- a/backend/socket/board-room.js +++ b/backend/socket/board-room.js @@ -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) ---- 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 ---- socket.on('disconnect', () => { diff --git a/backend/socket/index.js b/backend/socket/index.js index 83de98e..b63f738 100644 --- a/backend/socket/index.js +++ b/backend/socket/index.js @@ -2,6 +2,7 @@ const { Server } = require('socket.io'); const { verifyToken } = require('../auth'); const { getUserById } = require('../db'); const { setupBoardRoom } = require('./board-room'); +const { setupViewportSync } = require('./viewport-sync'); /** * Set up Socket.IO on the given HTTP server. @@ -49,6 +50,7 @@ function setupSocket(httpServer) { // Set up board room handlers setupBoardRoom(io, socket); + setupViewportSync(io, socket); socket.on('disconnect', (reason) => { console.log(`[socket] User disconnected: ${socket.userDisplayName} (${reason})`); diff --git a/backend/socket/viewport-sync.js b/backend/socket/viewport-sync.js new file mode 100644 index 0000000..4e086c5 --- /dev/null +++ b/backend/socket/viewport-sync.js @@ -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 }; diff --git a/frontend/src/canvas/InboxZone.ts b/frontend/src/canvas/InboxZone.ts index 732368d..49c94e0 100644 --- a/frontend/src/canvas/InboxZone.ts +++ b/frontend/src/canvas/InboxZone.ts @@ -141,8 +141,8 @@ export class InboxZone extends Container { sprite.width = sw; sprite.height = sh; - // Load thumbnail texture - this.textures.load(assetKey, 'thumb').then((tex) => { + // Load texture (GPU handles scaling) + this.textures.load(assetKey).then((tex) => { if (!sprite.destroyed) { sprite.texture = tex; sprite.width = sw; diff --git a/frontend/src/canvas/LaserPointer.ts b/frontend/src/canvas/LaserPointer.ts new file mode 100644 index 0000000..791bdec --- /dev/null +++ b/frontend/src/canvas/LaserPointer.ts @@ -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 = new Map(); + private _remotePoints: Map = 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(); + } +} diff --git a/frontend/src/canvas/PixiCanvas.tsx b/frontend/src/canvas/PixiCanvas.tsx index fb9990b..28e82c2 100644 --- a/frontend/src/canvas/PixiCanvas.tsx +++ b/frontend/src/canvas/PixiCanvas.tsx @@ -12,10 +12,11 @@ import { useImperativeHandle, useRef, useCallback, + useState, } from 'react'; import { Application } from 'pixi.js'; import { Viewport } from 'pixi-viewport'; -import { SceneManager } from './SceneManager'; +import { SceneManager, getItemWorldBounds } from './SceneManager'; import { TextureManager } from './TextureManager'; import { SpringManager } from './spring'; import { convertFabricToV2 } from './scene-format'; @@ -28,13 +29,14 @@ import type { SceneData } from './scene-format'; export interface PixiCanvasHandle { getViewport: () => Viewport | null; getScene: () => SceneManager | null; + getApp: () => Application | null; fitAll: () => void; getZoom: () => number; setZoom: (zoom: number) => void; } export interface PixiCanvasProps { - canvasState?: string | null; + canvasState?: string | object | null; currentTool: string; boardId?: string; onChange?: () => void; @@ -99,6 +101,7 @@ const PixiCanvas = forwardRef( const initialLoadDone = useRef(false); const spaceHeld = useRef(false); const onChangeRef = useRef(onChange); + const [pixiReady, setPixiReady] = useState(false); // Keep onChange ref current without re-running effects onChangeRef.current = onChange; @@ -121,6 +124,7 @@ const PixiCanvas = forwardRef( antialias: true, autoDensity: true, resolution: window.devicePixelRatio, + preserveDrawingBuffer: true, }); if (destroyed) { @@ -160,7 +164,7 @@ const PixiCanvas = forwardRef( springs.tick(ticker.deltaMS / 1000); }); - // -- Culling + LOD ticker (runs every 200ms, not every frame) ------ + // -- Visibility culling ticker (runs every 200ms, not every frame) -- let lastCullCheck = 0; app.ticker.add((ticker) => { @@ -168,29 +172,18 @@ const PixiCanvas = forwardRef( if (lastCullCheck < 200) return; lastCullCheck = 0; - const zoom = viewport.scale.x; const bounds = viewport.getVisibleBounds(); const margin = 200; for (const item of scene.getAllItems()) { - const d = item.displayObject; - const ib = d.getBounds(); - const inView = - ib.x + ib.width > bounds.x - margin && - ib.x < bounds.x + bounds.width + margin && - ib.y + ib.height > bounds.y - margin && - ib.y < bounds.y + bounds.height + margin; - - if (item.type === 'image' && 'updateLOD' in d) { - if (inView) { - (d as any).updateLOD(zoom); - d.visible = true; - } else { - d.visible = false; - } - } - if (item.type === 'video' && 'onVisibilityChange' in d) { - (d as any).onVisibilityChange(inView); + if (item.type === 'video' && 'onVisibilityChange' in item.displayObject) { + const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item); + const inView = + ix + iw > bounds.x - margin && + ix < bounds.x + bounds.width + margin && + iy + ih > bounds.y - margin && + iy < bounds.y + bounds.height + margin; + (item.displayObject as any).onVisibilityChange(inView); } } }); @@ -230,6 +223,18 @@ const PixiCanvas = forwardRef( // Store observer for cleanup (container as any).__pixiRO = ro; + + // Signal that PixiJS is ready for scene loading + setPixiReady(true); + + // -- Prevent Ctrl+wheel browser zoom on canvas ----------------------- + const preventBrowserZoom = (e: WheelEvent) => { + if (e.ctrlKey) { + e.preventDefault(); + } + }; + container.addEventListener('wheel', preventBrowserZoom, { passive: false }); + (container as any).__pixiWheelHandler = preventBrowserZoom; })(); // -- Cleanup --------------------------------------------------------- @@ -243,6 +248,12 @@ const PixiCanvas = forwardRef( delete (container as any).__pixiRO; } + const wheelHandler = (container as any).__pixiWheelHandler; + if (wheelHandler) { + container.removeEventListener('wheel', wheelHandler); + delete (container as any).__pixiWheelHandler; + } + textures.clear(); if (appRef.current) { @@ -263,11 +274,16 @@ const PixiCanvas = forwardRef( if (initialLoadDone.current) return; if (!canvasState || !sceneRef.current) return; + // canvasState may be a JSON string or already-parsed object let parsed: any; - try { - parsed = JSON.parse(canvasState); - } catch { - return; + if (typeof canvasState === 'string') { + try { + parsed = JSON.parse(canvasState); + } catch { + return; + } + } else { + parsed = canvasState; } let sceneData: SceneData; @@ -279,7 +295,7 @@ const PixiCanvas = forwardRef( initialLoadDone.current = true; sceneRef.current.loadScene(sceneData, false); - }, [canvasState]); + }, [canvasState, pixiReady]); // ── Space key for pan mode ──────────────────────────────────────── @@ -337,12 +353,11 @@ const PixiCanvas = forwardRef( let maxY = -Infinity; for (const item of items) { - const obj = item.displayObject; - const bounds = obj.getBounds(); - if (bounds.x < minX) minX = bounds.x; - if (bounds.y < minY) minY = bounds.y; - if (bounds.x + bounds.width > maxX) maxX = bounds.x + bounds.width; - if (bounds.y + bounds.height > maxY) maxY = bounds.y + bounds.height; + const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item); + if (ix < minX) minX = ix; + if (iy < minY) minY = iy; + if (ix + iw > maxX) maxX = ix + iw; + if (iy + ih > maxY) maxY = iy + ih; } const padding = 40; @@ -368,6 +383,7 @@ const PixiCanvas = forwardRef( () => ({ getViewport: () => viewportRef.current, getScene: () => sceneRef.current, + getApp: () => appRef.current, fitAll, getZoom: () => viewportRef.current?.scale.x ?? 1, setZoom: (zoom: number) => { diff --git a/frontend/src/canvas/PresenceOverlay.ts b/frontend/src/canvas/PresenceOverlay.ts new file mode 100644 index 0000000..c47d02b --- /dev/null +++ b/frontend/src/canvas/PresenceOverlay.ts @@ -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 = new Map(); + private _graphics: Map = new Map(); // per-user graphics + private _labels: Map = 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(); + } +} diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index 05407ad..7e897be 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -5,16 +5,22 @@ * 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 { 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 { reparentGroupChildren } from './grouping'; import type { SceneData, AnySceneObject, ImageObject, VideoObject, TextObject, + DrawingObject, GroupObject, SceneObject, } from './scene-format'; @@ -25,11 +31,106 @@ import type { export interface SceneItem { id: string; - type: 'image' | 'video' | 'text' | 'group'; + type: 'image' | 'video' | 'text' | 'drawing' | 'group'; displayObject: Container; data: AnySceneObject; } +/** Set of child IDs that belong to a group — rebuilt when groups change. */ +let _groupChildIds: Set | 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(); + 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 // --------------------------------------------------------------------------- @@ -41,6 +142,7 @@ export class SceneManager { readonly springs: SpringManager; private _onChange: (() => void) | null = null; + private _onItemDimensionsChanged: ((itemId: string) => void) | null = null; private _zCounter: number = 0; constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) { @@ -55,6 +157,10 @@ export class SceneManager { this._onChange = fn; } + set onItemDimensionsChanged(fn: ((itemId: string) => void) | null) { + this._onItemDimensionsChanged = fn; + } + get onChange(): (() => void) | null { return this._onChange; } @@ -98,6 +204,21 @@ export class SceneManager { } for (const id of toRemove) { 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 }); this.items.delete(id); } @@ -121,7 +242,11 @@ export class SceneManager { 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._onChange?.(); @@ -136,31 +261,18 @@ export class SceneManager { switch (data.type) { case 'image': { const imgData = data as ImageObject; - const sprite = new Sprite(Texture.EMPTY); - 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; + displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures); break; } case 'video': { const vidData = data as VideoObject; - const gfx = new Graphics(); - gfx.rect(0, 0, vidData.w, vidData.h); - gfx.fill(0x333333); - displayObject = gfx; + const videoUrl = this.textures.urlForAsset(vidData.asset); + const videoSprite = new VideoSprite(vidData.asset, vidData.w, vidData.h, videoUrl); + // Auto-correct dimensions when video metadata loads + // 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; } @@ -175,8 +287,15 @@ export class SceneManager { break; } + case 'drawing': { + const drawData = data as DrawingObject; + displayObject = new DrawingSprite(drawData.points, drawData.color, drawData.strokeWidth); + break; + } + case 'group': { - displayObject = new Container(); + const gd = data as GroupObject; + displayObject = new FrameSprite(gd.w, gd.h, gd.bgColor, gd.label, gd.padding); break; } @@ -206,23 +325,52 @@ export class SceneManager { 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) { displayObject.scale.set(0, 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); scaleSpring.onUpdate = (v) => { if (!displayObject.destroyed) { displayObject.scale.set(v * data.sx, v * data.sy); + displayObject.position.set( + finalX + halfW * (1 - v), + finalY + halfH * (1 - v), + ); } }; scaleSpring.onComplete = () => { - // Second spring: 1.05 → 1.0 const settleSpring = new Spring(1.05, 1.0, PRESETS.snappy); settleSpring.onUpdate = (v) => { if (!displayObject.destroyed) { 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); @@ -252,6 +400,21 @@ export class SceneManager { obj.visible = data.visible; 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 item.data = { ...data }; item.type = data.type; @@ -259,16 +422,47 @@ export class SceneManager { // -- 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 { const sorted = Array.from(this.items.values()).sort( (a, b) => a.data.z - b.data.z, ); - for (let i = 0; i < sorted.length; i++) { - const child = sorted[i].displayObject; + // Reorder top-level items in the viewport + let vpIndex = 0; + for (const item of sorted) { + const child = item.displayObject; 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()); } + /** 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 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 { const item = this.items.get(id); 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) { item.displayObject.destroy({ children: true }); this.items.delete(id); @@ -302,10 +510,20 @@ export class SceneManager { // Remove from map immediately to prevent double-remove 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); scaleSpring.onUpdate = (v) => { 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); @@ -371,6 +589,41 @@ export class SceneManager { 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 -------------------------------- /** diff --git a/frontend/src/canvas/SelectionManager.ts b/frontend/src/canvas/SelectionManager.ts index 5e78a39..9360eb4 100644 --- a/frontend/src/canvas/SelectionManager.ts +++ b/frontend/src/canvas/SelectionManager.ts @@ -7,8 +7,9 @@ import { Container, Graphics, FederatedPointerEvent } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; -import { SceneManager, SceneItem, getItemWorldBounds, isGroupChild } from './SceneManager'; +import { SceneManager, SceneItem, getItemWorldBounds } from './SceneManager'; import { TransformBox } from './TransformBox'; +import { SnapGuides } from './SnapGuides'; import { ImageSprite } from './sprites/ImageSprite'; import { VideoSprite } from './sprites/VideoSprite'; @@ -31,6 +32,7 @@ const DRAG_THRESHOLD = 5; // px in screen space before object drag activa export class SelectionManager { readonly selectedIds: Set = new Set(); readonly transformBox: TransformBox; + private _snapGuides: SnapGuides; private _viewport: Viewport; private _scene: SceneManager; @@ -62,6 +64,8 @@ export class SelectionManager { private _enabled = true; + get snapGuides(): SnapGuides { return this._snapGuides; } + constructor(viewport: Viewport, scene: SceneManager) { this._viewport = viewport; this._scene = scene; @@ -81,6 +85,9 @@ export class SelectionManager { this.transformBox.setViewport(viewport); this._overlay.addChild(this.transformBox); + // Snap guides + this._snapGuides = new SnapGuides(scene, this._overlay); + // Bind events on viewport viewport.on('pointerdown', this._onPointerDown, this); viewport.on('globalpointermove', this._onPointerMove, this); @@ -229,17 +236,31 @@ export class SelectionManager { // Lift shadow + spring scale on all selected image sprites this._applyLift(); + + // Begin snap guide session + this._snapGuides.beginSession(this.selectedIds); } if (this._objectDragging) { const currentWorld = this._viewport.toWorld(e.global.x, e.global.y); - const ddx = currentWorld.x - this._lastDragWorldX; - const ddy = currentWorld.y - this._lastDragWorldY; + let ddx = currentWorld.x - this._lastDragWorldX; + let ddy = currentWorld.y - this._lastDragWorldY; this._lastDragWorldX = currentWorld.x; 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 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) { item.displayObject.x += ddx; item.displayObject.y += ddy; @@ -274,6 +295,7 @@ export class SelectionManager { if (this._objectDragging) { // End object drag — drop shadow + spring scale back this._applyDrop(); + this._snapGuides.endSession(); this._objectDragging = false; // Resume viewport drag @@ -390,6 +412,26 @@ export class SelectionManager { 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 -------------------------------------------------- private _emitChange(): void { diff --git a/frontend/src/canvas/SnapGuides.ts b/frontend/src/canvas/SnapGuides.ts new file mode 100644 index 0000000..a7ed61f --- /dev/null +++ b/frontend/src/canvas/SnapGuides.ts @@ -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): 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 }); + } +} diff --git a/frontend/src/canvas/TextureManager.ts b/frontend/src/canvas/TextureManager.ts index 29bfd9d..8ee69ea 100644 --- a/frontend/src/canvas/TextureManager.ts +++ b/frontend/src/canvas/TextureManager.ts @@ -1,67 +1,42 @@ import { Texture, Assets } from "pixi.js"; -export type LODTier = "thumb" | "medium" | "full"; - interface TextureEntry { texture: Texture; - tier: LODTier; lastUsed: 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 { private cache = new Map(); private budget = 512 * 1024 * 1024; // 512 MB private currentUsage = 0; - /** Map zoom level to the appropriate LOD tier. */ - tierForZoom(zoom: number): LODTier { - if (zoom < 0.3) return "thumb"; - if (zoom > 1.5) return "full"; - 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}`; + /** Build the URL for a given asset. */ + urlForAsset(assetKey: string): string { + if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) { + return assetKey; } - // New LOD format: boards/{boardId}/{imageId}/thumb.webp - return `/api/images/${assetKey}/${tier}.webp`; + return `/api/images/${assetKey}`; } /** - * Load a texture for the given asset and LOD tier. - * For legacy assets (pre-migration), loads the original file directly — - * the GPU handles downscaling naturally. - * For new assets, loads the appropriate LOD tier with fallback. + * Load a texture for the given asset. + * Returns cached texture if available, otherwise fetches and caches. + * GPU handles all scaling — no LOD tiers needed. */ - async load(assetKey: string, tier: LODTier): Promise { - // For legacy assets, all tiers resolve to the same URL - const effectiveTier = this._isLegacyAsset(assetKey) ? 'full' : tier; - const key = `${assetKey}:${effectiveTier}`; - - const existing = this.cache.get(key); + async load(assetKey: string): Promise { + const existing = this.cache.get(assetKey); if (existing) { existing.lastUsed = performance.now(); return existing.texture; } - const url = this.urlForAsset(assetKey, effectiveTier); + const url = this.urlForAsset(assetKey); let texture: Texture; try { texture = await Assets.load(url); @@ -76,9 +51,8 @@ export class TextureManager { this.currentUsage += memoryEstimate; - this.cache.set(key, { + this.cache.set(assetKey, { texture, - tier, lastUsed: performance.now(), memoryEstimate, }); @@ -91,14 +65,13 @@ export class TextureManager { } /** Remove a specific texture from the cache and destroy it. */ - unload(assetKey: string, tier: LODTier): void { - const key = `${assetKey}:${tier}`; - const entry = this.cache.get(key); + unload(assetKey: string): void { + const entry = this.cache.get(assetKey); if (!entry) return; this.currentUsage -= entry.memoryEstimate; entry.texture.destroy(true); - this.cache.delete(key); + this.cache.delete(assetKey); } /** Evict the least-recently-used cache entry. */ diff --git a/frontend/src/canvas/TransformBox.ts b/frontend/src/canvas/TransformBox.ts index f66cd34..c459d86 100644 --- a/frontend/src/canvas/TransformBox.ts +++ b/frontend/src/canvas/TransformBox.ts @@ -6,9 +6,10 @@ * 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 { SceneItem } from './SceneManager'; +import { type SceneItem, getItemWorldBounds } from './SceneManager'; +import type { SnapGuides } from './SnapGuides'; // --------------------------------------------------------------------------- // Constants @@ -19,11 +20,8 @@ const BORDER_WIDTH = 1.5; const HANDLE_SIZE = 8; const HANDLE_FILL = 0xffffff; 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 = { tl: 'nwse-resize', @@ -34,7 +32,6 @@ const HANDLE_CURSORS: Record = { bc: 'ns-resize', ml: 'ew-resize', mr: 'ew-resize', - rot: 'grab', }; // --------------------------------------------------------------------------- @@ -45,6 +42,7 @@ interface DragState { handleId: HandleId; startX: number; startY: number; + origBounds: { x: number; y: number; w: number; h: number }; origTransforms: Map; } @@ -55,11 +53,22 @@ interface DragState { export class TransformBox extends Container { private _border: Graphics; private _handles: Map = new Map(); - private _rotateLine: Graphics; private _items: SceneItem[] = []; private _bounds = { x: 0, y: 0, w: 0, h: 0 }; private _drag: DragState | 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() { super(); @@ -70,12 +79,29 @@ export class TransformBox extends Container { this._border = new Graphics(); this.addChild(this._border); - // Rotate stem line - this._rotateLine = new Graphics(); - this.addChild(this._rotateLine); + + // Dimension label (shown during resize) + 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 - 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) { const handle = new Graphics(); handle.eventMode = 'static'; @@ -103,23 +129,25 @@ export class TransformBox extends Container { update(items: SceneItem[]): void { 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) { this.visible = false; return; } - // Compute combined bounding rect in WORLD space using item data - // (not getBounds() which returns screen-space and causes offset) + // Compute combined bounding rect via canonical getItemWorldBounds() let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const item of items) { - const ix = item.data.x; - const iy = item.data.y; - const iw = item.data.w * Math.abs(item.data.sx); - const ih = item.data.h * Math.abs(item.data.sy); + const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item); if (ix < minX) minX = ix; if (iy < minY) minY = iy; 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.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 const cx = x + w / 2; const cy = y + h / 2; @@ -160,25 +182,16 @@ export class TransformBox extends Container { bl: { px: x, py: y + h }, bc: { px: cx, py: y + h }, br: { px: x + w, py: y + h }, - rot: { px: cx, py: y - ROTATE_OFFSET }, }; for (const [id, handle] of this._handles) { const pos = positions[id]; handle.clear(); - if (id === 'rot') { - // Green circle for rotation - handle.circle(0, 0, ROTATE_RADIUS); - handle.fill(ROTATE_COLOR); - 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 }); - } + 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); } @@ -204,108 +217,181 @@ export class TransformBox extends Container { handleId: id, startX: e.global.x, startY: e.global.y, + origBounds: { ...this._bounds }, 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 { if (!this._drag) return; - // Convert screen-space deltas to world-space by dividing by viewport zoom - const zoom = this._viewport?.scale.x ?? 1; - const dx = (e.global.x - this._drag.startX) / zoom; - const dy = (e.global.y - this._drag.startY) / zoom; - const { handleId, origTransforms } = this._drag; + // Convert mouse position to world space + const world = this._viewport!.toWorld(e.global.x, e.global.y); + const { handleId, origBounds: ob, origTransforms } = this._drag; - // Use bounding box size as reference for scale sensitivity - const { w: bw, h: bh } = this._bounds; - const refSize = Math.max(bw, bh, 100); // avoid division by tiny numbers + // Compute scale factors: new size / original size + // Each handle has a fixed edge — the opposite side stays put + 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) { const orig = origTransforms.get(item.id); 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) { - case 'br': { - // Proportional scale — drag distance relative to object size - 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 + case 'tl': + item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w; item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h; break; - } - case 'ml': { - const factor = 1 - dx / (bw || refSize); - item.data.sx = orig.sx * Math.max(0.05, factor); - // Middle-left: right edge stays fixed + case 'tc': + item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h; + break; + case 'tr': + 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; 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.angle = item.data.angle; item.displayObject.position.set(item.data.x, item.data.y); + + this._onItemTransform?.(item); } - // Redraw transform box around new bounds 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 { this._drag = null; + this._dimLabel.visible = false; + this._dimLabelBg.visible = false; + this._snapGuides?.endSession(); } } diff --git a/frontend/src/canvas/clipboard.ts b/frontend/src/canvas/clipboard.ts new file mode 100644 index 0000000..baf58a3 --- /dev/null +++ b/frontend/src/canvas/clipboard.ts @@ -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 { + 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(); + + // Pre-compute world bounds for each item (handles group children with local coords) + const worldBoundsMap = new Map(); + 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((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 { + 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'; +} diff --git a/frontend/src/canvas/context-menu-items.ts b/frontend/src/canvas/context-menu-items.ts new file mode 100644 index 0000000..8bf231c --- /dev/null +++ b/frontend/src/canvas/context-menu-items.ts @@ -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; + disabled?: boolean; + divider?: boolean; + danger?: boolean; +} + +interface MenuContext { + scene: SceneManager | null; + selection: SelectionManager | null; + viewport: Viewport | null; + clipboardRef: React.MutableRefObject; + writeCanvasToClipboard: (items?: SceneItem[]) => Promise; + 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(); + 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(); + 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(); +} diff --git a/frontend/src/canvas/grouping.ts b/frontend/src/canvas/grouping.ts new file mode 100644 index 0000000..0ac518e --- /dev/null +++ b/frontend/src/canvas/grouping.ts @@ -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); + } + } + } +} diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index 09d7eb7..963c378 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -1,7 +1,6 @@ import { Graphics } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; import type { SceneManager } from './SceneManager'; -import { VideoSprite } from './sprites/VideoSprite'; import { uploadImage, uploadImageFromUrl } from '../api'; type OnChange = () => void; @@ -55,10 +54,7 @@ function handleUploadResult( } if (mediaType === 'video' && assetKey) { - const url = imgData.public_url; - const video = new VideoSprite(assetKey, finalW, finalH, url); - video.position.set(x, y); - viewport.addChild(video); + sceneManager.addVideoFromUpload(assetKey, finalW, finalH, x, y); } else if (assetKey) { sceneManager.addImageFromUpload(assetKey, finalW, finalH, x, y); } else { @@ -122,18 +118,23 @@ export function setupDragDrop( return; } + let dropIndex = 0; for (let i = 0; i < files.length; i++) { const file = files[i]; if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) continue; const rect = container.getBoundingClientRect(); 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 { const res = await uploadImage(boardId, file); 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) { console.error('Image upload failed:', err); 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('drop', onDrop); return () => { + document.removeEventListener('dragover', onDocDragOver); + document.removeEventListener('drop', onDocDrop); container.removeEventListener('dragover', onDragOver); container.removeEventListener('drop', onDrop); }; diff --git a/frontend/src/canvas/operations.ts b/frontend/src/canvas/operations.ts index 23667db..ab1a269 100644 --- a/frontend/src/canvas/operations.ts +++ b/frontend/src/canvas/operations.ts @@ -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 ─── export function overlayCompare(objects: SceneItem[]) { diff --git a/frontend/src/canvas/scene-format.ts b/frontend/src/canvas/scene-format.ts index 437157e..5b2f1e9 100644 --- a/frontend/src/canvas/scene-format.ts +++ b/frontend/src/canvas/scene-format.ts @@ -4,7 +4,7 @@ export interface SceneObject { id: string; - type: 'image' | 'video' | 'text' | 'group'; + type: 'image' | 'video' | 'text' | 'group' | 'drawing'; x: number; y: number; w: number; @@ -42,12 +42,22 @@ export interface TextObject extends SceneObject { 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 { type: 'group'; 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 { v: 2; diff --git a/frontend/src/canvas/shortcut-definitions.ts b/frontend/src/canvas/shortcut-definitions.ts index 0612f85..ef78638 100644 --- a/frontend/src/canvas/shortcut-definitions.ts +++ b/frontend/src/canvas/shortcut-definitions.ts @@ -13,9 +13,103 @@ * - 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'; +// 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(); + 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(); + 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 { + 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[] = [ // ═══════════════════════════════════════ @@ -25,34 +119,22 @@ export const shortcuts: ShortcutDef[] = [ { id: 'align-left', keys: { key: 'arrowleft', ctrl: true }, category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.alignLeft(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.alignLeft), }, { id: 'align-right', keys: { key: 'arrowright', ctrl: true }, category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.alignRight(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.alignRight), }, { id: 'align-top', keys: { key: 'arrowup', ctrl: true }, category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.alignTop(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.alignTop), }, { id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true }, category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.alignBottom(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.alignBottom), }, // ═══════════════════════════════════════ @@ -62,18 +144,12 @@ export const shortcuts: ShortcutDef[] = [ { id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true }, category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3, - handler: (ctx) => { - ops.distributeHorizontal(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.distributeHorizontal), }, { id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true }, category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3, - handler: (ctx) => { - ops.distributeVertical(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.distributeVertical), }, // ═══════════════════════════════════════ @@ -83,34 +159,22 @@ export const shortcuts: ShortcutDef[] = [ { id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true }, category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.normalizeSize(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.normalizeSize), }, { id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true }, category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.normalizeScale(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.normalizeScale), }, { id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true }, category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.normalizeHeight(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.normalizeHeight), }, { id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true }, category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.normalizeWidth(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.normalizeWidth), }, // ═══════════════════════════════════════ @@ -120,42 +184,27 @@ export const shortcuts: ShortcutDef[] = [ { id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true }, category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.arrangeOptimal(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.arrangeOptimal), }, { id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true }, category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.arrangeByName(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.arrangeByName), }, { id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true }, category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.arrangeByZOrder(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.arrangeByZOrder), }, { id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true }, category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.arrangeRandomly(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.arrangeRandomly), }, { id: 'stack', keys: { key: 's', ctrl: true, alt: true }, category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.stackObjects(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.stackObjects), }, // ═══════════════════════════════════════ @@ -165,26 +214,17 @@ export const shortcuts: ShortcutDef[] = [ { id: 'flip-h', keys: { key: 'h', alt: true, shift: true }, category: 'image', description: 'Flip horizontal', needsSelection: true, - handler: (ctx) => { - ops.flipHorizontal(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.flipHorizontal), }, { id: 'flip-v', keys: { key: 'v', alt: true, shift: true }, category: 'image', description: 'Flip vertical', needsSelection: true, - handler: (ctx) => { - ops.flipVertical(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.flipVertical), }, { id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true }, category: 'image', description: 'Reset transform', needsSelection: true, - handler: (ctx) => { - ops.resetTransform(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.resetTransform), }, { id: 'toggle-grayscale', keys: { key: 'g', alt: true }, @@ -199,16 +239,95 @@ export const shortcuts: ShortcutDef[] = [ category: 'image', description: 'Toggle locked', needsSelection: true, handler: (ctx) => { ops.toggleLocked(ctx.selection.getSelectedItems()); + ctx.onChange(); ctx.refreshLayers(); }, }, { id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true }, category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2, - handler: (ctx) => { - ops.overlayCompare(ctx.selection.getSelectedItems()); - ctx.onChange(); - }, + handler: (ctx) => _opUpdate(ctx, ops.overlayCompare), + }, + + // ═══════════════════════════════════════ + // 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', 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' }, - category: 'navigation', description: 'Select next object', + id: 'focus-selection', keys: { key: 'f' }, + 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) => { - if (ctx.selection.selectedIds.size > 0) return; - const all = ctx.scene.getAllItems(); - if (all.length === 0) return; - // Sort by z to get consistent order - all.sort((a, b) => a.data.z - b.data.z); - ctx.selection.selectOnly(all[0].id); + if (ctx.selection.selectedIds.size > 0) { + _opUpdate(ctx, (items) => ops.nudge(items, 1, 0)); + } else { + const all = ctx.scene.getAllItems(); + if (all.length === 0) return; + all.sort((a, b) => a.data.z - b.data.z); + ctx.selection.selectOnly(all[0].id); + } }, }, { - id: 'cycle-prev', keys: { key: 'arrowleft' }, - category: 'navigation', description: 'Select previous object', + id: 'nudge-or-cycle-left', keys: { key: 'arrowleft' }, + category: 'navigation', description: 'Nudge 1px / Select prev', handler: (ctx) => { - if (ctx.selection.selectedIds.size > 0) return; - const all = ctx.scene.getAllItems(); - if (all.length === 0) return; - all.sort((a, b) => a.data.z - b.data.z); - ctx.selection.selectOnly(all[all.length - 1].id); + if (ctx.selection.selectedIds.size > 0) { + _opUpdate(ctx, (items) => ops.nudge(items, -1, 0)); + } else { + const all = ctx.scene.getAllItems(); + 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 { id: 'send-to-front', keys: { key: ']' }, @@ -311,7 +453,9 @@ export const shortcuts: ShortcutDef[] = [ handler: (ctx) => { const selected = ctx.selection.getSelectedItems(); 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.showToast('Copied'); } else { @@ -332,24 +476,36 @@ export const shortcuts: ShortcutDef[] = [ id: 'paste', keys: { key: 'v', ctrl: true }, category: 'editing', description: 'Paste', handler: async (ctx) => { - if (ctx.clipboardRef.current.length === 0) return; - const newItems: typeof ctx.clipboardRef.current = []; - for (const original of ctx.clipboardRef.current) { - // Clone: duplicate the item data with new ID and offset position - const newData = { - ...original.data, - id: crypto.randomUUID(), - x: original.data.x + 20, - y: original.data.y + 20, - z: ctx.scene.nextZ(), - }; - await ctx.scene._createItem(newData, true); - const newItem = ctx.scene.getById(newData.id); - if (newItem) newItems.push(newItem); + // Strategy: Check system clipboard for images first. + // - If system clipboard has an image AND we did NOT just do an internal copy + // (or it's been a while), paste from system clipboard (external image). + // - If we just did an internal copy (lastInternalCopyTime is recent), + // use internal clipboard to duplicate scene items (preserves vector data). + // - If system clipboard has no images, fall back to internal clipboard. + + const timeSinceInternalCopy = Date.now() - _lastInternalCopyTime; + const hasInternalItems = ctx.clipboardRef.current.length > 0; + const recentInternalCopy = hasInternalItems && timeSinceInternalCopy < 500; + + // If we JUST did an internal copy (<500ms ago), use internal clipboard + // (the system clipboard image is just the rasterized version of what we copied) + 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) => { const selected = ctx.selection.getSelectedItems(); if (selected.length === 0) return; - ctx.clipboardRef.current = [...selected]; + ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene); + markInternalCopy(); ctx.writeCanvasToClipboard(selected); for (const item of selected) { ctx.scene.removeItem(item.id, true); @@ -374,14 +531,13 @@ export const shortcuts: ShortcutDef[] = [ handler: async (ctx) => { const selected = ctx.selection.getSelectedItems(); if (selected.length === 0) return; - for (const original of selected) { - const newData = { - ...original.data, - id: crypto.randomUUID(), - x: original.data.x + 20, - y: original.data.y + 20, - z: ctx.scene.nextZ(), - }; + + // Collect group children and clone with remapped IDs + const allItems = _collectGroupChildren(selected, ctx.scene); + const clones = _cloneItemsWithOffset(allItems, ctx.scene); + const sorted = [...clones].sort((a: any, b: any) => + (a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0)); + for (const newData of sorted) { await ctx.scene._createItem(newData, true); } ctx.selection.clear(); @@ -455,6 +611,27 @@ export const shortcuts: ShortcutDef[] = [ category: 'editing', description: 'Ungroup', needsSelection: true, 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 { id: 'save', keys: { key: 's', ctrl: true }, diff --git a/frontend/src/canvas/shortcuts.ts b/frontend/src/canvas/shortcuts.ts index 3ebf35b..1edbc9b 100644 --- a/frontend/src/canvas/shortcuts.ts +++ b/frontend/src/canvas/shortcuts.ts @@ -43,7 +43,10 @@ export interface ShortcutContext { handleUngroup: () => void; toggleGrid: () => void; toggleShowHelp: () => void; + toggleFocusMode: () => void; + fitSelection: () => void; writeCanvasToClipboard: (items?: SceneItem[]) => Promise; + pasteFromSystemClipboard: () => Promise; } /** diff --git a/frontend/src/canvas/sprites/DrawingSprite.ts b/frontend/src/canvas/sprites/DrawingSprite.ts new file mode 100644 index 0000000..bc1b0d6 --- /dev/null +++ b/frontend/src/canvas/sprites/DrawingSprite.ts @@ -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); + } +} diff --git a/frontend/src/canvas/sprites/FrameSprite.ts b/frontend/src/canvas/sprites/FrameSprite.ts new file mode 100644 index 0000000..7e1ba46 --- /dev/null +++ b/frontend/src/canvas/sprites/FrameSprite.ts @@ -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 }); + } + } +} diff --git a/frontend/src/canvas/sprites/ImageSprite.ts b/frontend/src/canvas/sprites/ImageSprite.ts index b330b15..9146677 100644 --- a/frontend/src/canvas/sprites/ImageSprite.ts +++ b/frontend/src/canvas/sprites/ImageSprite.ts @@ -1,25 +1,24 @@ -import { Sprite, Texture, Graphics } from "pixi.js"; -import { DropShadowFilter } from "pixi-filters"; -import { TextureManager, LODTier } from "../TextureManager"; +import { Container, Sprite, Texture, Graphics } from "pixi.js"; +import { TextureManager } from "../TextureManager"; /** - * A Sprite subclass that manages LOD tier switching and lazy loading. - * Shows a placeholder shimmer rect until the first texture tier loads, - * then swaps textures as zoom level changes. + * A Container holding a shadow graphic + sprite with lazy texture loading. + * Uses a lightweight Graphics shadow instead of DropShadowFilter (GPU-heavy). */ // Shadow defaults (resting state) -const SHADOW_REST = { offsetX: 3, offsetY: 3, blur: 4, alpha: 0.25 }; +const SHADOW_REST = { offsetX: 3, offsetY: 3, alpha: 0.2 }; // Shadow lifted state (during drag) -const SHADOW_LIFT = { offsetX: 6, offsetY: 8, blur: 8, alpha: 0.35 }; +const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 }; -export class ImageSprite extends Sprite { +export class ImageSprite extends Container { readonly assetKey: string; - readonly shadow: DropShadowFilter; private textures: TextureManager; - private currentTier: LODTier | null = null; + private loaded = false; private loading = false; private placeholder: Graphics | null = null; + private _sprite: Sprite; + private _shadow: Graphics; private _naturalWidth: number; private _naturalHeight: number; @@ -29,24 +28,23 @@ export class ImageSprite extends Sprite { h: number, textures: TextureManager, ) { - super(Texture.EMPTY); + super(); this.assetKey = assetKey; this.textures = textures; this._naturalWidth = w; this._naturalHeight = h; - this.width = w; - this.height = h; + // Shadow: simple dark rect behind the sprite (cheap, no GPU filter) + this._shadow = new Graphics(); + this._drawShadow(SHADOW_REST); + this.addChild(this._shadow); - // Drop shadow for photos-on-a-desk feel - this.shadow = new DropShadowFilter({ - offset: { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY }, - blur: SHADOW_REST.blur, - alpha: SHADOW_REST.alpha, - color: 0x000000, - }); - this.filters = [this.shadow]; + // Main sprite + this._sprite = new Sprite(Texture.EMPTY); + this._sprite.width = w; + this._sprite.height = h; + this.addChild(this._sprite); // Create placeholder: dark rect shown until first texture loads const placeholder = new Graphics(); @@ -54,40 +52,39 @@ export class ImageSprite extends Sprite { this.placeholder = placeholder; this.addChild(placeholder); - // Immediately start loading the thumbnail tier - this.loadTier("thumb"); + // Immediately start loading the full texture + this.loadTexture(); + } + + private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void { + this._shadow.clear(); + this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight); + this._shadow.fill({ color: 0x000000, alpha: cfg.alpha }); } /** Expand shadow for drag-lift effect. */ liftShadow(): void { - this.shadow.offset = { x: SHADOW_LIFT.offsetX, y: SHADOW_LIFT.offsetY }; - this.shadow.blur = SHADOW_LIFT.blur; - this.shadow.alpha = SHADOW_LIFT.alpha; + this._drawShadow(SHADOW_LIFT); } /** Restore shadow to resting state. */ dropShadow(): void { - this.shadow.offset = { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY }; - this.shadow.blur = SHADOW_REST.blur; - this.shadow.alpha = SHADOW_REST.alpha; + this._drawShadow(SHADOW_REST); } - /** - * Load a specific LOD tier texture for this sprite. - * Skips if already at the requested tier or currently loading. - */ - async loadTier(tier: LODTier): Promise { - if (this.currentTier === tier || this.loading) return; + /** Load the full-res texture. GPU handles scaling natively. */ + async loadTexture(): Promise { + if (this.loaded || this.loading) return; this.loading = true; try { - const tex = await this.textures.load(this.assetKey, tier); - this.texture = tex; - this.width = this._naturalWidth; - this.height = this._naturalHeight; - this.currentTier = tier; + const tex = await this.textures.load(this.assetKey); + this._sprite.texture = tex; + this._sprite.width = this._naturalWidth; + this._sprite.height = this._naturalHeight; + this.loaded = true; - // Remove placeholder after first successful load + // Remove placeholder after successful load if (this.placeholder) { this.removeChild(this.placeholder); this.placeholder.destroy(); @@ -95,29 +92,11 @@ export class ImageSprite extends Sprite { } } catch (err) { console.warn( - `[ImageSprite] Failed to load tier "${tier}" for "${this.assetKey}":`, + `[ImageSprite] Failed to load "${this.assetKey}":`, err, ); } finally { this.loading = false; } } - - /** - * Evaluate the current zoom level and switch LOD tier if needed. - */ - updateLOD(zoom: number): void { - const needed = this.textures.tierForZoom(zoom); - if (needed !== this.currentTier) { - this.loadTier(needed); - } - } - - /** - * Release the current texture (for off-screen sprites to free GPU memory). - */ - unloadTexture(): void { - this.texture = Texture.EMPTY; - this.currentTier = null; - } } diff --git a/frontend/src/canvas/sync.ts b/frontend/src/canvas/sync.ts index 5c40e0d..03f3745 100644 --- a/frontend/src/canvas/sync.ts +++ b/frontend/src/canvas/sync.ts @@ -1,47 +1,128 @@ import type { Socket } from 'socket.io-client'; 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) - * 2. On receive → sceneManager.loadScene() (diff-based, no flicker) - * 3. During drag → lightweight transform events (throttled 50ms) + * Three event tiers: + * 1. scene:update — full scene snapshot. Sent on join (INIT) and periodically + * 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, - * so no suppress/resume logic is needed. + * During freehand drawing, only the single drawing element is sent via + * 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( sceneManager: SceneManager, socket: Socket, boardId: string, -): () => void { - let sceneTimer: ReturnType | null = null; + options?: SyncOptions, +): SyncHandle { + let debounceTimer: ReturnType | null = null; let moveTimer: ReturnType | null = null; - let receiving = false; // true while applying a remote scene/transform - const SCENE_THROTTLE = 300; // ms - const MOVE_THROTTLE = 50; // ms + let receiving = false; + let localVersion = 0; + 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 = new Map(); - function broadcastScene() { - if (receiving) return; - if (sceneTimer) clearTimeout(sceneTimer); - sceneTimer = setTimeout(() => { - sceneTimer = null; - if (receiving) return; - const scene = sceneManager.serialize(); - socket.emit('scene:update', { boardId, scene }); - }, SCENE_THROTTLE); + // Per-element version counter — bumped whenever an element changes locally + const elementVersions: Map = new Map(); + + function bumpElementVersion(id: string): number { + const v = (elementVersions.get(id) ?? 0) + 1; + elementVersions.set(id, v); + return v; } - // ---- 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) { if (receiving) return; - if (moveTimer) return; // throttled + if (moveTimer) return; const { id, data } = item; socket.emit('object:transform', { boardId, @@ -52,22 +133,34 @@ export function setupSync( sy: data.sy, angle: data.angle, }); - moveTimer = setTimeout(() => { - moveTimer = null; - }, MOVE_THROTTLE); + moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE); + broadcastSceneDebounced(); } - // ---- RECEIVE: full scene ---------------------------------------------- + // ---- RECEIVE: full scene -------------------------------------------------- function onSceneReceived(payload: any) { if (payload.boardId !== boardId) return; const scene: SceneData = payload.scene; 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; sceneManager .loadScene(scene) .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; }) .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) { if (payload.boardId !== boardId) return; const item = sceneManager.getById(payload.objectId); if (!item) return; - // Update data model item.data.x = payload.x; item.data.y = payload.y; item.data.sx = payload.sx; item.data.sy = payload.sy; item.data.angle = payload.angle; - // Update display object const obj = item.displayObject; obj.position.set(payload.x, payload.y); obj.scale.set(payload.sx, payload.sy); 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; sceneManager.onChange = () => { 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('element:update', onElementUpdate); + socket.on('element:remove', onElementRemove); socket.on('object:transform', onTransformReceived); - // ---- Join room -------------------------------------------------------- + // ---- Join room ------------------------------------------------------------ socket.emit('board:join', { boardId }, (response: any) => { if (response?.users) { @@ -119,21 +261,24 @@ export function setupSync( } }); - // ---- Cleanup ---------------------------------------------------------- + // ---- Return handle -------------------------------------------------------- - return () => { - // Restore previous onChange - sceneManager.onChange = prevOnChange; - - socket.off('scene:update', onSceneReceived); - socket.off('object:transform', onTransformReceived); - - socket.emit('board:leave', { boardId }); - - if (sceneTimer) clearTimeout(sceneTimer); - if (moveTimer) clearTimeout(moveTimer); + return { + broadcastTransform, + broadcastSceneNow, + broadcastElements(ids: string[]) { + for (const id of ids) bumpElementVersion(id); + broadcastElements(ids); + }, + cleanup: () => { + sceneManager.onChange = prevOnChange; + socket.off('scene:update', onSceneReceived); + 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. diff --git a/frontend/src/canvas/tools.ts b/frontend/src/canvas/tools.ts index 3c5318c..123f2c8 100644 --- a/frontend/src/canvas/tools.ts +++ b/frontend/src/canvas/tools.ts @@ -11,6 +11,8 @@ import type { Viewport } from 'pixi-viewport'; import type { SceneManager } from './SceneManager'; import type { SelectionManager } from './SelectionManager'; import { Text, TextStyle } from 'pixi.js'; +import { DrawingSprite } from './sprites/DrawingSprite'; +import type { DrawingObject } from './scene-format'; export enum ToolType { SELECT = 'SELECT', @@ -40,6 +42,8 @@ export interface ToolContext { selection: SelectionManager; container: HTMLElement; onChange: () => void; + /** Broadcast only specific changed elements (lightweight, for live drawing). */ + broadcastElements?: (ids: string[]) => void; } export function activateTool( @@ -53,6 +57,9 @@ export function activateTool( // Reset cursor container.style.cursor = ''; + // Enable/disable SelectionManager based on tool + selection.setEnabled(tool === ToolType.SELECT); + switch (tool) { case ToolType.SELECT: { container.style.cursor = 'default'; @@ -96,6 +103,7 @@ export function activateTool( scene._createItem(textData, true); scene._applyZOrder(); + ctx.broadcastElements?.([textData.id]); ctx.onChange(); // Remove handler after placing text @@ -110,12 +118,17 @@ export function activateTool( case ToolType.ERASER: { container.style.cursor = 'crosshair'; + // Clear any existing selection/transform box when switching to eraser + selection.clear(); + selection.transformBox.update([]); const onClick = (e: PointerEvent) => { const rect = container.getBoundingClientRect(); const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top); const hit = selection._hitTest(world.x, world.y); if (hit) { + selection.selectedIds.delete(hit.id); + selection.transformBox.update([]); scene.removeItem(hit.id, true); ctx.onChange(); } @@ -129,8 +142,131 @@ export function activateTool( case ToolType.PEN: { 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 | 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: diff --git a/frontend/src/components/Minimap.tsx b/frontend/src/components/Minimap.tsx new file mode 100644 index 0000000..9c581fa --- /dev/null +++ b/frontend/src/components/Minimap.tsx @@ -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 = { + 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(null); + const draggingRef = useRef(false); + const rafRef = useRef(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) => { + 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) => { + draggingRef.current = true; + (e.target as HTMLCanvasElement).setPointerCapture(e.pointerId); + handlePointerEvent(e); + }, [handlePointerEvent]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!draggingRef.current) return; + handlePointerEvent(e); + }, [handlePointerEvent]); + + const onPointerUp = useCallback((e: React.PointerEvent) => { + 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 ( + + ); +} diff --git a/frontend/src/components/SelectionToolbar.tsx b/frontend/src/components/SelectionToolbar.tsx new file mode 100644 index 0000000..fe37b77 --- /dev/null +++ b/frontend/src/components/SelectionToolbar.tsx @@ -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 ; +} +function IcoAlignCH() { + return ; +} +function IcoAlignR() { + return ; +} +function IcoAlignT() { + return ; +} +function IcoAlignCV() { + return ; +} +function IcoAlignB() { + return ; +} +function IcoDistH() { + return ; +} +function IcoDistV() { + return ; +} +function IcoPack() { + return ; +} +function IcoGrid() { + return ; +} +function IcoRow() { + return ; +} +function IcoCol() { + return ; +} +function IcoStack() { + return ; +} +function IcoFlipH() { + return ; +} +function IcoFlipV() { + return ; +} +function IcoGroup() { + return ; +} +function IcoNormSize() { + return ; +} + +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 ( +
e.stopPropagation()} + > + {groups.map((group, gi) => ( + + {gi > 0 && ( +
+ )} +
+ {group.items.map((btn) => { + const disabled = (btn.minItems ?? 2) > count; + const Icon = btn.icon; + return ( + + ); + })} +
+ + ))} +
+ ); +} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 4e8bf98..3f4b0d5 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -26,6 +26,8 @@ interface ToolbarProps { onUndo: () => void; onRedo: () => void; onlineUsers: OnlineUser[]; + onUserClick?: (userId: string, displayName: string) => void; + followingUserId?: string | null; onShareClick?: () => void; onToggleLayers?: () => void; showLayers?: boolean; @@ -139,6 +141,8 @@ export default function Toolbar({ onUndo, onRedo, onlineUsers, + onUserClick, + followingUserId, onShareClick, onToggleLayers, showLayers, @@ -306,11 +310,16 @@ export default function Toolbar({ background: `linear-gradient(135deg, ${u.color}, ${u.color}dd)`, display: 'flex', alignItems: 'center', justifyContent: 'center', 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', 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()}
))} diff --git a/frontend/src/components/UserCursors.tsx b/frontend/src/components/UserCursors.tsx index 8dfb0d4..5f6210f 100644 --- a/frontend/src/components/UserCursors.tsx +++ b/frontend/src/components/UserCursors.tsx @@ -91,10 +91,11 @@ export default function UserCursors({ socket, boardId, canvasTransform }: UserCu key={cursor.userId} style={{ position: 'absolute', - left: screenX, - top: screenY, - transform: 'translate(-2px, -2px)', - transition: 'left 0.1s, top 0.1s', + left: 0, + top: 0, + transform: `translate(${screenX - 2}px, ${screenY - 2}px)`, + transition: 'transform 50ms linear', + willChange: 'transform', }} > diff --git a/frontend/src/hooks/useBoardLoader.ts b/frontend/src/hooks/useBoardLoader.ts new file mode 100644 index 0000000..e27861a --- /dev/null +++ b/frontend/src/hooks/useBoardLoader.ts @@ -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(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 }; +} diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index 08aab90..e723b8d 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -6,6 +6,8 @@ import { setupSync, SyncHandle } from '../canvas/sync'; import { setupDragDrop, setupPaste } from '../canvas/image-drop'; import { UndoManager } from '../canvas/history'; 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'; interface OnlineUser { @@ -57,6 +59,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { const dropCleanupRef = useRef<(() => void) | null>(null); const pasteCleanupRef = useRef<(() => void) | null>(null); + const laserCleanupRef = useRef<(() => void) | null>(null); useEffect(() => { if (!boardData || !resolvedBoardId) return; @@ -70,6 +73,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { const selection = new SelectionManager(viewport, scene); selectionRef.current = selection; + // Wire snap guides to transform box + selection.transformBox.setSnapGuides(selection.snapGuides); + // Wire selection change to update layer panel state selection.onSelectionChange = (ids: string[]) => { setSelectedLayerIds(ids); @@ -176,6 +182,65 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { cursorTimer = setTimeout(() => { cursorTimer = null; }, 33); }; 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 | 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 @@ -208,6 +273,8 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { inboxZoneRef.current = null; } syncRef.current?.cleanup(); + laserCleanupRef.current?.(); + laserCleanupRef.current = null; dropCleanupRef.current?.(); pasteCleanupRef.current?.(); disconnectSocket(); diff --git a/frontend/src/hooks/useFollowMode.ts b/frontend/src/hooks/useFollowMode.ts new file mode 100644 index 0000000..5ca3550 --- /dev/null +++ b/frontend/src/hooks/useFollowMode.ts @@ -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({ + followingUserId: null, + followingDisplayName: null, + }); + const followRef = useRef(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, + }; +} diff --git a/frontend/src/hooks/useLayerPanel.ts b/frontend/src/hooks/useLayerPanel.ts new file mode 100644 index 0000000..48bca56 --- /dev/null +++ b/frontend/src/hooks/useLayerPanel.ts @@ -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; + selectionRef: React.RefObject; + onCanvasChange: () => void; +} + +function autoName(item: SceneItem, i: number, counters: Record): 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, + getScene: () => ReturnType, +): 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>({}); + + 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, + }, + }; +} diff --git a/frontend/src/hooks/useSaveManager.ts b/frontend/src/hooks/useSaveManager.ts new file mode 100644 index 0000000..fa15a6e --- /dev/null +++ b/frontend/src/hooks/useSaveManager.ts @@ -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; + setSaveStatus: (s: SaveStatus) => void; +} + +/** + * Debounced save with thumbnail generation from PixiJS renderer. + */ +export function useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSaveStatus }: SaveManagerOptions) { + const saveTimerRef = useRef | 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 }; +} diff --git a/frontend/src/hooks/useShortcutHandler.ts b/frontend/src/hooks/useShortcutHandler.ts new file mode 100644 index 0000000..a1a4213 --- /dev/null +++ b/frontend/src/hooks/useShortcutHandler.ts @@ -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; + selectionRef: React.RefObject; + undoRef: React.RefObject; + clipboardRef: React.MutableRefObject; + 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>; + setShowHelp: React.Dispatch>; + setFocusMode: React.Dispatch>; +} + +/** + * 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 => { + 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, + ]); +} diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 90d208f..9ffd032 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -22,6 +22,7 @@ import SelectionToolbar from '../components/SelectionToolbar'; import VideoControls from '../components/VideoControls'; import ShortcutsHelp from '../components/ShortcutsHelp'; import MattermostImport from '../components/MattermostImport'; +import Minimap from '../components/Minimap'; import { InboxZone } from '../canvas/InboxZone'; import { getItemWorldBounds } from '../canvas/SceneManager'; import { VideoSprite } from '../canvas/sprites/VideoSprite'; @@ -33,6 +34,7 @@ import { useSaveManager } from '../hooks/useSaveManager'; import { useCanvasSetup } from '../hooks/useCanvasSetup'; import { useShortcutHandler } from '../hooks/useShortcutHandler'; import { useLayerPanel } from '../hooks/useLayerPanel'; +import { useFollowMode } from '../hooks/useFollowMode'; interface EditorProps { isPublicView?: boolean; @@ -73,7 +75,7 @@ export default function Editor({ isPublicView }: EditorProps) { const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]); const [showLayers, setShowLayers] = useState(false); - const [showGrid, setShowGrid] = useState(false); + const [showGrid, setShowGrid] = useState(true); const [showHelp, setShowHelp] = useState(false); const [showMmImport, setShowMmImport] = useState(false); const [focusMode, setFocusMode] = useState(false); @@ -81,6 +83,10 @@ export default function Editor({ isPublicView }: EditorProps) { const [selectedLayerIds, setSelectedLayerIds] = useState([]); 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 [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 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]); }, [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) useCanvasSetup({ boardData, resolvedBoardId, user, isPublicView, @@ -156,11 +170,8 @@ export default function Editor({ isPublicView }: EditorProps) { }, [refreshLayerData]); useEffect(() => { - if (!showLayers) return; - refreshLayers(); - const interval = setInterval(refreshLayers, 500); - return () => clearInterval(interval); - }, [showLayers, refreshLayers]); + if (showLayers) refreshLayers(); + }, [showLayers, refreshLayers, selectedLayerIds]); // Grouping const handleGroup = useCallback(() => { @@ -240,38 +251,36 @@ export default function Editor({ isPublicView }: EditorProps) { return () => window.removeEventListener('beforeunload', onBeforeUnload); }, [resolvedBoardId]); - // Zoom poll + selection toolbar position tracking - useEffect(() => { - const interval = setInterval(() => { - setZoom(canvasRef.current?.getZoom() ?? 1); + // Update UI overlays (selection toolbar, video controls, minimap) on demand + const updateOverlays = useCallback(() => { + const selection = selectionRef.current; + const vp = canvasRef.current?.getViewport(); + if (!selection || !vp) { setSelToolbar(null); setVideoCtrl(null); return; } + const items = selection.getSelectedItems(); - // Update selection toolbar position - const selection = selectionRef.current; - const vp = canvasRef.current?.getViewport(); - if (!selection || !vp) { setSelToolbar(null); setVideoCtrl(null); return; } - const items = selection.getSelectedItems(); + setZoom(canvasRef.current?.getZoom() ?? 1); + setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]); - // Video controls: show when exactly 1 video is selected - if (items.length === 1 && items[0].type === 'video' && items[0].displayObject instanceof VideoSprite) { - const vs = items[0].displayObject as VideoSprite; - const b = getItemWorldBounds(items[0]); - const screenTL = vp.toScreen(b.x, b.y); - const screenBR = vp.toScreen(b.x + b.w, b.y + b.h); - setVideoCtrl({ - videoSprite: vs, - screenRect: { - x: screenTL.x, - y: screenTL.y, - w: screenBR.x - screenTL.x, - h: screenBR.y - screenTL.y, - }, - }); - } else { - setVideoCtrl(null); - } - - if (items.length < 2) { setSelToolbar(null); return; } + // Video controls: show when exactly 1 video is selected + if (items.length === 1 && items[0].type === 'video' && items[0].displayObject instanceof VideoSprite) { + const vs = items[0].displayObject as VideoSprite; + const b = getItemWorldBounds(items[0]); + const screenTL = vp.toScreen(b.x, b.y); + const screenBR = vp.toScreen(b.x + b.w, b.y + b.h); + setVideoCtrl({ + videoSprite: vs, + screenRect: { + x: screenTL.x, + y: screenTL.y, + w: screenBR.x - screenTL.x, + h: screenBR.y - screenTL.y, + }, + }); + } else { + setVideoCtrl(null); + } + if (items.length < 2) { setSelToolbar(null); } else { // Compute world bounding box of selection let minX = Infinity, minY = Infinity, maxX = -Infinity; for (const item of items) { @@ -280,17 +289,65 @@ export default function Editor({ isPublicView }: EditorProps) { if (b.y < minY) minY = b.y; if (b.x + b.w > maxX) maxX = b.x + b.w; } - - // Convert to screen const screenTL = vp.toScreen(minX, minY); const screenTR = vp.toScreen(maxX, minY); - const sx = (screenTL.x + screenTR.x) / 2; - const sy = screenTL.y; - setSelToolbar({ x: sx, y: sy, count: items.length }); - }, 100); - return () => clearInterval(interval); + setSelToolbar({ x: (screenTL.x + screenTR.x) / 2, y: screenTL.y, count: items.length }); + } + + // Minimap data + 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 -- if (loading) { @@ -399,6 +456,8 @@ export default function Editor({ isPublicView }: EditorProps) { setZoom(z); }} onlineUsers={onlineUsers} + onUserClick={startFollowing} + followingUserId={followingUserId} onToggleLayers={() => setShowLayers((v) => !v)} showLayers={showLayers} onToggleHelp={() => setShowHelp((v) => !v)} @@ -421,23 +480,32 @@ export default function Editor({ isPublicView }: EditorProps) { canvasTransform={canvasTransform} /> - {/* Grid overlay */} - {showGrid && ( - - - - - - - - - - )} + {/* Dot grid overlay — adapts spacing at zoom levels like Figma */} + {showGrid && (() => { + const scale = canvasTransform[0] || 1; + const tx = canvasTransform[4] || 0; + const ty = canvasTransform[5] || 0; + // Adaptive spacing: base 20px, doubles when dots get too dense, halves when too sparse + let spacing = 20; + while (spacing * scale < 12) spacing *= 2; + while (spacing * scale > 50) spacing /= 2; + const screenSpacing = spacing * scale; + const dotR = Math.max(0.5, Math.min(1.2, scale * 0.6)); + const ox = tx % screenSpacing; + const oy = ty % screenSpacing; + // Subtle dot: brighter at high zoom, dimmer at low zoom + const alpha = Math.max(0.08, Math.min(0.25, scale * 0.12)); + return ( + + + + + + + + + ); + })()} {/* Empty canvas guide */} {objectCount === 0 && ( @@ -459,23 +527,23 @@ export default function Editor({ isPublicView }: EditorProps) { x={selToolbar.x} y={selToolbar.y} count={selToolbar.count} - onAlignLeft={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignLeft(s); onCanvasChange(); } }} - onAlignCenterH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterH(s); onCanvasChange(); } }} - onAlignRight={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignRight(s); onCanvasChange(); } }} - onAlignTop={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignTop(s); onCanvasChange(); } }} - onAlignCenterV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterV(s); onCanvasChange(); } }} - onAlignBottom={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignBottom(s); onCanvasChange(); } }} - onDistributeH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeHorizontal(s); onCanvasChange(); } }} - onDistributeV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeVertical(s); onCanvasChange(); } }} - onPack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeOptimal(s); onCanvasChange(); } }} - onGrid={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeGrid(s); onCanvasChange(); } }} - onRow={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeRow(s); onCanvasChange(); } }} - onColumn={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeColumn(s); onCanvasChange(); } }} - onStack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.stackObjects(s); onCanvasChange(); } }} - onFlipH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipHorizontal(s); onCanvasChange(); } }} - onFlipV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipVertical(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); selectionRef.current?.transformBox.update(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); selectionRef.current?.transformBox.update(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); selectionRef.current?.transformBox.update(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); selectionRef.current?.transformBox.update(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); selectionRef.current?.transformBox.update(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); selectionRef.current?.transformBox.update(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); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} + onFlipV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} 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 && ( + { + const vp = canvasRef.current?.getViewport(); + if (vp) vp.animate({ time: 200, position: { x: wx, y: wy }, ease: 'easeOutQuad' }); + }} + /> + )} + + {/* Follow mode banner */} + {followingDisplayName && ( +
+ Following {followingDisplayName} — click to stop +
+ )} + {/* Context menu */} {contextMenu && (