feat(refboard): canvas polish — transform box sync, dot grid, snap tuning, perf fixes
- Fix transform box not updating after alignment/arrangement/normalize/flip operations (shortcuts, context menu, and selection toolbar all fixed with DRY _opUpdate helper) - Replace 100ms polling loop with event-driven viewport updates (moved + wheel-scroll) - Add adaptive dot grid background that responds to zoom/pan - Reduce snap guide threshold from 8px to 4px for subtler snapping - Remove PresenceOverlay (remote selection highlighting) — too heavy for minimal benefit - Offset multiple dropped images so they don't overlap - Add new canvas modules: SnapGuides, clipboard, grouping, FrameSprite, DrawingSprite, LaserPointer, context-menu-items, SelectionToolbar, Minimap - Extract Editor hooks into dedicated files (useBoardLoader, useCanvasSetup, useShortcutHandler, useLayerPanel, useSaveManager, useFollowMode) - Sync improvements: real-time transform broadcast, board rooms, viewport sync
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.git
|
||||||
|
**/.env
|
||||||
@@ -116,6 +116,8 @@ function getImageUrl(minioPath) {
|
|||||||
return `/api/images/${minioPath}`;
|
return `/api/images/${minioPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE_MB || '200', 10) * 1024 * 1024;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
minioClient,
|
minioClient,
|
||||||
initBucket,
|
initBucket,
|
||||||
@@ -126,4 +128,5 @@ module.exports = {
|
|||||||
getImageUrl,
|
getImageUrl,
|
||||||
MINIO_BUCKET,
|
MINIO_BUCKET,
|
||||||
MIME_TO_EXT,
|
MIME_TO_EXT,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const {
|
|||||||
createImage, getImageByMmFileId,
|
createImage, getImageByMmFileId,
|
||||||
} = require('../db');
|
} = require('../db');
|
||||||
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
|
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
|
||||||
const { generateLOD } = require('../services/lod-generator');
|
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -94,44 +93,23 @@ function classifyMedia(mimeType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload image buffer with LOD tiers to MinIO.
|
* Upload a media file (image or video) to MinIO as a single file.
|
||||||
|
* GPU handles all scaling natively — no LOD tiers needed.
|
||||||
*/
|
*/
|
||||||
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) {
|
async function uploadMedia(boardId, imageId, buffer, mimetype) {
|
||||||
const assetKey = `boards/${boardId}/${imageId}`;
|
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
||||||
const originalExt = MIME_TO_EXT[mimetype] || '.bin';
|
const minioPath = `boards/${boardId}/${imageId}${ext}`;
|
||||||
|
await putBuffer(minioPath, buffer, mimetype);
|
||||||
|
|
||||||
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') {
|
|
||||||
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 {
|
try {
|
||||||
const metadata = await sharp(buffer).metadata();
|
const metadata = await sharp(buffer).metadata();
|
||||||
width = metadata.width || null;
|
width = metadata.width || null;
|
||||||
height = metadata.height || null;
|
height = metadata.height || null;
|
||||||
} catch {}
|
} catch {}
|
||||||
return { assetKey, minioPath: fullPath, width, height };
|
|
||||||
}
|
}
|
||||||
|
return { assetKey: minioPath, minioPath, width, height };
|
||||||
const lod = await generateLOD(buffer, originalExt);
|
|
||||||
await Promise.all([
|
|
||||||
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
|
|
||||||
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
|
|
||||||
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const minioPath = `${assetKey}/full${lod.full.ext}`;
|
|
||||||
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload video buffer to MinIO (no LOD).
|
|
||||||
*/
|
|
||||||
async function uploadVideo(boardId, imageId, buffer, mimetype) {
|
|
||||||
const assetKey = `boards/${boardId}/${imageId}`;
|
|
||||||
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
|
||||||
const fullPath = `${assetKey}/full${ext}`;
|
|
||||||
await putBuffer(fullPath, buffer, mimetype);
|
|
||||||
return { assetKey, minioPath: fullPath, width: null, height: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------
|
// ---------------------
|
||||||
@@ -287,13 +265,7 @@ router.post('/:boardId/mm-pull', async (req, res) => {
|
|||||||
const imageId = uuidv4();
|
const imageId = uuidv4();
|
||||||
const mediaType = classifyMedia(mimeType);
|
const mediaType = classifyMedia(mimeType);
|
||||||
|
|
||||||
let assetKey, minioPath, width, height;
|
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType);
|
||||||
|
|
||||||
if (mediaType === 'video') {
|
|
||||||
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimeType));
|
|
||||||
} else {
|
|
||||||
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimeType));
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = getImageUrl(minioPath);
|
const publicUrl = getImageUrl(minioPath);
|
||||||
|
|
||||||
|
|||||||
+17
-61
@@ -7,8 +7,7 @@ const http = require('http');
|
|||||||
const { URL } = require('url');
|
const { URL } = require('url');
|
||||||
const { authMiddleware } = require('../auth');
|
const { authMiddleware } = require('../auth');
|
||||||
const { getBoard, getCollectionMember, createImage } = require('../db');
|
const { getBoard, getCollectionMember, createImage } = require('../db');
|
||||||
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
|
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
|
||||||
const { generateLOD } = require('../services/lod-generator');
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -28,9 +27,9 @@ const VIDEO_MIME_TYPES = [
|
|||||||
|
|
||||||
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES];
|
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES];
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
const MAX_FILE_SIZE_LABEL = `${MAX_FILE_SIZE / 1024 / 1024}MB`;
|
||||||
|
|
||||||
// Multer config: memory storage, 50MB limit, image types only
|
// Multer config: memory storage, configurable size limit
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: MAX_FILE_SIZE },
|
limits: { fileSize: MAX_FILE_SIZE },
|
||||||
@@ -90,46 +89,17 @@ function classifyMedia(mimeType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload LOD tiers for an image to MinIO.
|
* Upload a media file (image or video) to MinIO as a single file.
|
||||||
* Stores: {assetKey}/thumb.webp, {assetKey}/medium.webp, {assetKey}/full{ext}
|
* GPU handles all image scaling natively — no LOD tiers needed.
|
||||||
* Returns the minioPath for the full tier (backward compat) and dimensions.
|
|
||||||
*/
|
*/
|
||||||
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) {
|
async function uploadMedia(boardId, imageId, buffer, mimetype) {
|
||||||
const assetKey = `boards/${boardId}/${imageId}`;
|
|
||||||
const originalExt = MIME_TO_EXT[mimetype] || '.bin';
|
|
||||||
|
|
||||||
// SVG and GIF skip LOD — store single file
|
|
||||||
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') {
|
|
||||||
const fullPath = `${assetKey}/full${originalExt}`;
|
|
||||||
await putBuffer(fullPath, buffer, mimetype);
|
|
||||||
const dims = await getImageDimensions(buffer, mimetype);
|
|
||||||
return { assetKey, minioPath: fullPath, ...dims };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lod = await generateLOD(buffer, originalExt);
|
|
||||||
|
|
||||||
// Upload all 3 tiers in parallel
|
|
||||||
await Promise.all([
|
|
||||||
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
|
|
||||||
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
|
|
||||||
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// minioPath points to full tier for backward compat
|
|
||||||
const minioPath = `${assetKey}/full${lod.full.ext}`;
|
|
||||||
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a video file to MinIO (single file, no LOD).
|
|
||||||
* Returns { assetKey, minioPath, width: null, height: null }.
|
|
||||||
*/
|
|
||||||
async function uploadVideo(boardId, imageId, buffer, mimetype) {
|
|
||||||
const assetKey = `boards/${boardId}/${imageId}`;
|
|
||||||
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
||||||
const fullPath = `${assetKey}/full${ext}`;
|
const minioPath = `boards/${boardId}/${imageId}${ext}`;
|
||||||
await putBuffer(fullPath, buffer, mimetype);
|
await putBuffer(minioPath, buffer, mimetype);
|
||||||
return { assetKey, minioPath: fullPath, width: null, height: null };
|
|
||||||
|
const dims = await getImageDimensions(buffer, mimetype);
|
||||||
|
// assetKey = minioPath so frontend can build direct URLs
|
||||||
|
return { assetKey: minioPath, minioPath, width: dims.width, height: dims.height };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,14 +119,7 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
|
|||||||
const { buffer, originalname, mimetype, size } = req.file;
|
const { buffer, originalname, mimetype, size } = req.file;
|
||||||
const mediaType = classifyMedia(mimetype);
|
const mediaType = classifyMedia(mimetype);
|
||||||
|
|
||||||
let assetKey, minioPath, width, height;
|
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimetype);
|
||||||
|
|
||||||
if (mediaType === 'video') {
|
|
||||||
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimetype));
|
|
||||||
} else {
|
|
||||||
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimetype));
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = getImageUrl(minioPath);
|
const publicUrl = getImageUrl(minioPath);
|
||||||
|
|
||||||
// Save record
|
// Save record
|
||||||
@@ -188,7 +151,7 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(413).json({ error: 'File too large (max 50MB)' });
|
return res.status(413).json({ error: `File too large (max ${MAX_FILE_SIZE_LABEL})` });
|
||||||
}
|
}
|
||||||
console.error('[upload] error:', err);
|
console.error('[upload] error:', err);
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
@@ -225,7 +188,7 @@ function downloadImage(imageUrl) {
|
|||||||
totalSize += chunk.length;
|
totalSize += chunk.length;
|
||||||
if (totalSize > MAX_FILE_SIZE) {
|
if (totalSize > MAX_FILE_SIZE) {
|
||||||
response.destroy();
|
response.destroy();
|
||||||
return reject(new Error('Downloaded file too large (max 50MB)'));
|
return reject(new Error(`Downloaded file too large (max ${MAX_FILE_SIZE_LABEL})`));
|
||||||
}
|
}
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
});
|
});
|
||||||
@@ -261,14 +224,7 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
|
|||||||
const imageId = uuidv4();
|
const imageId = uuidv4();
|
||||||
const mediaType = classifyMedia(mimeType);
|
const mediaType = classifyMedia(mimeType);
|
||||||
|
|
||||||
let assetKey, minioPath, width, height;
|
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType);
|
||||||
|
|
||||||
if (mediaType === 'video') {
|
|
||||||
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimeType));
|
|
||||||
} else {
|
|
||||||
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimeType));
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = getImageUrl(minioPath);
|
const publicUrl = getImageUrl(minioPath);
|
||||||
|
|
||||||
// Save record
|
// Save record
|
||||||
@@ -311,7 +267,7 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
|
|||||||
router.use((err, req, res, next) => {
|
router.use((err, req, res, next) => {
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(413).json({ error: 'File too large (max 50MB)' });
|
return res.status(413).json({ error: `File too large (max ${MAX_FILE_SIZE_LABEL})` });
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: err.message });
|
return res.status(400).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ const { URL } = require('url');
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const { getAllBoardChannelLinks, getImageByMmFileId, createImage, getBoard } = require('../db');
|
const { getAllBoardChannelLinks, getImageByMmFileId, createImage, getBoard } = require('../db');
|
||||||
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
|
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
|
||||||
const { generateLOD } = require('./lod-generator');
|
|
||||||
|
|
||||||
const MM_URL = process.env.MM_URL;
|
const MM_URL = process.env.MM_URL;
|
||||||
const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN;
|
const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN;
|
||||||
@@ -87,7 +86,7 @@ function mmDownloadFile(fileId) {
|
|||||||
const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
const MAX_SIZE = 50 * 1024 * 1024;
|
const MAX_SIZE = MAX_FILE_SIZE;
|
||||||
|
|
||||||
res.on('data', (chunk) => {
|
res.on('data', (chunk) => {
|
||||||
totalSize += chunk.length;
|
totalSize += chunk.length;
|
||||||
@@ -147,47 +146,23 @@ function classifyMedia(mimeType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload image with LOD tiers (mirrors upload.js logic).
|
* Upload a media file (image or video) to MinIO as a single file.
|
||||||
|
* GPU handles all image scaling natively — no LOD tiers needed.
|
||||||
*/
|
*/
|
||||||
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) {
|
async function uploadMedia(boardId, imageId, buffer, mimetype) {
|
||||||
const assetKey = `boards/${boardId}/${imageId}`;
|
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
||||||
const originalExt = MIME_TO_EXT[mimetype] || '.bin';
|
const minioPath = `boards/${boardId}/${imageId}${ext}`;
|
||||||
|
await putBuffer(minioPath, buffer, mimetype);
|
||||||
|
|
||||||
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') {
|
|
||||||
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') {
|
if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) {
|
||||||
try {
|
try {
|
||||||
const meta = await sharp(buffer).metadata();
|
const meta = await sharp(buffer).metadata();
|
||||||
width = meta.width || null;
|
width = meta.width || null;
|
||||||
height = meta.height || null;
|
height = meta.height || null;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return { assetKey, minioPath: fullPath, width, height };
|
return { assetKey: minioPath, minioPath, width, height };
|
||||||
}
|
|
||||||
|
|
||||||
const lod = await generateLOD(buffer, originalExt);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
|
|
||||||
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
|
|
||||||
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const minioPath = `${assetKey}/full${lod.full.ext}`;
|
|
||||||
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a video file (single file, no LOD).
|
|
||||||
*/
|
|
||||||
async function uploadVideo(boardId, imageId, buffer, mimetype) {
|
|
||||||
const assetKey = `boards/${boardId}/${imageId}`;
|
|
||||||
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
|
||||||
const fullPath = `${assetKey}/full${ext}`;
|
|
||||||
await putBuffer(fullPath, buffer, mimetype);
|
|
||||||
return { assetKey, minioPath: fullPath, width: null, height: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,13 +214,7 @@ async function processLink(link) {
|
|||||||
const imageId = uuidv4();
|
const imageId = uuidv4();
|
||||||
const mediaType = classifyMedia(mimeType);
|
const mediaType = classifyMedia(mimeType);
|
||||||
|
|
||||||
let assetKey, minioPath, width, height;
|
const { assetKey, minioPath, width, height } = await uploadMedia(boardId, imageId, buffer, mimeType);
|
||||||
|
|
||||||
if (mediaType === 'video') {
|
|
||||||
({ assetKey, minioPath, width, height } = await uploadVideo(boardId, imageId, buffer, mimeType));
|
|
||||||
} else {
|
|
||||||
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(boardId, imageId, buffer, mimeType));
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = getImageUrl(minioPath);
|
const publicUrl = getImageUrl(minioPath);
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,26 @@ function setupBoardRoom(io, socket) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Incremental element sync (Excalidraw-style) ----
|
||||||
|
|
||||||
|
socket.on('element:update', (data) => {
|
||||||
|
if (!socket.currentBoardId) return;
|
||||||
|
const roomName = getRoomName(socket.currentBoardId);
|
||||||
|
socket.to(roomName).emit('element:update', {
|
||||||
|
...data,
|
||||||
|
userId: socket.userId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('element:remove', (data) => {
|
||||||
|
if (!socket.currentBoardId) return;
|
||||||
|
const roomName = getRoomName(socket.currentBoardId);
|
||||||
|
socket.to(roomName).emit('element:remove', {
|
||||||
|
...data,
|
||||||
|
userId: socket.userId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Lightweight transform (during drag/resize/rotate) ----
|
// ---- Lightweight transform (during drag/resize/rotate) ----
|
||||||
|
|
||||||
socket.on('object:transform', (data) => {
|
socket.on('object:transform', (data) => {
|
||||||
@@ -102,6 +122,32 @@ function setupBoardRoom(io, socket) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Selection presence (broadcast what items a user has selected) ----
|
||||||
|
|
||||||
|
socket.on('selection:update', (data) => {
|
||||||
|
if (!socket.currentBoardId) return;
|
||||||
|
const roomName = getRoomName(socket.currentBoardId);
|
||||||
|
socket.volatile.to(roomName).emit('selection:update', {
|
||||||
|
...data,
|
||||||
|
userId: socket.userId,
|
||||||
|
displayName: socket.userDisplayName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Laser pointer ----
|
||||||
|
|
||||||
|
socket.on('laser:move', (data) => {
|
||||||
|
if (!socket.currentBoardId) return;
|
||||||
|
const roomName = getRoomName(socket.currentBoardId);
|
||||||
|
socket.volatile.to(roomName).emit('laser:move', { ...data, userId: socket.userId });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('laser:stop', (data) => {
|
||||||
|
if (!socket.currentBoardId) return;
|
||||||
|
const roomName = getRoomName(socket.currentBoardId);
|
||||||
|
socket.to(roomName).emit('laser:stop', { ...data, userId: socket.userId });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Disconnect cleanup ----
|
// ---- Disconnect cleanup ----
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const { Server } = require('socket.io');
|
|||||||
const { verifyToken } = require('../auth');
|
const { verifyToken } = require('../auth');
|
||||||
const { getUserById } = require('../db');
|
const { getUserById } = require('../db');
|
||||||
const { setupBoardRoom } = require('./board-room');
|
const { setupBoardRoom } = require('./board-room');
|
||||||
|
const { setupViewportSync } = require('./viewport-sync');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up Socket.IO on the given HTTP server.
|
* Set up Socket.IO on the given HTTP server.
|
||||||
@@ -49,6 +50,7 @@ function setupSocket(httpServer) {
|
|||||||
|
|
||||||
// Set up board room handlers
|
// Set up board room handlers
|
||||||
setupBoardRoom(io, socket);
|
setupBoardRoom(io, socket);
|
||||||
|
setupViewportSync(io, socket);
|
||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
console.log(`[socket] User disconnected: ${socket.userDisplayName} (${reason})`);
|
console.log(`[socket] User disconnected: ${socket.userDisplayName} (${reason})`);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -141,8 +141,8 @@ export class InboxZone extends Container {
|
|||||||
sprite.width = sw;
|
sprite.width = sw;
|
||||||
sprite.height = sh;
|
sprite.height = sh;
|
||||||
|
|
||||||
// Load thumbnail texture
|
// Load texture (GPU handles scaling)
|
||||||
this.textures.load(assetKey, 'thumb').then((tex) => {
|
this.textures.load(assetKey).then((tex) => {
|
||||||
if (!sprite.destroyed) {
|
if (!sprite.destroyed) {
|
||||||
sprite.texture = tex;
|
sprite.texture = tex;
|
||||||
sprite.width = sw;
|
sprite.width = sw;
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { Graphics } from 'pixi.js';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
|
||||||
|
interface LaserPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
t: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LaserPointer {
|
||||||
|
private _gfx: Graphics;
|
||||||
|
private _viewport: Viewport;
|
||||||
|
private _points: LaserPoint[] = [];
|
||||||
|
private _active = false;
|
||||||
|
private _rafId: number | null = null;
|
||||||
|
private _color: number;
|
||||||
|
private _fadeMs = 2000;
|
||||||
|
private _maxPoints = 200;
|
||||||
|
|
||||||
|
// Remote user lasers
|
||||||
|
private _remoteGfx: Map<string, Graphics> = new Map();
|
||||||
|
private _remotePoints: Map<string, LaserPoint[]> = new Map();
|
||||||
|
|
||||||
|
constructor(viewport: Viewport, color: number = 0xff4444) {
|
||||||
|
this._viewport = viewport;
|
||||||
|
this._color = color;
|
||||||
|
this._gfx = new Graphics();
|
||||||
|
this._gfx.label = '__laser_local';
|
||||||
|
viewport.addChild(this._gfx);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isActive(): boolean {
|
||||||
|
return this._active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin recording trail, start render loop */
|
||||||
|
start(): void {
|
||||||
|
this._active = true;
|
||||||
|
if (this._rafId === null) {
|
||||||
|
this._tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop recording, let trail fade naturally, stop loop when empty */
|
||||||
|
stop(): void {
|
||||||
|
this._active = false;
|
||||||
|
// Render loop continues until all points have faded
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a local laser point with current timestamp */
|
||||||
|
addPoint(worldX: number, worldY: number): void {
|
||||||
|
this._points.push({ x: worldX, y: worldY, t: performance.now() });
|
||||||
|
if (this._points.length > this._maxPoints) {
|
||||||
|
this._points.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add points from a remote user's laser */
|
||||||
|
addRemotePoints(
|
||||||
|
userId: string,
|
||||||
|
points: { x: number; y: number }[],
|
||||||
|
color: number,
|
||||||
|
): void {
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
if (!this._remoteGfx.has(userId)) {
|
||||||
|
const gfx = new Graphics();
|
||||||
|
gfx.label = `__laser_remote_${userId}`;
|
||||||
|
this._viewport.addChild(gfx);
|
||||||
|
this._remoteGfx.set(userId, gfx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let arr = this._remotePoints.get(userId);
|
||||||
|
if (!arr) {
|
||||||
|
arr = [];
|
||||||
|
this._remotePoints.set(userId, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store color on the graphics object for rendering
|
||||||
|
const gfx = this._remoteGfx.get(userId)!;
|
||||||
|
(gfx as any)._laserColor = color;
|
||||||
|
|
||||||
|
for (const p of points) {
|
||||||
|
arr.push({ x: p.x, y: p.y, t: now });
|
||||||
|
}
|
||||||
|
if (arr.length > this._maxPoints) {
|
||||||
|
arr.splice(0, arr.length - this._maxPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure render loop is running
|
||||||
|
if (this._rafId === null) {
|
||||||
|
this._tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a remote user's laser entirely */
|
||||||
|
removeRemote(userId: string): void {
|
||||||
|
const gfx = this._remoteGfx.get(userId);
|
||||||
|
if (gfx) {
|
||||||
|
gfx.clear();
|
||||||
|
gfx.destroy();
|
||||||
|
this._remoteGfx.delete(userId);
|
||||||
|
}
|
||||||
|
this._remotePoints.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rAF-driven render loop */
|
||||||
|
private _tick = (): void => {
|
||||||
|
this._render();
|
||||||
|
|
||||||
|
// Check if there's anything left to draw
|
||||||
|
const hasLocal = this._points.length > 0;
|
||||||
|
let hasRemote = false;
|
||||||
|
for (const [, pts] of this._remotePoints) {
|
||||||
|
if (pts.length > 0) {
|
||||||
|
hasRemote = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLocal || hasRemote || this._active) {
|
||||||
|
this._rafId = requestAnimationFrame(this._tick);
|
||||||
|
} else {
|
||||||
|
this._rafId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private _render(): void {
|
||||||
|
const now = performance.now();
|
||||||
|
const scale = this._viewport.scale.x || 1;
|
||||||
|
const lineWidth = 3 / scale;
|
||||||
|
|
||||||
|
// --- Local laser ---
|
||||||
|
this._prunePoints(this._points, now);
|
||||||
|
this._drawTrail(this._gfx, this._points, this._color, lineWidth, now);
|
||||||
|
|
||||||
|
// --- Remote lasers ---
|
||||||
|
for (const [userId, pts] of this._remotePoints) {
|
||||||
|
this._prunePoints(pts, now);
|
||||||
|
const gfx = this._remoteGfx.get(userId);
|
||||||
|
if (!gfx) continue;
|
||||||
|
const color = (gfx as any)._laserColor ?? 0xff4444;
|
||||||
|
this._drawTrail(gfx, pts, color, lineWidth, now);
|
||||||
|
|
||||||
|
// Clean up empty remote lasers
|
||||||
|
if (pts.length === 0) {
|
||||||
|
gfx.clear();
|
||||||
|
// Don't destroy — they may send more points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _prunePoints(points: LaserPoint[], now: number): void {
|
||||||
|
while (points.length > 0 && now - points[0].t > this._fadeMs) {
|
||||||
|
points.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _drawTrail(
|
||||||
|
gfx: Graphics,
|
||||||
|
points: LaserPoint[],
|
||||||
|
color: number,
|
||||||
|
lineWidth: number,
|
||||||
|
now: number,
|
||||||
|
): void {
|
||||||
|
gfx.clear();
|
||||||
|
if (points.length < 2) return;
|
||||||
|
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const p0 = points[i - 1];
|
||||||
|
const p1 = points[i];
|
||||||
|
const age = now - p1.t;
|
||||||
|
const alpha = Math.max(0, 1 - age / this._fadeMs);
|
||||||
|
if (alpha <= 0) continue;
|
||||||
|
|
||||||
|
gfx.setStrokeStyle({ width: lineWidth, color, alpha, cap: 'round', join: 'round' });
|
||||||
|
gfx.moveTo(p0.x, p0.y);
|
||||||
|
gfx.lineTo(p1.x, p1.y);
|
||||||
|
gfx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clean up all resources */
|
||||||
|
destroy(): void {
|
||||||
|
if (this._rafId !== null) {
|
||||||
|
cancelAnimationFrame(this._rafId);
|
||||||
|
this._rafId = null;
|
||||||
|
}
|
||||||
|
this._active = false;
|
||||||
|
this._points.length = 0;
|
||||||
|
|
||||||
|
this._gfx.clear();
|
||||||
|
this._gfx.destroy();
|
||||||
|
|
||||||
|
for (const [, gfx] of this._remoteGfx) {
|
||||||
|
gfx.clear();
|
||||||
|
gfx.destroy();
|
||||||
|
}
|
||||||
|
this._remoteGfx.clear();
|
||||||
|
this._remotePoints.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,11 @@ import {
|
|||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Application } from 'pixi.js';
|
import { Application } from 'pixi.js';
|
||||||
import { Viewport } from 'pixi-viewport';
|
import { Viewport } from 'pixi-viewport';
|
||||||
import { SceneManager } from './SceneManager';
|
import { SceneManager, getItemWorldBounds } from './SceneManager';
|
||||||
import { TextureManager } from './TextureManager';
|
import { TextureManager } from './TextureManager';
|
||||||
import { SpringManager } from './spring';
|
import { SpringManager } from './spring';
|
||||||
import { convertFabricToV2 } from './scene-format';
|
import { convertFabricToV2 } from './scene-format';
|
||||||
@@ -28,13 +29,14 @@ import type { SceneData } from './scene-format';
|
|||||||
export interface PixiCanvasHandle {
|
export interface PixiCanvasHandle {
|
||||||
getViewport: () => Viewport | null;
|
getViewport: () => Viewport | null;
|
||||||
getScene: () => SceneManager | null;
|
getScene: () => SceneManager | null;
|
||||||
|
getApp: () => Application | null;
|
||||||
fitAll: () => void;
|
fitAll: () => void;
|
||||||
getZoom: () => number;
|
getZoom: () => number;
|
||||||
setZoom: (zoom: number) => void;
|
setZoom: (zoom: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PixiCanvasProps {
|
export interface PixiCanvasProps {
|
||||||
canvasState?: string | null;
|
canvasState?: string | object | null;
|
||||||
currentTool: string;
|
currentTool: string;
|
||||||
boardId?: string;
|
boardId?: string;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
@@ -99,6 +101,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
const initialLoadDone = useRef(false);
|
const initialLoadDone = useRef(false);
|
||||||
const spaceHeld = useRef(false);
|
const spaceHeld = useRef(false);
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
|
const [pixiReady, setPixiReady] = useState(false);
|
||||||
|
|
||||||
// Keep onChange ref current without re-running effects
|
// Keep onChange ref current without re-running effects
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
@@ -121,6 +124,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
antialias: true,
|
antialias: true,
|
||||||
autoDensity: true,
|
autoDensity: true,
|
||||||
resolution: window.devicePixelRatio,
|
resolution: window.devicePixelRatio,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (destroyed) {
|
if (destroyed) {
|
||||||
@@ -160,7 +164,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
springs.tick(ticker.deltaMS / 1000);
|
springs.tick(ticker.deltaMS / 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- Culling + LOD ticker (runs every 200ms, not every frame) ------
|
// -- Visibility culling ticker (runs every 200ms, not every frame) --
|
||||||
|
|
||||||
let lastCullCheck = 0;
|
let lastCullCheck = 0;
|
||||||
app.ticker.add((ticker) => {
|
app.ticker.add((ticker) => {
|
||||||
@@ -168,29 +172,18 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
if (lastCullCheck < 200) return;
|
if (lastCullCheck < 200) return;
|
||||||
lastCullCheck = 0;
|
lastCullCheck = 0;
|
||||||
|
|
||||||
const zoom = viewport.scale.x;
|
|
||||||
const bounds = viewport.getVisibleBounds();
|
const bounds = viewport.getVisibleBounds();
|
||||||
const margin = 200;
|
const margin = 200;
|
||||||
|
|
||||||
for (const item of scene.getAllItems()) {
|
for (const item of scene.getAllItems()) {
|
||||||
const d = item.displayObject;
|
if (item.type === 'video' && 'onVisibilityChange' in item.displayObject) {
|
||||||
const ib = d.getBounds();
|
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||||
const inView =
|
const inView =
|
||||||
ib.x + ib.width > bounds.x - margin &&
|
ix + iw > bounds.x - margin &&
|
||||||
ib.x < bounds.x + bounds.width + margin &&
|
ix < bounds.x + bounds.width + margin &&
|
||||||
ib.y + ib.height > bounds.y - margin &&
|
iy + ih > bounds.y - margin &&
|
||||||
ib.y < bounds.y + bounds.height + margin;
|
iy < bounds.y + bounds.height + margin;
|
||||||
|
(item.displayObject as any).onVisibilityChange(inView);
|
||||||
if (item.type === 'image' && 'updateLOD' in d) {
|
|
||||||
if (inView) {
|
|
||||||
(d as any).updateLOD(zoom);
|
|
||||||
d.visible = true;
|
|
||||||
} else {
|
|
||||||
d.visible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.type === 'video' && 'onVisibilityChange' in d) {
|
|
||||||
(d as any).onVisibilityChange(inView);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -230,6 +223,18 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
|
|
||||||
// Store observer for cleanup
|
// Store observer for cleanup
|
||||||
(container as any).__pixiRO = ro;
|
(container as any).__pixiRO = ro;
|
||||||
|
|
||||||
|
// Signal that PixiJS is ready for scene loading
|
||||||
|
setPixiReady(true);
|
||||||
|
|
||||||
|
// -- Prevent Ctrl+wheel browser zoom on canvas -----------------------
|
||||||
|
const preventBrowserZoom = (e: WheelEvent) => {
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
container.addEventListener('wheel', preventBrowserZoom, { passive: false });
|
||||||
|
(container as any).__pixiWheelHandler = preventBrowserZoom;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// -- Cleanup ---------------------------------------------------------
|
// -- Cleanup ---------------------------------------------------------
|
||||||
@@ -243,6 +248,12 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
delete (container as any).__pixiRO;
|
delete (container as any).__pixiRO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wheelHandler = (container as any).__pixiWheelHandler;
|
||||||
|
if (wheelHandler) {
|
||||||
|
container.removeEventListener('wheel', wheelHandler);
|
||||||
|
delete (container as any).__pixiWheelHandler;
|
||||||
|
}
|
||||||
|
|
||||||
textures.clear();
|
textures.clear();
|
||||||
|
|
||||||
if (appRef.current) {
|
if (appRef.current) {
|
||||||
@@ -263,12 +274,17 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
if (initialLoadDone.current) return;
|
if (initialLoadDone.current) return;
|
||||||
if (!canvasState || !sceneRef.current) return;
|
if (!canvasState || !sceneRef.current) return;
|
||||||
|
|
||||||
|
// canvasState may be a JSON string or already-parsed object
|
||||||
let parsed: any;
|
let parsed: any;
|
||||||
|
if (typeof canvasState === 'string') {
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(canvasState);
|
parsed = JSON.parse(canvasState);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
parsed = canvasState;
|
||||||
|
}
|
||||||
|
|
||||||
let sceneData: SceneData;
|
let sceneData: SceneData;
|
||||||
if (parsed.v === 2) {
|
if (parsed.v === 2) {
|
||||||
@@ -279,7 +295,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
|
|
||||||
initialLoadDone.current = true;
|
initialLoadDone.current = true;
|
||||||
sceneRef.current.loadScene(sceneData, false);
|
sceneRef.current.loadScene(sceneData, false);
|
||||||
}, [canvasState]);
|
}, [canvasState, pixiReady]);
|
||||||
|
|
||||||
// ── Space key for pan mode ────────────────────────────────────────
|
// ── Space key for pan mode ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -337,12 +353,11 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
let maxY = -Infinity;
|
let maxY = -Infinity;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const obj = item.displayObject;
|
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||||
const bounds = obj.getBounds();
|
if (ix < minX) minX = ix;
|
||||||
if (bounds.x < minX) minX = bounds.x;
|
if (iy < minY) minY = iy;
|
||||||
if (bounds.y < minY) minY = bounds.y;
|
if (ix + iw > maxX) maxX = ix + iw;
|
||||||
if (bounds.x + bounds.width > maxX) maxX = bounds.x + bounds.width;
|
if (iy + ih > maxY) maxY = iy + ih;
|
||||||
if (bounds.y + bounds.height > maxY) maxY = bounds.y + bounds.height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const padding = 40;
|
const padding = 40;
|
||||||
@@ -368,6 +383,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
() => ({
|
() => ({
|
||||||
getViewport: () => viewportRef.current,
|
getViewport: () => viewportRef.current,
|
||||||
getScene: () => sceneRef.current,
|
getScene: () => sceneRef.current,
|
||||||
|
getApp: () => appRef.current,
|
||||||
fitAll,
|
fitAll,
|
||||||
getZoom: () => viewportRef.current?.scale.x ?? 1,
|
getZoom: () => viewportRef.current?.scale.x ?? 1,
|
||||||
setZoom: (zoom: number) => {
|
setZoom: (zoom: number) => {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* PresenceOverlay — renders colored selection borders for remote users.
|
||||||
|
*
|
||||||
|
* When another user selects items on the collaborative whiteboard, this overlay
|
||||||
|
* draws thin colored outlines around those items on the local screen, with a
|
||||||
|
* name-label pill above the first selected item.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
import type { SceneManager } from './SceneManager';
|
||||||
|
import { getItemWorldBounds } from './SceneManager';
|
||||||
|
|
||||||
|
interface RemoteSelection {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
color: number; // hex color, e.g. 0xff6600
|
||||||
|
itemIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PresenceOverlay {
|
||||||
|
private _viewport: Viewport;
|
||||||
|
private _scene: SceneManager;
|
||||||
|
private _container: Container;
|
||||||
|
private _selections: Map<string, RemoteSelection> = new Map();
|
||||||
|
private _graphics: Map<string, Graphics> = new Map(); // per-user graphics
|
||||||
|
private _labels: Map<string, Text> = new Map(); // per-user name label
|
||||||
|
private _rafId: number | null = null;
|
||||||
|
|
||||||
|
constructor(viewport: Viewport, scene: SceneManager) {
|
||||||
|
this._viewport = viewport;
|
||||||
|
this._scene = scene;
|
||||||
|
this._container = new Container();
|
||||||
|
this._container.label = '__presence_overlay';
|
||||||
|
this._container.eventMode = 'none';
|
||||||
|
this._container.interactiveChildren = false;
|
||||||
|
viewport.addChild(this._container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a remote user's selection. Empty array = deselected. */
|
||||||
|
updateSelection(userId: string, displayName: string, color: number, itemIds: string[]): void {
|
||||||
|
if (itemIds.length === 0) {
|
||||||
|
this._selections.delete(userId);
|
||||||
|
this._cleanupUser(userId);
|
||||||
|
} else {
|
||||||
|
this._selections.set(userId, { userId, displayName, color, itemIds });
|
||||||
|
}
|
||||||
|
this._scheduleRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a user entirely (they disconnected / left). */
|
||||||
|
removeUser(userId: string): void {
|
||||||
|
this._selections.delete(userId);
|
||||||
|
this._cleanupUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cleanupUser(userId: string): void {
|
||||||
|
const gfx = this._graphics.get(userId);
|
||||||
|
if (gfx) {
|
||||||
|
gfx.destroy();
|
||||||
|
this._graphics.delete(userId);
|
||||||
|
}
|
||||||
|
const label = this._labels.get(userId);
|
||||||
|
if (label) {
|
||||||
|
label.destroy();
|
||||||
|
this._labels.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleRedraw(): void {
|
||||||
|
if (this._rafId !== null) return;
|
||||||
|
this._rafId = requestAnimationFrame(() => {
|
||||||
|
this._rafId = null;
|
||||||
|
this._redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _redraw(): void {
|
||||||
|
const zoom = this._viewport.scale.x;
|
||||||
|
|
||||||
|
for (const [userId, sel] of this._selections) {
|
||||||
|
// Get or create graphics for this user
|
||||||
|
let gfx = this._graphics.get(userId);
|
||||||
|
if (!gfx) {
|
||||||
|
gfx = new Graphics();
|
||||||
|
gfx.label = `__presence_${userId}`;
|
||||||
|
this._container.addChild(gfx);
|
||||||
|
this._graphics.set(userId, gfx);
|
||||||
|
}
|
||||||
|
gfx.clear();
|
||||||
|
|
||||||
|
// Get or create label
|
||||||
|
let label = this._labels.get(userId);
|
||||||
|
if (!label) {
|
||||||
|
label = new Text({
|
||||||
|
text: sel.displayName,
|
||||||
|
style: new TextStyle({
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
fill: '#ffffff',
|
||||||
|
fontWeight: '600',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
label.label = `__presence_label_${userId}`;
|
||||||
|
this._container.addChild(label);
|
||||||
|
this._labels.set(userId, label);
|
||||||
|
}
|
||||||
|
label.text = sel.displayName;
|
||||||
|
label.visible = false; // hide until we know where to place it
|
||||||
|
label.scale.set(1 / zoom); // fixed screen-space size
|
||||||
|
|
||||||
|
// Draw border around each selected item, track first bounds for label
|
||||||
|
let firstBounds: { x: number; y: number; w: number; h: number } | null = null;
|
||||||
|
|
||||||
|
for (const itemId of sel.itemIds) {
|
||||||
|
const item = this._scene.getById(itemId);
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
const b = getItemWorldBounds(item);
|
||||||
|
|
||||||
|
const pad = 2 / zoom;
|
||||||
|
gfx.rect(b.x - pad, b.y - pad, b.w + pad * 2, b.h + pad * 2);
|
||||||
|
gfx.stroke({ color: sel.color, width: 1.5 / zoom, alpha: 0.7 });
|
||||||
|
|
||||||
|
if (!firstBounds) firstBounds = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position name-label pill above the first selected item
|
||||||
|
if (firstBounds) {
|
||||||
|
// Draw background pill first so text renders on top
|
||||||
|
const labelOffsetY = 16 / zoom;
|
||||||
|
const px = 3 / zoom;
|
||||||
|
|
||||||
|
// We need label dimensions — make it visible and measure
|
||||||
|
label.visible = true;
|
||||||
|
label.position.set(firstBounds.x, firstBounds.y - labelOffsetY);
|
||||||
|
|
||||||
|
const lw = label.width;
|
||||||
|
const lh = label.height;
|
||||||
|
|
||||||
|
gfx.roundRect(
|
||||||
|
firstBounds.x - px,
|
||||||
|
firstBounds.y - labelOffsetY - px / 2,
|
||||||
|
lw + px * 2,
|
||||||
|
lh + px,
|
||||||
|
2 / zoom,
|
||||||
|
);
|
||||||
|
gfx.fill({ color: sel.color, alpha: 0.85 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up graphics/labels for users no longer in _selections
|
||||||
|
for (const userId of this._graphics.keys()) {
|
||||||
|
if (!this._selections.has(userId)) {
|
||||||
|
this._cleanupUser(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call when items may have moved to keep borders in sync. */
|
||||||
|
refresh(): void {
|
||||||
|
if (this._selections.size > 0) this._redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this._rafId !== null) cancelAnimationFrame(this._rafId);
|
||||||
|
this._container.destroy({ children: true });
|
||||||
|
this._graphics.clear();
|
||||||
|
this._labels.clear();
|
||||||
|
this._selections.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,16 +5,22 @@
|
|||||||
* and spring-animated add/remove operations.
|
* and spring-animated add/remove operations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Container, Sprite, Texture, Graphics, Text, TextStyle } from 'pixi.js';
|
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import { TextureManager, LODTier } from './TextureManager';
|
import { TextureManager } from './TextureManager';
|
||||||
|
import { ImageSprite } from './sprites/ImageSprite';
|
||||||
|
import { VideoSprite } from './sprites/VideoSprite';
|
||||||
|
import { DrawingSprite } from './sprites/DrawingSprite';
|
||||||
|
import { FrameSprite } from './sprites/FrameSprite';
|
||||||
import { SpringManager, Spring, PRESETS } from './spring';
|
import { SpringManager, Spring, PRESETS } from './spring';
|
||||||
|
import { reparentGroupChildren } from './grouping';
|
||||||
import type {
|
import type {
|
||||||
SceneData,
|
SceneData,
|
||||||
AnySceneObject,
|
AnySceneObject,
|
||||||
ImageObject,
|
ImageObject,
|
||||||
VideoObject,
|
VideoObject,
|
||||||
TextObject,
|
TextObject,
|
||||||
|
DrawingObject,
|
||||||
GroupObject,
|
GroupObject,
|
||||||
SceneObject,
|
SceneObject,
|
||||||
} from './scene-format';
|
} from './scene-format';
|
||||||
@@ -25,11 +31,106 @@ import type {
|
|||||||
|
|
||||||
export interface SceneItem {
|
export interface SceneItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'image' | 'video' | 'text' | 'group';
|
type: 'image' | 'video' | 'text' | 'drawing' | 'group';
|
||||||
displayObject: Container;
|
displayObject: Container;
|
||||||
data: AnySceneObject;
|
data: AnySceneObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set of child IDs that belong to a group — rebuilt when groups change. */
|
||||||
|
let _groupChildIds: Set<string> | null = null;
|
||||||
|
|
||||||
|
/** Rebuild the set of all group-child IDs. Call after group add/remove/load. */
|
||||||
|
export function rebuildGroupChildSet(scene: SceneManager): void {
|
||||||
|
_groupChildIds = new Set<string>();
|
||||||
|
for (const item of scene.items.values()) {
|
||||||
|
if (item.data.type !== 'group') continue;
|
||||||
|
const gd = item.data as GroupObject;
|
||||||
|
for (const cid of gd.children) _groupChildIds.add(cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the item is a child of a group (not independently selectable). */
|
||||||
|
export function isGroupChild(id: string): boolean {
|
||||||
|
return _groupChildIds?.has(id) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single source of truth for an item's world-space bounding rect.
|
||||||
|
* Uses data.sx/sy (not obj.scale which may be mid-animation).
|
||||||
|
* For groups: computes the union of children bounds (w/h on group data may be 0).
|
||||||
|
* For group children: converts local coords to world using parent group transforms. */
|
||||||
|
export function getItemWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
|
||||||
|
if (item.data.type === 'group') {
|
||||||
|
return _getGroupWorldBounds(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this item is a child of a group, convert local → world
|
||||||
|
const parent = item.displayObject.parent;
|
||||||
|
if (parent && parent.label && _groupChildIds?.has(item.id)) {
|
||||||
|
const px = parent.position.x;
|
||||||
|
const py = parent.position.y;
|
||||||
|
const psx = parent.scale.x;
|
||||||
|
const psy = parent.scale.y;
|
||||||
|
return {
|
||||||
|
x: px + item.data.x * psx,
|
||||||
|
y: py + item.data.y * psy,
|
||||||
|
w: item.data.w * Math.abs(item.data.sx * psx),
|
||||||
|
h: item.data.h * Math.abs(item.data.sy * psy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: item.data.x,
|
||||||
|
y: item.data.y,
|
||||||
|
w: item.data.w * Math.abs(item.data.sx),
|
||||||
|
h: item.data.h * Math.abs(item.data.sy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute group bounds from its data.w/h (set during grouping) or fall back
|
||||||
|
* to stored children data. Children store LOCAL x/y relative to group.
|
||||||
|
*/
|
||||||
|
function _getGroupWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
|
||||||
|
const groupX = item.data.x;
|
||||||
|
const groupY = item.data.y;
|
||||||
|
const groupW = item.data.w;
|
||||||
|
const groupH = item.data.h;
|
||||||
|
|
||||||
|
// If group has valid w/h (set during creation), apply scale just like regular items
|
||||||
|
if (groupW > 0 && groupH > 0) {
|
||||||
|
return {
|
||||||
|
x: groupX,
|
||||||
|
y: groupY,
|
||||||
|
w: groupW * Math.abs(item.data.sx),
|
||||||
|
h: groupH * Math.abs(item.data.sy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: shouldn't happen, but compute from children display objects
|
||||||
|
const container = item.displayObject;
|
||||||
|
if (container.children.length === 0) {
|
||||||
|
return { x: groupX, y: groupY, w: 0, h: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const child of container.children) {
|
||||||
|
const cx = child.x;
|
||||||
|
const cy = child.y;
|
||||||
|
// Approximate child size from its local bounds
|
||||||
|
const lb = child.getLocalBounds();
|
||||||
|
const cw = lb.width;
|
||||||
|
const ch = lb.height;
|
||||||
|
minX = Math.min(minX, cx);
|
||||||
|
minY = Math.min(minY, cy);
|
||||||
|
maxX = Math.max(maxX, cx + cw);
|
||||||
|
maxY = Math.max(maxY, cy + ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFinite(minX)) return { x: groupX, y: groupY, w: 0, h: 0 };
|
||||||
|
// Children positions are local to group, so offset by group world position
|
||||||
|
return { x: groupX + minX, y: groupY + minY, w: maxX - minX, h: maxY - minY };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SceneManager
|
// SceneManager
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -41,6 +142,7 @@ export class SceneManager {
|
|||||||
readonly springs: SpringManager;
|
readonly springs: SpringManager;
|
||||||
|
|
||||||
private _onChange: (() => void) | null = null;
|
private _onChange: (() => void) | null = null;
|
||||||
|
private _onItemDimensionsChanged: ((itemId: string) => void) | null = null;
|
||||||
private _zCounter: number = 0;
|
private _zCounter: number = 0;
|
||||||
|
|
||||||
constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) {
|
constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) {
|
||||||
@@ -55,6 +157,10 @@ export class SceneManager {
|
|||||||
this._onChange = fn;
|
this._onChange = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set onItemDimensionsChanged(fn: ((itemId: string) => void) | null) {
|
||||||
|
this._onItemDimensionsChanged = fn;
|
||||||
|
}
|
||||||
|
|
||||||
get onChange(): (() => void) | null {
|
get onChange(): (() => void) | null {
|
||||||
return this._onChange;
|
return this._onChange;
|
||||||
}
|
}
|
||||||
@@ -98,6 +204,21 @@ export class SceneManager {
|
|||||||
}
|
}
|
||||||
for (const id of toRemove) {
|
for (const id of toRemove) {
|
||||||
const item = this.items.get(id)!;
|
const item = this.items.get(id)!;
|
||||||
|
|
||||||
|
// If removing a group, reparent its PixiJS children to viewport first
|
||||||
|
// so they survive the container destruction (they may still be in the
|
||||||
|
// incoming set as ungrouped top-level items).
|
||||||
|
if (item.data.type === 'group') {
|
||||||
|
const container = item.displayObject;
|
||||||
|
// Reparent actual scene children (skip internal frame bg/label)
|
||||||
|
const toReparent = container.children.filter(
|
||||||
|
(c) => !c.label?.startsWith('__frame_'),
|
||||||
|
);
|
||||||
|
for (const child of toReparent) {
|
||||||
|
this.viewport.addChild(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item.displayObject.destroy({ children: true });
|
item.displayObject.destroy({ children: true });
|
||||||
this.items.delete(id);
|
this.items.delete(id);
|
||||||
}
|
}
|
||||||
@@ -121,7 +242,11 @@ export class SceneManager {
|
|||||||
|
|
||||||
await Promise.all(loadPromises);
|
await Promise.all(loadPromises);
|
||||||
|
|
||||||
// 4. Apply z-ordering
|
// 4. Reparent group children into their group containers
|
||||||
|
reparentGroupChildren(this);
|
||||||
|
rebuildGroupChildSet(this);
|
||||||
|
|
||||||
|
// 5. Apply z-ordering
|
||||||
this._applyZOrder();
|
this._applyZOrder();
|
||||||
|
|
||||||
this._onChange?.();
|
this._onChange?.();
|
||||||
@@ -136,31 +261,18 @@ export class SceneManager {
|
|||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'image': {
|
case 'image': {
|
||||||
const imgData = data as ImageObject;
|
const imgData = data as ImageObject;
|
||||||
const sprite = new Sprite(Texture.EMPTY);
|
displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
||||||
sprite.width = imgData.w;
|
|
||||||
sprite.height = imgData.h;
|
|
||||||
|
|
||||||
// Load texture asynchronously at appropriate LOD
|
|
||||||
const zoom = this.viewport.scale.x;
|
|
||||||
const tier = this.textures.tierForZoom(zoom);
|
|
||||||
this.textures.load(imgData.asset, tier).then((tex) => {
|
|
||||||
if (!sprite.destroyed) {
|
|
||||||
sprite.texture = tex;
|
|
||||||
sprite.width = imgData.w;
|
|
||||||
sprite.height = imgData.h;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
displayObject = sprite;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'video': {
|
case 'video': {
|
||||||
const vidData = data as VideoObject;
|
const vidData = data as VideoObject;
|
||||||
const gfx = new Graphics();
|
const videoUrl = this.textures.urlForAsset(vidData.asset);
|
||||||
gfx.rect(0, 0, vidData.w, vidData.h);
|
const videoSprite = new VideoSprite(vidData.asset, vidData.w, vidData.h, videoUrl);
|
||||||
gfx.fill(0x333333);
|
// Auto-correct dimensions when video metadata loads
|
||||||
displayObject = gfx;
|
// NOTE: callback must update item.data (the stored copy), not the original `data` param.
|
||||||
|
// We wire this after the item is created below (see post-creation video wiring).
|
||||||
|
displayObject = videoSprite;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +287,15 @@ export class SceneManager {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'drawing': {
|
||||||
|
const drawData = data as DrawingObject;
|
||||||
|
displayObject = new DrawingSprite(drawData.points, drawData.color, drawData.strokeWidth);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'group': {
|
case 'group': {
|
||||||
displayObject = new Container();
|
const gd = data as GroupObject;
|
||||||
|
displayObject = new FrameSprite(gd.w, gd.h, gd.bgColor, gd.label, gd.padding);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,23 +325,52 @@ export class SceneManager {
|
|||||||
|
|
||||||
this.items.set(data.id, item);
|
this.items.set(data.id, item);
|
||||||
|
|
||||||
// Animate entrance: spring scale from 0 → 1.05 → 1.0 with fade-in
|
// Wire video dimension auto-correction to the STORED item.data (not the original param)
|
||||||
|
if (data.type === 'video' && displayObject instanceof VideoSprite) {
|
||||||
|
displayObject.onDimensionsKnown = (realW, realH) => {
|
||||||
|
item.data.w = realW;
|
||||||
|
item.data.h = realH;
|
||||||
|
this._onChange?.();
|
||||||
|
this._onItemDimensionsChanged?.(item.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate entrance: spring scale from center 0 → 1.05 → 1.0 with fade-in
|
||||||
if (animate) {
|
if (animate) {
|
||||||
displayObject.scale.set(0, 0);
|
displayObject.scale.set(0, 0);
|
||||||
displayObject.alpha = 0;
|
displayObject.alpha = 0;
|
||||||
|
|
||||||
|
// Scale from center by adjusting position to keep center point fixed
|
||||||
|
const halfW = data.w * data.sx / 2;
|
||||||
|
const halfH = data.h * data.sy / 2;
|
||||||
|
const finalX = data.x;
|
||||||
|
const finalY = data.y;
|
||||||
|
|
||||||
const scaleSpring = new Spring(0, 1.05, PRESETS.bounce);
|
const scaleSpring = new Spring(0, 1.05, PRESETS.bounce);
|
||||||
scaleSpring.onUpdate = (v) => {
|
scaleSpring.onUpdate = (v) => {
|
||||||
if (!displayObject.destroyed) {
|
if (!displayObject.destroyed) {
|
||||||
displayObject.scale.set(v * data.sx, v * data.sy);
|
displayObject.scale.set(v * data.sx, v * data.sy);
|
||||||
|
displayObject.position.set(
|
||||||
|
finalX + halfW * (1 - v),
|
||||||
|
finalY + halfH * (1 - v),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
scaleSpring.onComplete = () => {
|
scaleSpring.onComplete = () => {
|
||||||
// Second spring: 1.05 → 1.0
|
|
||||||
const settleSpring = new Spring(1.05, 1.0, PRESETS.snappy);
|
const settleSpring = new Spring(1.05, 1.0, PRESETS.snappy);
|
||||||
settleSpring.onUpdate = (v) => {
|
settleSpring.onUpdate = (v) => {
|
||||||
if (!displayObject.destroyed) {
|
if (!displayObject.destroyed) {
|
||||||
displayObject.scale.set(v * data.sx, v * data.sy);
|
displayObject.scale.set(v * data.sx, v * data.sy);
|
||||||
|
displayObject.position.set(
|
||||||
|
finalX + halfW * (1 - v),
|
||||||
|
finalY + halfH * (1 - v),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
settleSpring.onComplete = () => {
|
||||||
|
if (!displayObject.destroyed) {
|
||||||
|
displayObject.position.set(finalX, finalY);
|
||||||
|
displayObject.scale.set(data.sx, data.sy);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.springs.add(settleSpring);
|
this.springs.add(settleSpring);
|
||||||
@@ -252,6 +400,21 @@ export class SceneManager {
|
|||||||
obj.visible = data.visible;
|
obj.visible = data.visible;
|
||||||
obj.eventMode = data.locked ? 'none' : 'static';
|
obj.eventMode = data.locked ? 'none' : 'static';
|
||||||
|
|
||||||
|
// Type-specific updates
|
||||||
|
if (data.type === 'drawing' && obj instanceof DrawingSprite) {
|
||||||
|
const drawData = data as DrawingObject;
|
||||||
|
obj.setPoints(drawData.points);
|
||||||
|
}
|
||||||
|
if (data.type === 'text' && obj instanceof Text) {
|
||||||
|
const txtData = data as TextObject;
|
||||||
|
obj.text = txtData.text;
|
||||||
|
obj.style.fontSize = txtData.fontSize;
|
||||||
|
obj.style.fill = txtData.fill;
|
||||||
|
}
|
||||||
|
if (data.type === 'group' && obj instanceof FrameSprite) {
|
||||||
|
obj.updateFromData(data as GroupObject);
|
||||||
|
}
|
||||||
|
|
||||||
// Update stored data
|
// Update stored data
|
||||||
item.data = { ...data };
|
item.data = { ...data };
|
||||||
item.type = data.type;
|
item.type = data.type;
|
||||||
@@ -259,16 +422,47 @@ export class SceneManager {
|
|||||||
|
|
||||||
// -- Z-Ordering ----------------------------------------------------------
|
// -- Z-Ordering ----------------------------------------------------------
|
||||||
|
|
||||||
/** Sort items by z and reorder children in the viewport. */
|
/** Sort items by z and reorder children in both viewport and group containers. */
|
||||||
_applyZOrder(): void {
|
_applyZOrder(): void {
|
||||||
const sorted = Array.from(this.items.values()).sort(
|
const sorted = Array.from(this.items.values()).sort(
|
||||||
(a, b) => a.data.z - b.data.z,
|
(a, b) => a.data.z - b.data.z,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
// Reorder top-level items in the viewport
|
||||||
const child = sorted[i].displayObject;
|
let vpIndex = 0;
|
||||||
|
for (const item of sorted) {
|
||||||
|
const child = item.displayObject;
|
||||||
if (child.parent === this.viewport) {
|
if (child.parent === this.viewport) {
|
||||||
this.viewport.setChildIndex(child, i);
|
// Clamp index to valid range (selection overlay etc. may also be viewport children)
|
||||||
|
const maxIdx = this.viewport.children.length - 1;
|
||||||
|
const targetIdx = Math.min(vpIndex, maxIdx);
|
||||||
|
if (this.viewport.getChildIndex(child) !== targetIdx) {
|
||||||
|
this.viewport.setChildIndex(child, targetIdx);
|
||||||
|
}
|
||||||
|
vpIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder children within each group container
|
||||||
|
for (const item of sorted) {
|
||||||
|
if (item.data.type !== 'group') continue;
|
||||||
|
const groupData = item.data as GroupObject;
|
||||||
|
const container = item.displayObject;
|
||||||
|
// Sort children by their z value within the group
|
||||||
|
const childItems = groupData.children
|
||||||
|
.map((id) => this.items.get(id))
|
||||||
|
.filter((c): c is SceneItem => !!c)
|
||||||
|
.sort((a, b) => a.data.z - b.data.z);
|
||||||
|
|
||||||
|
for (let i = 0; i < childItems.length; i++) {
|
||||||
|
const child = childItems[i].displayObject;
|
||||||
|
if (child.parent === container) {
|
||||||
|
const maxIdx = container.children.length - 1;
|
||||||
|
const targetIdx = Math.min(i, maxIdx);
|
||||||
|
if (container.getChildIndex(child) !== targetIdx) {
|
||||||
|
container.setChildIndex(child, targetIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,13 +477,27 @@ export class SceneManager {
|
|||||||
return Array.from(this.items.values());
|
return Array.from(this.items.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get only top-level items (excludes group children). Used for selection/hit testing. */
|
||||||
|
getTopLevelItems(): SceneItem[] {
|
||||||
|
return Array.from(this.items.values()).filter((item) => !isGroupChild(item.id));
|
||||||
|
}
|
||||||
|
|
||||||
// -- Remove Item ---------------------------------------------------------
|
// -- Remove Item ---------------------------------------------------------
|
||||||
|
|
||||||
/** Remove an item, optionally animating scale→0.8 + fade out before destroy. */
|
/** Remove an item, optionally animating scale→0.8 + fade out before destroy.
|
||||||
|
* If item is a group, recursively removes all children from the items map. */
|
||||||
removeItem(id: string, animate = true): void {
|
removeItem(id: string, animate = true): void {
|
||||||
const item = this.items.get(id);
|
const item = this.items.get(id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
|
// If this is a group, remove children from the items map first
|
||||||
|
if (item.data.type === 'group') {
|
||||||
|
const groupData = item.data as import('./scene-format').GroupObject;
|
||||||
|
for (const childId of groupData.children) {
|
||||||
|
this.items.delete(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!animate) {
|
if (!animate) {
|
||||||
item.displayObject.destroy({ children: true });
|
item.displayObject.destroy({ children: true });
|
||||||
this.items.delete(id);
|
this.items.delete(id);
|
||||||
@@ -302,10 +510,20 @@ export class SceneManager {
|
|||||||
// Remove from map immediately to prevent double-remove
|
// Remove from map immediately to prevent double-remove
|
||||||
this.items.delete(id);
|
this.items.delete(id);
|
||||||
|
|
||||||
|
// Scale toward center on removal
|
||||||
|
const halfW = item.data.w * item.data.sx / 2;
|
||||||
|
const halfH = item.data.h * item.data.sy / 2;
|
||||||
|
const startX = item.data.x;
|
||||||
|
const startY = item.data.y;
|
||||||
|
|
||||||
const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy);
|
const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy);
|
||||||
scaleSpring.onUpdate = (v) => {
|
scaleSpring.onUpdate = (v) => {
|
||||||
if (!obj.destroyed) {
|
if (!obj.destroyed) {
|
||||||
obj.scale.set(v * (item.data.sx), v * (item.data.sy));
|
obj.scale.set(v * item.data.sx, v * item.data.sy);
|
||||||
|
obj.position.set(
|
||||||
|
startX + halfW * (1 - v),
|
||||||
|
startY + halfH * (1 - v),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.springs.add(scaleSpring);
|
this.springs.add(scaleSpring);
|
||||||
@@ -371,6 +589,41 @@ export class SceneManager {
|
|||||||
return this.items.get(data.id)!;
|
return this.items.get(data.id)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a VideoObject from an upload and add it to the scene with animation. */
|
||||||
|
addVideoFromUpload(
|
||||||
|
assetKey: string,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): SceneItem {
|
||||||
|
const data: VideoObject = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: 'video',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
sx: 1,
|
||||||
|
sy: 1,
|
||||||
|
angle: 0,
|
||||||
|
z: this.nextZ(),
|
||||||
|
opacity: 1,
|
||||||
|
locked: false,
|
||||||
|
name: '',
|
||||||
|
visible: true,
|
||||||
|
asset: assetKey,
|
||||||
|
muted: true,
|
||||||
|
loop: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._createItem(data, true);
|
||||||
|
this._applyZOrder();
|
||||||
|
this._onChange?.();
|
||||||
|
|
||||||
|
return this.items.get(data.id)!;
|
||||||
|
}
|
||||||
|
|
||||||
// -- Group / Ungroup with Spring Animation --------------------------------
|
// -- Group / Ungroup with Spring Animation --------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
|
|
||||||
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import { SceneManager, SceneItem, getItemWorldBounds, isGroupChild } from './SceneManager';
|
import { SceneManager, SceneItem, getItemWorldBounds } from './SceneManager';
|
||||||
import { TransformBox } from './TransformBox';
|
import { TransformBox } from './TransformBox';
|
||||||
|
import { SnapGuides } from './SnapGuides';
|
||||||
import { ImageSprite } from './sprites/ImageSprite';
|
import { ImageSprite } from './sprites/ImageSprite';
|
||||||
import { VideoSprite } from './sprites/VideoSprite';
|
import { VideoSprite } from './sprites/VideoSprite';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const DRAG_THRESHOLD = 5; // px in screen space before object drag activa
|
|||||||
export class SelectionManager {
|
export class SelectionManager {
|
||||||
readonly selectedIds: Set<string> = new Set();
|
readonly selectedIds: Set<string> = new Set();
|
||||||
readonly transformBox: TransformBox;
|
readonly transformBox: TransformBox;
|
||||||
|
private _snapGuides: SnapGuides;
|
||||||
|
|
||||||
private _viewport: Viewport;
|
private _viewport: Viewport;
|
||||||
private _scene: SceneManager;
|
private _scene: SceneManager;
|
||||||
@@ -62,6 +64,8 @@ export class SelectionManager {
|
|||||||
|
|
||||||
private _enabled = true;
|
private _enabled = true;
|
||||||
|
|
||||||
|
get snapGuides(): SnapGuides { return this._snapGuides; }
|
||||||
|
|
||||||
constructor(viewport: Viewport, scene: SceneManager) {
|
constructor(viewport: Viewport, scene: SceneManager) {
|
||||||
this._viewport = viewport;
|
this._viewport = viewport;
|
||||||
this._scene = scene;
|
this._scene = scene;
|
||||||
@@ -81,6 +85,9 @@ export class SelectionManager {
|
|||||||
this.transformBox.setViewport(viewport);
|
this.transformBox.setViewport(viewport);
|
||||||
this._overlay.addChild(this.transformBox);
|
this._overlay.addChild(this.transformBox);
|
||||||
|
|
||||||
|
// Snap guides
|
||||||
|
this._snapGuides = new SnapGuides(scene, this._overlay);
|
||||||
|
|
||||||
// Bind events on viewport
|
// Bind events on viewport
|
||||||
viewport.on('pointerdown', this._onPointerDown, this);
|
viewport.on('pointerdown', this._onPointerDown, this);
|
||||||
viewport.on('globalpointermove', this._onPointerMove, this);
|
viewport.on('globalpointermove', this._onPointerMove, this);
|
||||||
@@ -229,17 +236,31 @@ export class SelectionManager {
|
|||||||
|
|
||||||
// Lift shadow + spring scale on all selected image sprites
|
// Lift shadow + spring scale on all selected image sprites
|
||||||
this._applyLift();
|
this._applyLift();
|
||||||
|
|
||||||
|
// Begin snap guide session
|
||||||
|
this._snapGuides.beginSession(this.selectedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._objectDragging) {
|
if (this._objectDragging) {
|
||||||
const currentWorld = this._viewport.toWorld(e.global.x, e.global.y);
|
const currentWorld = this._viewport.toWorld(e.global.x, e.global.y);
|
||||||
const ddx = currentWorld.x - this._lastDragWorldX;
|
let ddx = currentWorld.x - this._lastDragWorldX;
|
||||||
const ddy = currentWorld.y - this._lastDragWorldY;
|
let ddy = currentWorld.y - this._lastDragWorldY;
|
||||||
this._lastDragWorldX = currentWorld.x;
|
this._lastDragWorldX = currentWorld.x;
|
||||||
this._lastDragWorldY = currentWorld.y;
|
this._lastDragWorldY = currentWorld.y;
|
||||||
|
|
||||||
// Move all selected items by delta and broadcast transforms
|
// Compute combined bounds of selected items after applying delta
|
||||||
const selected = this.getSelectedItems();
|
const selected = this.getSelectedItems();
|
||||||
|
const prospective = this._getSelectionBounds(selected);
|
||||||
|
prospective.x += ddx;
|
||||||
|
prospective.y += ddy;
|
||||||
|
|
||||||
|
// Snap to alignment guides
|
||||||
|
const snap = this._snapGuides.computeSnap(prospective, this._viewport);
|
||||||
|
ddx += snap.dx;
|
||||||
|
ddy += snap.dy;
|
||||||
|
this._snapGuides.drawGuides(snap.guides, this._viewport);
|
||||||
|
|
||||||
|
// Move all selected items by corrected delta and broadcast transforms
|
||||||
for (const item of selected) {
|
for (const item of selected) {
|
||||||
item.displayObject.x += ddx;
|
item.displayObject.x += ddx;
|
||||||
item.displayObject.y += ddy;
|
item.displayObject.y += ddy;
|
||||||
@@ -274,6 +295,7 @@ export class SelectionManager {
|
|||||||
if (this._objectDragging) {
|
if (this._objectDragging) {
|
||||||
// End object drag — drop shadow + spring scale back
|
// End object drag — drop shadow + spring scale back
|
||||||
this._applyDrop();
|
this._applyDrop();
|
||||||
|
this._snapGuides.endSession();
|
||||||
this._objectDragging = false;
|
this._objectDragging = false;
|
||||||
|
|
||||||
// Resume viewport drag
|
// Resume viewport drag
|
||||||
@@ -390,6 +412,26 @@ export class SelectionManager {
|
|||||||
this._emitChange();
|
this._emitChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Selection Bounds Helper -----------------------------------------------
|
||||||
|
|
||||||
|
/** Compute the combined world bounding rect of the given items. */
|
||||||
|
private _getSelectionBounds(items: SceneItem[]): { x: number; y: number; w: number; h: number } {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const { x, y, w, h } = getItemWorldBounds(item);
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (x + w > maxX) maxX = x + w;
|
||||||
|
if (y + h > maxY) maxY = y + h;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||||
|
}
|
||||||
|
|
||||||
// -- Change Notification --------------------------------------------------
|
// -- Change Notification --------------------------------------------------
|
||||||
|
|
||||||
private _emitChange(): void {
|
private _emitChange(): void {
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* SnapGuides — alignment snap guides for drag/resize operations (like Figma).
|
||||||
|
*
|
||||||
|
* Shows thin magenta guide lines when edges or centers of dragged items
|
||||||
|
* align with other items on the canvas, and returns snap corrections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Container, Graphics } from 'pixi.js';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
import { SceneManager, getItemWorldBounds } from './SceneManager';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SNAP_THRESHOLD = 4; // pixels in screen space (subtle, not aggressive)
|
||||||
|
const GUIDE_COLOR = 0xff4081;
|
||||||
|
const GUIDE_PADDING = 20; // world-space extension beyond bounds
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SnapEdge {
|
||||||
|
axis: 'x' | 'y';
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapResult {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
guides: SnapEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SnapGuides
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class SnapGuides {
|
||||||
|
private _scene: SceneManager;
|
||||||
|
private _gfx: Graphics;
|
||||||
|
|
||||||
|
/** Cached candidate edges from non-selected items. */
|
||||||
|
private _candidatesX: { value: number; min: number; max: number }[] = [];
|
||||||
|
private _candidatesY: { value: number; min: number; max: number }[] = [];
|
||||||
|
|
||||||
|
constructor(scene: SceneManager, parent: Container) {
|
||||||
|
this._scene = scene;
|
||||||
|
this._gfx = new Graphics();
|
||||||
|
this._gfx.label = '__snap_guides';
|
||||||
|
parent.addChild(this._gfx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Session management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Cache candidate edges from all visible, unlocked, non-selected top-level items. */
|
||||||
|
beginSession(excludeIds: Set<string>): void {
|
||||||
|
this._candidatesX = [];
|
||||||
|
this._candidatesY = [];
|
||||||
|
|
||||||
|
for (const item of this._scene.getTopLevelItems()) {
|
||||||
|
if (excludeIds.has(item.id)) continue;
|
||||||
|
if (item.data.locked || !item.data.visible) continue;
|
||||||
|
|
||||||
|
const { x, y, w, h } = getItemWorldBounds(item);
|
||||||
|
const right = x + w;
|
||||||
|
const bottom = y + h;
|
||||||
|
const cx = x + w / 2;
|
||||||
|
const cy = y + h / 2;
|
||||||
|
|
||||||
|
// X-axis edges: left, center, right
|
||||||
|
this._candidatesX.push({ value: x, min: y, max: bottom });
|
||||||
|
this._candidatesX.push({ value: cx, min: y, max: bottom });
|
||||||
|
this._candidatesX.push({ value: right, min: y, max: bottom });
|
||||||
|
|
||||||
|
// Y-axis edges: top, center, bottom
|
||||||
|
this._candidatesY.push({ value: y, min: x, max: right });
|
||||||
|
this._candidatesY.push({ value: cy, min: x, max: right });
|
||||||
|
this._candidatesY.push({ value: bottom, min: x, max: right });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear cached edges and guide lines. */
|
||||||
|
endSession(): void {
|
||||||
|
this._candidatesX = [];
|
||||||
|
this._candidatesY = [];
|
||||||
|
this._gfx.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Snap computation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare 6 edges of the selection bounds against cached candidates.
|
||||||
|
* Returns correction deltas and guide lines to draw.
|
||||||
|
*/
|
||||||
|
computeSnap(
|
||||||
|
bounds: { x: number; y: number; w: number; h: number },
|
||||||
|
viewport: Viewport,
|
||||||
|
): SnapResult {
|
||||||
|
const scale = viewport.scale.x;
|
||||||
|
const threshold = SNAP_THRESHOLD / scale;
|
||||||
|
|
||||||
|
const { x, y, w, h } = bounds;
|
||||||
|
const right = x + w;
|
||||||
|
const bottom = y + h;
|
||||||
|
const cx = x + w / 2;
|
||||||
|
const cy = y + h / 2;
|
||||||
|
|
||||||
|
// Selection edges for each axis
|
||||||
|
const selEdgesX = [x, cx, right];
|
||||||
|
const selEdgesY = [y, cy, bottom];
|
||||||
|
|
||||||
|
// Selection extent ranges (for guide line extension)
|
||||||
|
const selMinY = y;
|
||||||
|
const selMaxY = bottom;
|
||||||
|
const selMinX = x;
|
||||||
|
const selMaxX = right;
|
||||||
|
|
||||||
|
let bestDx = Infinity;
|
||||||
|
let bestGuideX: SnapEdge | null = null;
|
||||||
|
|
||||||
|
// Find closest X match
|
||||||
|
for (const selVal of selEdgesX) {
|
||||||
|
for (const cand of this._candidatesX) {
|
||||||
|
const diff = cand.value - selVal;
|
||||||
|
if (Math.abs(diff) < Math.abs(bestDx)) {
|
||||||
|
bestDx = diff;
|
||||||
|
const gMin = Math.min(selMinY, cand.min) - GUIDE_PADDING;
|
||||||
|
const gMax = Math.max(selMaxY, cand.max) + GUIDE_PADDING;
|
||||||
|
bestGuideX = { axis: 'x', value: cand.value, min: gMin, max: gMax };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestDy = Infinity;
|
||||||
|
let bestGuideY: SnapEdge | null = null;
|
||||||
|
|
||||||
|
// Find closest Y match
|
||||||
|
for (const selVal of selEdgesY) {
|
||||||
|
for (const cand of this._candidatesY) {
|
||||||
|
const diff = cand.value - selVal;
|
||||||
|
if (Math.abs(diff) < Math.abs(bestDy)) {
|
||||||
|
bestDy = diff;
|
||||||
|
const gMin = Math.min(selMinX, cand.min) - GUIDE_PADDING;
|
||||||
|
const gMax = Math.max(selMaxX, cand.max) + GUIDE_PADDING;
|
||||||
|
bestGuideY = { axis: 'y', value: cand.value, min: gMin, max: gMax };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const guides: SnapEdge[] = [];
|
||||||
|
const dx = Math.abs(bestDx) <= threshold && bestGuideX ? bestDx : 0;
|
||||||
|
const dy = Math.abs(bestDy) <= threshold && bestGuideY ? bestDy : 0;
|
||||||
|
|
||||||
|
if (dx !== 0 && bestGuideX) guides.push(bestGuideX);
|
||||||
|
if (dy !== 0 && bestGuideY) guides.push(bestGuideY);
|
||||||
|
|
||||||
|
return { dx, dy, guides };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Drawing
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Draw guide lines; line width compensates for viewport zoom. */
|
||||||
|
drawGuides(guides: SnapEdge[], viewport: Viewport): void {
|
||||||
|
this._gfx.clear();
|
||||||
|
|
||||||
|
if (guides.length === 0) return;
|
||||||
|
|
||||||
|
const lineWidth = 1 / viewport.scale.x;
|
||||||
|
|
||||||
|
for (const g of guides) {
|
||||||
|
this._gfx.moveTo(
|
||||||
|
g.axis === 'x' ? g.value : g.min,
|
||||||
|
g.axis === 'x' ? g.min : g.value,
|
||||||
|
);
|
||||||
|
this._gfx.lineTo(
|
||||||
|
g.axis === 'x' ? g.value : g.max,
|
||||||
|
g.axis === 'x' ? g.max : g.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._gfx.stroke({ color: GUIDE_COLOR, width: lineWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,67 +1,42 @@
|
|||||||
import { Texture, Assets } from "pixi.js";
|
import { Texture, Assets } from "pixi.js";
|
||||||
|
|
||||||
export type LODTier = "thumb" | "medium" | "full";
|
|
||||||
|
|
||||||
interface TextureEntry {
|
interface TextureEntry {
|
||||||
texture: Texture;
|
texture: Texture;
|
||||||
tier: LODTier;
|
|
||||||
lastUsed: number;
|
lastUsed: number;
|
||||||
memoryEstimate: number;
|
memoryEstimate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TextureManager — GPU texture cache with LRU eviction and memory budget.
|
||||||
|
* No LOD tiers — PixiJS GPU handles scaling natively.
|
||||||
|
* Just loads the full-res image and caches it.
|
||||||
|
*/
|
||||||
export class TextureManager {
|
export class TextureManager {
|
||||||
private cache = new Map<string, TextureEntry>();
|
private cache = new Map<string, TextureEntry>();
|
||||||
private budget = 512 * 1024 * 1024; // 512 MB
|
private budget = 512 * 1024 * 1024; // 512 MB
|
||||||
private currentUsage = 0;
|
private currentUsage = 0;
|
||||||
|
|
||||||
/** Map zoom level to the appropriate LOD tier. */
|
/** Build the URL for a given asset. */
|
||||||
tierForZoom(zoom: number): LODTier {
|
urlForAsset(assetKey: string): string {
|
||||||
if (zoom < 0.3) return "thumb";
|
|
||||||
if (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/')) {
|
if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) {
|
||||||
return assetKey;
|
return assetKey;
|
||||||
}
|
}
|
||||||
return `/api/images/${assetKey}`;
|
return `/api/images/${assetKey}`;
|
||||||
}
|
}
|
||||||
// New LOD format: boards/{boardId}/{imageId}/thumb.webp
|
|
||||||
return `/api/images/${assetKey}/${tier}.webp`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a texture for the given asset and LOD tier.
|
* Load a texture for the given asset.
|
||||||
* For legacy assets (pre-migration), loads the original file directly —
|
* Returns cached texture if available, otherwise fetches and caches.
|
||||||
* the GPU handles downscaling naturally.
|
* GPU handles all scaling — no LOD tiers needed.
|
||||||
* For new assets, loads the appropriate LOD tier with fallback.
|
|
||||||
*/
|
*/
|
||||||
async load(assetKey: string, tier: LODTier): Promise<Texture> {
|
async load(assetKey: string): Promise<Texture> {
|
||||||
// For legacy assets, all tiers resolve to the same URL
|
const existing = this.cache.get(assetKey);
|
||||||
const effectiveTier = this._isLegacyAsset(assetKey) ? 'full' : tier;
|
|
||||||
const key = `${assetKey}:${effectiveTier}`;
|
|
||||||
|
|
||||||
const existing = this.cache.get(key);
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.lastUsed = performance.now();
|
existing.lastUsed = performance.now();
|
||||||
return existing.texture;
|
return existing.texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.urlForAsset(assetKey, effectiveTier);
|
const url = this.urlForAsset(assetKey);
|
||||||
let texture: Texture;
|
let texture: Texture;
|
||||||
try {
|
try {
|
||||||
texture = await Assets.load(url);
|
texture = await Assets.load(url);
|
||||||
@@ -76,9 +51,8 @@ export class TextureManager {
|
|||||||
|
|
||||||
this.currentUsage += memoryEstimate;
|
this.currentUsage += memoryEstimate;
|
||||||
|
|
||||||
this.cache.set(key, {
|
this.cache.set(assetKey, {
|
||||||
texture,
|
texture,
|
||||||
tier,
|
|
||||||
lastUsed: performance.now(),
|
lastUsed: performance.now(),
|
||||||
memoryEstimate,
|
memoryEstimate,
|
||||||
});
|
});
|
||||||
@@ -91,14 +65,13 @@ export class TextureManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a specific texture from the cache and destroy it. */
|
/** Remove a specific texture from the cache and destroy it. */
|
||||||
unload(assetKey: string, tier: LODTier): void {
|
unload(assetKey: string): void {
|
||||||
const key = `${assetKey}:${tier}`;
|
const entry = this.cache.get(assetKey);
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
this.currentUsage -= entry.memoryEstimate;
|
this.currentUsage -= entry.memoryEstimate;
|
||||||
entry.texture.destroy(true);
|
entry.texture.destroy(true);
|
||||||
this.cache.delete(key);
|
this.cache.delete(assetKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Evict the least-recently-used cache entry. */
|
/** Evict the least-recently-used cache entry. */
|
||||||
|
|||||||
+193
-107
@@ -6,9 +6,10 @@
|
|||||||
* Each handle is draggable and applies scale/rotation transforms to the items.
|
* Each handle is draggable and applies scale/rotation transforms to the items.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pixi.js';
|
||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import type { SceneItem } from './SceneManager';
|
import { type SceneItem, getItemWorldBounds } from './SceneManager';
|
||||||
|
import type { SnapGuides } from './SnapGuides';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -19,11 +20,8 @@ const BORDER_WIDTH = 1.5;
|
|||||||
const HANDLE_SIZE = 8;
|
const HANDLE_SIZE = 8;
|
||||||
const HANDLE_FILL = 0xffffff;
|
const HANDLE_FILL = 0xffffff;
|
||||||
const HANDLE_STROKE = 0x4a90d9;
|
const HANDLE_STROKE = 0x4a90d9;
|
||||||
const ROTATE_COLOR = 0x55aa55;
|
|
||||||
const ROTATE_OFFSET = 25;
|
|
||||||
const ROTATE_RADIUS = 5;
|
|
||||||
|
|
||||||
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br' | 'rot';
|
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
|
||||||
|
|
||||||
const HANDLE_CURSORS: Record<HandleId, string> = {
|
const HANDLE_CURSORS: Record<HandleId, string> = {
|
||||||
tl: 'nwse-resize',
|
tl: 'nwse-resize',
|
||||||
@@ -34,7 +32,6 @@ const HANDLE_CURSORS: Record<HandleId, string> = {
|
|||||||
bc: 'ns-resize',
|
bc: 'ns-resize',
|
||||||
ml: 'ew-resize',
|
ml: 'ew-resize',
|
||||||
mr: 'ew-resize',
|
mr: 'ew-resize',
|
||||||
rot: 'grab',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -45,6 +42,7 @@ interface DragState {
|
|||||||
handleId: HandleId;
|
handleId: HandleId;
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
|
origBounds: { x: number; y: number; w: number; h: number };
|
||||||
origTransforms: Map<string, { sx: number; sy: number; angle: number; x: number; y: number }>;
|
origTransforms: Map<string, { sx: number; sy: number; angle: number; x: number; y: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +53,22 @@ interface DragState {
|
|||||||
export class TransformBox extends Container {
|
export class TransformBox extends Container {
|
||||||
private _border: Graphics;
|
private _border: Graphics;
|
||||||
private _handles: Map<HandleId, Graphics> = new Map();
|
private _handles: Map<HandleId, Graphics> = new Map();
|
||||||
private _rotateLine: Graphics;
|
|
||||||
private _items: SceneItem[] = [];
|
private _items: SceneItem[] = [];
|
||||||
private _bounds = { x: 0, y: 0, w: 0, h: 0 };
|
private _bounds = { x: 0, y: 0, w: 0, h: 0 };
|
||||||
private _drag: DragState | null = null;
|
private _drag: DragState | null = null;
|
||||||
private _viewport: Viewport | null = null;
|
private _viewport: Viewport | null = null;
|
||||||
|
private _onItemTransform: ((item: SceneItem) => void) | null = null;
|
||||||
|
private _snapGuides: SnapGuides | null = null;
|
||||||
|
private _dimLabel!: Text;
|
||||||
|
private _dimLabelBg!: Graphics;
|
||||||
|
|
||||||
|
set onItemTransform(fn: (item: SceneItem) => void) {
|
||||||
|
this._onItemTransform = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapGuides(sg: SnapGuides): void {
|
||||||
|
this._snapGuides = sg;
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -70,12 +79,29 @@ export class TransformBox extends Container {
|
|||||||
this._border = new Graphics();
|
this._border = new Graphics();
|
||||||
this.addChild(this._border);
|
this.addChild(this._border);
|
||||||
|
|
||||||
// Rotate stem line
|
|
||||||
this._rotateLine = new Graphics();
|
// Dimension label (shown during resize)
|
||||||
this.addChild(this._rotateLine);
|
this._dimLabel = new Text({
|
||||||
|
text: '',
|
||||||
|
style: new TextStyle({
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
fill: '#ffffff',
|
||||||
|
fontWeight: '500',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
this._dimLabel.visible = false;
|
||||||
|
this._dimLabel.label = '__dim_label';
|
||||||
|
|
||||||
|
this._dimLabelBg = new Graphics();
|
||||||
|
this._dimLabelBg.visible = false;
|
||||||
|
this._dimLabelBg.label = '__dim_label_bg';
|
||||||
|
|
||||||
|
this.addChild(this._dimLabelBg);
|
||||||
|
this.addChild(this._dimLabel);
|
||||||
|
|
||||||
// Create all handles
|
// Create all handles
|
||||||
const ids: HandleId[] = ['tl', 'tc', 'tr', 'ml', 'mr', 'bl', 'bc', 'br', 'rot'];
|
const ids: HandleId[] = ['tl', 'tc', 'tr', 'ml', 'mr', 'bl', 'bc', 'br'];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const handle = new Graphics();
|
const handle = new Graphics();
|
||||||
handle.eventMode = 'static';
|
handle.eventMode = 'static';
|
||||||
@@ -103,23 +129,25 @@ export class TransformBox extends Container {
|
|||||||
update(items: SceneItem[]): void {
|
update(items: SceneItem[]): void {
|
||||||
this._items = items;
|
this._items = items;
|
||||||
|
|
||||||
|
// Hide dimension label when not actively dragging
|
||||||
|
if (!this._drag) {
|
||||||
|
this._dimLabel.visible = false;
|
||||||
|
this._dimLabelBg.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute combined bounding rect in WORLD space using item data
|
// Compute combined bounding rect via canonical getItemWorldBounds()
|
||||||
// (not getBounds() which returns screen-space and causes offset)
|
|
||||||
let minX = Infinity;
|
let minX = Infinity;
|
||||||
let minY = Infinity;
|
let minY = Infinity;
|
||||||
let maxX = -Infinity;
|
let maxX = -Infinity;
|
||||||
let maxY = -Infinity;
|
let maxY = -Infinity;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const ix = item.data.x;
|
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||||
const iy = item.data.y;
|
|
||||||
const iw = item.data.w * Math.abs(item.data.sx);
|
|
||||||
const ih = item.data.h * Math.abs(item.data.sy);
|
|
||||||
if (ix < minX) minX = ix;
|
if (ix < minX) minX = ix;
|
||||||
if (iy < minY) minY = iy;
|
if (iy < minY) minY = iy;
|
||||||
if (ix + iw > maxX) maxX = ix + iw;
|
if (ix + iw > maxX) maxX = ix + iw;
|
||||||
@@ -141,12 +169,6 @@ export class TransformBox extends Container {
|
|||||||
this._border.rect(x, y, w, h);
|
this._border.rect(x, y, w, h);
|
||||||
this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH });
|
this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH });
|
||||||
|
|
||||||
// Rotate line (from top-center up to rotate handle)
|
|
||||||
this._rotateLine.clear();
|
|
||||||
this._rotateLine.moveTo(x + w / 2, y);
|
|
||||||
this._rotateLine.lineTo(x + w / 2, y - ROTATE_OFFSET);
|
|
||||||
this._rotateLine.stroke({ color: BORDER_COLOR, width: 1 });
|
|
||||||
|
|
||||||
// Position handles
|
// Position handles
|
||||||
const cx = x + w / 2;
|
const cx = x + w / 2;
|
||||||
const cy = y + h / 2;
|
const cy = y + h / 2;
|
||||||
@@ -160,25 +182,16 @@ export class TransformBox extends Container {
|
|||||||
bl: { px: x, py: y + h },
|
bl: { px: x, py: y + h },
|
||||||
bc: { px: cx, py: y + h },
|
bc: { px: cx, py: y + h },
|
||||||
br: { px: x + w, py: y + h },
|
br: { px: x + w, py: y + h },
|
||||||
rot: { px: cx, py: y - ROTATE_OFFSET },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [id, handle] of this._handles) {
|
for (const [id, handle] of this._handles) {
|
||||||
const pos = positions[id];
|
const pos = positions[id];
|
||||||
handle.clear();
|
handle.clear();
|
||||||
|
|
||||||
if (id === 'rot') {
|
|
||||||
// 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;
|
const half = HANDLE_SIZE / 2;
|
||||||
handle.rect(-half, -half, HANDLE_SIZE, HANDLE_SIZE);
|
handle.rect(-half, -half, HANDLE_SIZE, HANDLE_SIZE);
|
||||||
handle.fill(HANDLE_FILL);
|
handle.fill(HANDLE_FILL);
|
||||||
handle.stroke({ color: HANDLE_STROKE, width: 1 });
|
handle.stroke({ color: HANDLE_STROKE, width: 1 });
|
||||||
}
|
|
||||||
|
|
||||||
handle.position.set(pos.px, pos.py);
|
handle.position.set(pos.px, pos.py);
|
||||||
}
|
}
|
||||||
@@ -204,108 +217,181 @@ export class TransformBox extends Container {
|
|||||||
handleId: id,
|
handleId: id,
|
||||||
startX: e.global.x,
|
startX: e.global.x,
|
||||||
startY: e.global.y,
|
startY: e.global.y,
|
||||||
|
origBounds: { ...this._bounds },
|
||||||
origTransforms,
|
origTransforms,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Begin snap session excluding current items
|
||||||
|
const itemIds = new Set(this._items.map((it) => it.id));
|
||||||
|
this._snapGuides?.beginSession(itemIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onHandleMove(e: FederatedPointerEvent): void {
|
private _onHandleMove(e: FederatedPointerEvent): void {
|
||||||
if (!this._drag) return;
|
if (!this._drag) return;
|
||||||
|
|
||||||
// Convert screen-space deltas to world-space by dividing by viewport zoom
|
// Convert mouse position to world space
|
||||||
const zoom = this._viewport?.scale.x ?? 1;
|
const world = this._viewport!.toWorld(e.global.x, e.global.y);
|
||||||
const dx = (e.global.x - this._drag.startX) / zoom;
|
const { handleId, origBounds: ob, origTransforms } = this._drag;
|
||||||
const dy = (e.global.y - this._drag.startY) / zoom;
|
|
||||||
const { handleId, origTransforms } = this._drag;
|
|
||||||
|
|
||||||
// Use bounding box size as reference for scale sensitivity
|
// Compute scale factors: new size / original size
|
||||||
const { w: bw, h: bh } = this._bounds;
|
// Each handle has a fixed edge — the opposite side stays put
|
||||||
const refSize = Math.max(bw, bh, 100); // avoid division by tiny numbers
|
const MIN = 0.05;
|
||||||
|
let fx = 1; // horizontal scale multiplier
|
||||||
|
let fy = 1; // vertical scale multiplier
|
||||||
|
|
||||||
|
switch (handleId) {
|
||||||
|
// --- Corner handles (proportional) ---
|
||||||
|
case 'br': {
|
||||||
|
// Fixed edge: top-left. New size = mouse - top-left.
|
||||||
|
fx = Math.max(MIN, (world.x - ob.x) / ob.w);
|
||||||
|
fy = Math.max(MIN, (world.y - ob.y) / ob.h);
|
||||||
|
// Proportional: use average
|
||||||
|
const f = (fx + fy) / 2;
|
||||||
|
fx = f; fy = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tl': {
|
||||||
|
// Fixed edge: bottom-right
|
||||||
|
const right = ob.x + ob.w;
|
||||||
|
const bottom = ob.y + ob.h;
|
||||||
|
fx = Math.max(MIN, (right - world.x) / ob.w);
|
||||||
|
fy = Math.max(MIN, (bottom - world.y) / ob.h);
|
||||||
|
const f = (fx + fy) / 2;
|
||||||
|
fx = f; fy = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tr': {
|
||||||
|
// Fixed edge: bottom-left
|
||||||
|
const bottom = ob.y + ob.h;
|
||||||
|
fx = Math.max(MIN, (world.x - ob.x) / ob.w);
|
||||||
|
fy = Math.max(MIN, (bottom - world.y) / ob.h);
|
||||||
|
const f = (fx + fy) / 2;
|
||||||
|
fx = f; fy = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'bl': {
|
||||||
|
// Fixed edge: top-right
|
||||||
|
const right = ob.x + ob.w;
|
||||||
|
fx = Math.max(MIN, (right - world.x) / ob.w);
|
||||||
|
fy = Math.max(MIN, (world.y - ob.y) / ob.h);
|
||||||
|
const f = (fx + fy) / 2;
|
||||||
|
fx = f; fy = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// --- Edge handles (proportional by default, hold Shift for free-form) ---
|
||||||
|
case 'mr': {
|
||||||
|
fx = Math.max(MIN, (world.x - ob.x) / ob.w);
|
||||||
|
if (!e.shiftKey) fy = fx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ml': {
|
||||||
|
const right = ob.x + ob.w;
|
||||||
|
fx = Math.max(MIN, (right - world.x) / ob.w);
|
||||||
|
if (!e.shiftKey) fy = fx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'bc': {
|
||||||
|
fy = Math.max(MIN, (world.y - ob.y) / ob.h);
|
||||||
|
if (!e.shiftKey) fx = fy;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tc': {
|
||||||
|
const bottom = ob.y + ob.h;
|
||||||
|
fy = Math.max(MIN, (bottom - world.y) / ob.h);
|
||||||
|
if (!e.shiftKey) fx = fy;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of this._items) {
|
for (const item of this._items) {
|
||||||
const orig = origTransforms.get(item.id);
|
const orig = origTransforms.get(item.id);
|
||||||
if (!orig) continue;
|
if (!orig) continue;
|
||||||
|
|
||||||
|
// Apply scale factors relative to original
|
||||||
|
item.data.sx = orig.sx * fx;
|
||||||
|
item.data.sy = orig.sy * fy;
|
||||||
|
|
||||||
|
// Reposition to keep fixed edge in place
|
||||||
switch (handleId) {
|
switch (handleId) {
|
||||||
case 'br': {
|
case 'tl':
|
||||||
// Proportional scale — drag distance relative to object size
|
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
|
||||||
const factor = 1 + (dx + dy) / refSize;
|
|
||||||
const clampedFactor = Math.max(0.05, factor);
|
|
||||||
item.data.sx = orig.sx * clampedFactor;
|
|
||||||
item.data.sy = orig.sy * clampedFactor;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'mr': {
|
|
||||||
const factor = 1 + dx / (bw || refSize);
|
|
||||||
item.data.sx = orig.sx * Math.max(0.05, factor);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'bc': {
|
|
||||||
const factor = 1 + dy / (bh || refSize);
|
|
||||||
item.data.sy = orig.sy * Math.max(0.05, factor);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'tl': {
|
|
||||||
// Proportional scale + reposition (bottom-right stays fixed)
|
|
||||||
const factor = 1 - (dx + dy) / refSize;
|
|
||||||
const clampedFactor = Math.max(0.05, factor);
|
|
||||||
item.data.sx = orig.sx * clampedFactor;
|
|
||||||
item.data.sy = orig.sy * clampedFactor;
|
|
||||||
const dw = (item.data.sx - orig.sx) * item.data.w;
|
|
||||||
const dh = (item.data.sy - orig.sy) * item.data.h;
|
|
||||||
item.data.x = orig.x - dw;
|
|
||||||
item.data.y = orig.y - dh;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'rot': {
|
|
||||||
// Rotation: 1 world pixel = ~0.3 degrees
|
|
||||||
item.data.angle = orig.angle + dx * 0.3;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'tr': {
|
|
||||||
const factor = 1 + (dx - dy) / refSize;
|
|
||||||
const clampedFactor = Math.max(0.05, factor);
|
|
||||||
item.data.sx = orig.sx * clampedFactor;
|
|
||||||
item.data.sy = orig.sy * clampedFactor;
|
|
||||||
// Top-right: left edge stays fixed
|
|
||||||
item.data.y = orig.y - (item.data.sy - orig.sy) * item.data.h;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'bl': {
|
|
||||||
const factor = 1 + (-dx + dy) / refSize;
|
|
||||||
const clampedFactor = Math.max(0.05, factor);
|
|
||||||
item.data.sx = orig.sx * clampedFactor;
|
|
||||||
item.data.sy = orig.sy * clampedFactor;
|
|
||||||
// Bottom-left: right edge stays fixed
|
|
||||||
item.data.x = orig.x - (item.data.sx - orig.sx) * item.data.w;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'tc': {
|
|
||||||
const factor = 1 - dy / (bh || refSize);
|
|
||||||
item.data.sy = orig.sy * Math.max(0.05, factor);
|
|
||||||
// Top-center: bottom edge stays fixed
|
|
||||||
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
|
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
|
||||||
break;
|
break;
|
||||||
}
|
case 'tc':
|
||||||
case 'ml': {
|
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
|
||||||
const factor = 1 - dx / (bw || refSize);
|
break;
|
||||||
item.data.sx = orig.sx * Math.max(0.05, factor);
|
case 'tr':
|
||||||
// Middle-left: right edge stays fixed
|
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
|
||||||
|
break;
|
||||||
|
case 'ml':
|
||||||
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
|
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
|
||||||
break;
|
break;
|
||||||
}
|
case 'bl':
|
||||||
|
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
|
||||||
|
break;
|
||||||
|
// br, mr, bc: top-left is fixed, no reposition needed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply to display object
|
|
||||||
item.displayObject.scale.set(item.data.sx, item.data.sy);
|
item.displayObject.scale.set(item.data.sx, item.data.sy);
|
||||||
item.displayObject.angle = item.data.angle;
|
|
||||||
item.displayObject.position.set(item.data.x, item.data.y);
|
item.displayObject.position.set(item.data.x, item.data.y);
|
||||||
|
|
||||||
|
this._onItemTransform?.(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redraw transform box around new bounds
|
|
||||||
this.update(this._items);
|
this.update(this._items);
|
||||||
|
|
||||||
|
// Snap guides during resize
|
||||||
|
if (this._snapGuides && this._viewport) {
|
||||||
|
const snap = this._snapGuides.computeSnap(this._bounds, this._viewport);
|
||||||
|
if (snap.dx !== 0 || snap.dy !== 0) {
|
||||||
|
// Apply snap correction to all items
|
||||||
|
for (const item of this._items) {
|
||||||
|
item.data.x += snap.dx;
|
||||||
|
item.data.y += snap.dy;
|
||||||
|
item.displayObject.position.set(item.data.x, item.data.y);
|
||||||
|
this._onItemTransform?.(item);
|
||||||
|
}
|
||||||
|
this.update(this._items);
|
||||||
|
}
|
||||||
|
this._snapGuides.drawGuides(snap.guides, this._viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show dimension label
|
||||||
|
const bounds = this._bounds;
|
||||||
|
const zoom = this._viewport?.scale.x ?? 1;
|
||||||
|
const w = Math.round(bounds.w);
|
||||||
|
const h = Math.round(bounds.h);
|
||||||
|
this._dimLabel.text = `${w} \u00d7 ${h}`;
|
||||||
|
this._dimLabel.scale.set(1 / zoom); // Stay fixed screen size
|
||||||
|
|
||||||
|
// Position below bottom-right corner with offset
|
||||||
|
const labelX = bounds.x + bounds.w;
|
||||||
|
const labelY = bounds.y + bounds.h + 12 / zoom;
|
||||||
|
this._dimLabel.anchor.set(1, 0); // right-aligned
|
||||||
|
this._dimLabel.position.set(labelX, labelY);
|
||||||
|
|
||||||
|
// Background pill
|
||||||
|
const pad = 4 / zoom;
|
||||||
|
const textW = this._dimLabel.width;
|
||||||
|
const textH = this._dimLabel.height;
|
||||||
|
this._dimLabelBg.clear();
|
||||||
|
this._dimLabelBg.roundRect(
|
||||||
|
labelX - textW - pad,
|
||||||
|
labelY - pad / 2,
|
||||||
|
textW + pad * 2,
|
||||||
|
textH + pad,
|
||||||
|
3 / zoom,
|
||||||
|
);
|
||||||
|
this._dimLabelBg.fill({ color: 0x1a1a1a, alpha: 0.9 });
|
||||||
|
|
||||||
|
this._dimLabel.visible = true;
|
||||||
|
this._dimLabelBg.visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onHandleUp(): void {
|
private _onHandleUp(): void {
|
||||||
this._drag = null;
|
this._drag = null;
|
||||||
|
this._dimLabel.visible = false;
|
||||||
|
this._dimLabelBg.visible = false;
|
||||||
|
this._snapGuides?.endSession();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Clipboard module — copy canvas to system clipboard and paste images from it.
|
||||||
|
*
|
||||||
|
* Copy approach: render ONLY the selected items at native resolution using
|
||||||
|
* PixiJS generateTexture + extract. No viewport cropping — independent of
|
||||||
|
* zoom/pan state. Items are temporarily reparented into a clean container,
|
||||||
|
* rendered, then restored.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Container, Rectangle } from 'pixi.js';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
import type { Application } from 'pixi.js';
|
||||||
|
import type { SceneManager, SceneItem } from './SceneManager';
|
||||||
|
import { getItemWorldBounds } from './SceneManager';
|
||||||
|
import { uploadImage } from '../api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write selected items (or full viewport) to system clipboard as PNG.
|
||||||
|
* Selected items are rendered at their native size, not affected by zoom.
|
||||||
|
*/
|
||||||
|
export async function writeCanvasToClipboard(
|
||||||
|
app: Application | null,
|
||||||
|
viewport: Viewport | null,
|
||||||
|
items?: SceneItem[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!viewport) throw new Error('Viewport not available');
|
||||||
|
if (!app?.renderer?.extract) throw new Error('Renderer not available');
|
||||||
|
|
||||||
|
let outputCanvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
if (items && items.length > 0) {
|
||||||
|
// --- Render selected items at native resolution ---
|
||||||
|
|
||||||
|
// 1. Compute world-space bounding box
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const item of items) {
|
||||||
|
const { x, y, w, h } = getItemWorldBounds(item);
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x + w);
|
||||||
|
maxY = Math.max(maxY, y + h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = 10;
|
||||||
|
const totalW = Math.ceil(maxX - minX) + pad * 2;
|
||||||
|
const totalH = Math.ceil(maxY - minY) + pad * 2;
|
||||||
|
|
||||||
|
// 2. Save original parent/transform for each item, reparent into temp container
|
||||||
|
const tempContainer = new Container();
|
||||||
|
const saved = new Map<string, {
|
||||||
|
parent: Container;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
sx: number;
|
||||||
|
sy: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Pre-compute world bounds for each item (handles group children with local coords)
|
||||||
|
const worldBoundsMap = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||||
|
for (const item of items) {
|
||||||
|
worldBoundsMap.set(item.id, getItemWorldBounds(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const obj = item.displayObject;
|
||||||
|
saved.set(item.id, {
|
||||||
|
parent: obj.parent as Container,
|
||||||
|
x: obj.x,
|
||||||
|
y: obj.y,
|
||||||
|
sx: obj.scale.x,
|
||||||
|
sy: obj.scale.y,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from current parent
|
||||||
|
obj.parent?.removeChild(obj);
|
||||||
|
|
||||||
|
// Position using world bounds (correct for both top-level and group children)
|
||||||
|
const wb = worldBoundsMap.get(item.id)!;
|
||||||
|
obj.position.set(wb.x - minX + pad, wb.y - minY + pad);
|
||||||
|
// Use data scale (not viewport-affected scale)
|
||||||
|
obj.scale.set(item.data.sx, item.data.sy);
|
||||||
|
|
||||||
|
tempContainer.addChild(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generate texture at 1x resolution (native pixel size)
|
||||||
|
let texture;
|
||||||
|
let extractedCanvas: HTMLCanvasElement;
|
||||||
|
try {
|
||||||
|
texture = app.renderer.generateTexture({
|
||||||
|
target: tempContainer,
|
||||||
|
resolution: 1,
|
||||||
|
frame: new Rectangle(0, 0, totalW, totalH),
|
||||||
|
});
|
||||||
|
extractedCanvas = app.renderer.extract.canvas(texture) as HTMLCanvasElement;
|
||||||
|
} finally {
|
||||||
|
// 4. Restore items to their original parents and transforms (even on error)
|
||||||
|
for (const item of items) {
|
||||||
|
const s = saved.get(item.id)!;
|
||||||
|
tempContainer.removeChild(item.displayObject);
|
||||||
|
s.parent.addChild(item.displayObject);
|
||||||
|
item.displayObject.position.set(s.x, s.y);
|
||||||
|
item.displayObject.scale.set(s.sx, s.sy);
|
||||||
|
}
|
||||||
|
tempContainer.destroy();
|
||||||
|
texture?.destroy(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Add opaque background
|
||||||
|
outputCanvas = document.createElement('canvas');
|
||||||
|
outputCanvas.width = extractedCanvas.width;
|
||||||
|
outputCanvas.height = extractedCanvas.height;
|
||||||
|
const ctx2d = outputCanvas.getContext('2d')!;
|
||||||
|
ctx2d.fillStyle = '#1e1e1e';
|
||||||
|
ctx2d.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||||
|
ctx2d.drawImage(extractedCanvas, 0, 0);
|
||||||
|
} else {
|
||||||
|
// Full viewport screenshot
|
||||||
|
const extractedCanvas = app.renderer.extract.canvas(viewport) as HTMLCanvasElement;
|
||||||
|
outputCanvas = document.createElement('canvas');
|
||||||
|
outputCanvas.width = extractedCanvas.width;
|
||||||
|
outputCanvas.height = extractedCanvas.height;
|
||||||
|
const ctx2d = outputCanvas.getContext('2d')!;
|
||||||
|
ctx2d.fillStyle = '#1e1e1e';
|
||||||
|
ctx2d.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||||
|
ctx2d.drawImage(extractedCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
outputCanvas.toBlob((b) => {
|
||||||
|
if (b) resolve(b);
|
||||||
|
else reject(new Error('toBlob returned null'));
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ 'image/png': blob }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste an image from the system clipboard into the scene.
|
||||||
|
* Reads the clipboard, uploads the first image found, and adds it at the viewport center.
|
||||||
|
*/
|
||||||
|
export async function pasteFromSystemClipboard(
|
||||||
|
scene: SceneManager,
|
||||||
|
viewport: Viewport,
|
||||||
|
boardId: string,
|
||||||
|
onChange: () => void,
|
||||||
|
): Promise<string> {
|
||||||
|
const clipItems = await navigator.clipboard.read();
|
||||||
|
for (const clipItem of clipItems) {
|
||||||
|
for (const type of clipItem.types) {
|
||||||
|
if (!type.startsWith('image/')) continue;
|
||||||
|
const blob = await clipItem.getType(type);
|
||||||
|
const file = new File([blob], `paste.${type.split('/')[1]}`, { type });
|
||||||
|
const res = await uploadImage(boardId, file);
|
||||||
|
const imgData = res.data.image || res.data;
|
||||||
|
const assetKey = imgData.asset_key;
|
||||||
|
const w = imgData.width || 400;
|
||||||
|
const h = imgData.height || 300;
|
||||||
|
const maxDim = 600;
|
||||||
|
let fw = w, fh = h;
|
||||||
|
if (w > maxDim || h > maxDim) {
|
||||||
|
const s = maxDim / Math.max(w, h);
|
||||||
|
fw = Math.round(w * s);
|
||||||
|
fh = Math.round(h * s);
|
||||||
|
}
|
||||||
|
if (assetKey) {
|
||||||
|
const center = viewport.center;
|
||||||
|
scene.addImageFromUpload(assetKey, fw, fh, center.x - fw / 2, center.y - fh / 2);
|
||||||
|
onChange();
|
||||||
|
return 'Pasted image';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'No image in clipboard';
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Context menu builder — assembles menu items from scene/selection state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SceneManager, SceneItem } from './SceneManager';
|
||||||
|
import type { SelectionManager } from './SelectionManager';
|
||||||
|
import { getItemWorldBounds } from './SceneManager';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
import type { GroupObject } from './scene-format';
|
||||||
|
import { FrameSprite } from './sprites/FrameSprite';
|
||||||
|
import * as ops from './operations';
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
shortcut: string;
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
divider?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuContext {
|
||||||
|
scene: SceneManager | null;
|
||||||
|
selection: SelectionManager | null;
|
||||||
|
viewport: Viewport | null;
|
||||||
|
clipboardRef: React.MutableRefObject<SceneItem[]>;
|
||||||
|
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
|
||||||
|
onChange: () => void;
|
||||||
|
refreshLayers: () => void;
|
||||||
|
handleGroup: () => void;
|
||||||
|
handleUngroup: () => void;
|
||||||
|
fitAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
|
||||||
|
const { scene, selection, viewport } = ctx;
|
||||||
|
const selected = selection ? selection.getSelectedItems() : [];
|
||||||
|
const hasSel = selected.length > 0;
|
||||||
|
const multiSel = selected.length >= 2;
|
||||||
|
|
||||||
|
return [
|
||||||
|
// -- Clipboard --
|
||||||
|
{ label: 'Copy', shortcut: 'Ctrl+C', onClick: () => {
|
||||||
|
if (hasSel) { ctx.clipboardRef.current = [...selected]; ctx.writeCanvasToClipboard(selected); }
|
||||||
|
else ctx.writeCanvasToClipboard();
|
||||||
|
} },
|
||||||
|
{ label: 'Cut', shortcut: 'Ctrl+X', onClick: () => {
|
||||||
|
if (!scene || !hasSel || !selection) return;
|
||||||
|
ctx.clipboardRef.current = [...selected];
|
||||||
|
for (const item of selected) scene.removeItem(item.id, true);
|
||||||
|
selection.clear();
|
||||||
|
ctx.onChange();
|
||||||
|
}, disabled: !hasSel },
|
||||||
|
{ label: 'Paste', shortcut: 'Ctrl+V', onClick: async () => {
|
||||||
|
if (!scene || ctx.clipboardRef.current.length === 0) return;
|
||||||
|
const cloned = _cloneWithGroupSupport(ctx.clipboardRef.current, scene);
|
||||||
|
const sorted = [...cloned].sort((a, b) =>
|
||||||
|
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
|
||||||
|
for (const newData of sorted) {
|
||||||
|
await scene._createItem(newData, true);
|
||||||
|
}
|
||||||
|
scene._applyZOrder();
|
||||||
|
ctx.onChange();
|
||||||
|
}, disabled: ctx.clipboardRef.current.length === 0 },
|
||||||
|
{ label: 'Duplicate', shortcut: 'Ctrl+D', onClick: async () => {
|
||||||
|
if (!scene || !hasSel) return;
|
||||||
|
const allItems = _collectGroupChildren(selected, scene);
|
||||||
|
const cloned = _cloneWithGroupSupport(allItems, scene);
|
||||||
|
const sorted = [...cloned].sort((a, b) =>
|
||||||
|
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
|
||||||
|
for (const newData of sorted) {
|
||||||
|
await scene._createItem(newData, true);
|
||||||
|
}
|
||||||
|
if (selection) selection.clear();
|
||||||
|
scene._applyZOrder();
|
||||||
|
ctx.onChange();
|
||||||
|
}, disabled: !hasSel },
|
||||||
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
|
// -- Alignment --
|
||||||
|
{ label: 'Align Left', shortcut: 'Ctrl+\u2190', onClick: () => { ops.alignLeft(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Align Right', shortcut: 'Ctrl+\u2192', onClick: () => { ops.alignRight(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Align Top', shortcut: 'Ctrl+\u2191', onClick: () => { ops.alignTop(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Align Bottom', shortcut: 'Ctrl+\u2193', onClick: () => { ops.alignBottom(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Distribute H', shortcut: '', onClick: () => { ops.distributeHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: selected.length < 3 },
|
||||||
|
{ label: 'Distribute V', shortcut: '', onClick: () => { ops.distributeVertical(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: selected.length < 3 },
|
||||||
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
|
// -- Layer ordering --
|
||||||
|
{ label: 'Bring Forward', shortcut: ']', onClick: () => {
|
||||||
|
if (!scene) return;
|
||||||
|
const all = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
|
||||||
|
const selectedIds = new Set(selected.map((s) => s.id));
|
||||||
|
for (let i = all.length - 2; i >= 0; i--) {
|
||||||
|
if (selectedIds.has(all[i].id) && !selectedIds.has(all[i + 1].id)) {
|
||||||
|
const tmp = all[i].data.z;
|
||||||
|
all[i].data.z = all[i + 1].data.z;
|
||||||
|
all[i + 1].data.z = tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene._applyZOrder();
|
||||||
|
ctx.onChange();
|
||||||
|
}, disabled: !hasSel },
|
||||||
|
{ label: 'Send Backward', shortcut: '[', onClick: () => {
|
||||||
|
if (!scene) return;
|
||||||
|
const all = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
|
||||||
|
const selectedIds = new Set(selected.map((s) => s.id));
|
||||||
|
for (let i = 1; i < all.length; i++) {
|
||||||
|
if (selectedIds.has(all[i].id) && !selectedIds.has(all[i - 1].id)) {
|
||||||
|
const tmp = all[i].data.z;
|
||||||
|
all[i].data.z = all[i - 1].data.z;
|
||||||
|
all[i - 1].data.z = tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene._applyZOrder();
|
||||||
|
ctx.onChange();
|
||||||
|
}, disabled: !hasSel },
|
||||||
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
|
// -- Group --
|
||||||
|
{ label: 'Group', shortcut: 'Ctrl+G', onClick: ctx.handleGroup, disabled: !multiSel },
|
||||||
|
{ label: 'Ungroup', shortcut: 'Ctrl+Shift+G', onClick: ctx.handleUngroup,
|
||||||
|
disabled: selected.length !== 1 || selected[0]?.data.type !== 'group' },
|
||||||
|
// Frame color (for selected group/frame)
|
||||||
|
...(selected.length === 1 && selected[0]?.data.type === 'group' ? [
|
||||||
|
{ label: 'Frame: Blue', shortcut: '', onClick: () => _setFrameColor(selected[0], '#4a90d9', ctx) },
|
||||||
|
{ label: 'Frame: Green', shortcut: '', onClick: () => _setFrameColor(selected[0], '#69db7c', ctx) },
|
||||||
|
{ label: 'Frame: Red', shortcut: '', onClick: () => _setFrameColor(selected[0], '#ff6b6b', ctx) },
|
||||||
|
{ label: 'Frame: Purple', shortcut: '', onClick: () => _setFrameColor(selected[0], '#7950f2', ctx) },
|
||||||
|
{ label: 'Frame: Cyan', shortcut: '', onClick: () => _setFrameColor(selected[0], '#38d9a9', ctx) },
|
||||||
|
{ label: 'Frame: Orange', shortcut: '', onClick: () => _setFrameColor(selected[0], '#ffa94d', ctx) },
|
||||||
|
{ label: 'Frame: None', shortcut: '', onClick: () => _setFrameColor(selected[0], '', ctx) },
|
||||||
|
] as MenuItem[] : []),
|
||||||
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
|
// -- Arrangement --
|
||||||
|
{ label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => { ops.arrangeOptimal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Arrange Grid', shortcut: '', onClick: () => { ops.arrangeGrid(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Arrange Row', shortcut: '', onClick: () => { ops.arrangeRow(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Arrange Column', shortcut: '', onClick: () => { ops.arrangeColumn(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: () => { ops.stackObjects(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
|
// -- Normalize --
|
||||||
|
{ label: 'Normalize Size', shortcut: '', onClick: () => { ops.normalizeSize(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Normalize Width', shortcut: '', onClick: () => { ops.normalizeWidth(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: 'Normalize Height', shortcut: '', onClick: () => { ops.normalizeHeight(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||||
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
|
// -- Image --
|
||||||
|
{ label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => { ops.flipHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
|
||||||
|
{ label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => { ops.flipVertical(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
|
||||||
|
{ label: 'Reset Transform', shortcut: 'Ctrl+Shift+T', onClick: () => { ops.resetTransform(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
|
||||||
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
|
// -- View --
|
||||||
|
{ label: 'Select All', shortcut: 'Ctrl+A', onClick: () => { if (selection) selection.selectAll(); } },
|
||||||
|
{ label: 'Fit All', shortcut: 'Ctrl+0', onClick: ctx.fitAll },
|
||||||
|
{ label: 'Fit Selection', shortcut: 'F', onClick: () => {
|
||||||
|
if (!selection || !viewport) return;
|
||||||
|
const items = selection.getSelectedItems();
|
||||||
|
if (items.length === 0) return;
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const item of items) {
|
||||||
|
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||||
|
if (ix < minX) minX = ix;
|
||||||
|
if (iy < minY) minY = iy;
|
||||||
|
if (ix + iw > maxX) maxX = ix + iw;
|
||||||
|
if (iy + ih > maxY) maxY = iy + ih;
|
||||||
|
}
|
||||||
|
const pad = 60;
|
||||||
|
const cw = maxX - minX + pad * 2;
|
||||||
|
const ch = maxY - minY + pad * 2;
|
||||||
|
const s = Math.min(viewport.screenWidth / cw, viewport.screenHeight / ch, 5);
|
||||||
|
viewport.animate({ position: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }, scale: s, time: 300, ease: 'easeOutQuad' });
|
||||||
|
}, disabled: !hasSel },
|
||||||
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
|
// -- Delete --
|
||||||
|
{ label: 'Delete', shortcut: 'Del', onClick: () => {
|
||||||
|
if (!scene || !selection) return;
|
||||||
|
for (const item of selected) scene.removeItem(item.id, true);
|
||||||
|
selection.clear();
|
||||||
|
ctx.onChange();
|
||||||
|
}, disabled: !hasSel, danger: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect selected items + their group children (for deep clone). */
|
||||||
|
function _collectGroupChildren(items: SceneItem[], scene: SceneManager): SceneItem[] {
|
||||||
|
const all: SceneItem[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.id)) continue;
|
||||||
|
seen.add(item.id);
|
||||||
|
all.push(item);
|
||||||
|
if (item.data.type === 'group') {
|
||||||
|
const gd = item.data as GroupObject;
|
||||||
|
for (const cid of gd.children) {
|
||||||
|
if (seen.has(cid)) continue;
|
||||||
|
seen.add(cid);
|
||||||
|
const child = scene.getById(cid);
|
||||||
|
if (child) all.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clone items with new IDs, remapping group children references. */
|
||||||
|
function _cloneWithGroupSupport(items: SceneItem[], scene: SceneManager): any[] {
|
||||||
|
// Expand: include group children that aren't explicitly in the list
|
||||||
|
const expanded = _collectGroupChildren(items, scene);
|
||||||
|
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
for (const item of expanded) {
|
||||||
|
idMap.set(item.data.id, crypto.randomUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
const clones: any[] = [];
|
||||||
|
for (const item of expanded) {
|
||||||
|
const newId = idMap.get(item.data.id)!;
|
||||||
|
const newData = {
|
||||||
|
...item.data,
|
||||||
|
id: newId,
|
||||||
|
x: item.data.x + 20,
|
||||||
|
y: item.data.y + 20,
|
||||||
|
z: scene.nextZ(),
|
||||||
|
};
|
||||||
|
if (newData.type === 'group' && Array.isArray(newData.children)) {
|
||||||
|
newData.children = newData.children
|
||||||
|
.map((cid: string) => idMap.get(cid))
|
||||||
|
.filter((c): c is string => !!c);
|
||||||
|
}
|
||||||
|
clones.push(newData);
|
||||||
|
}
|
||||||
|
return clones;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the background color of a group/frame. */
|
||||||
|
function _setFrameColor(item: SceneItem, color: string, ctx: MenuContext): void {
|
||||||
|
const gd = item.data as GroupObject;
|
||||||
|
gd.bgColor = color;
|
||||||
|
if (item.displayObject instanceof FrameSprite) {
|
||||||
|
item.displayObject.setBgColor(color);
|
||||||
|
}
|
||||||
|
ctx.onChange();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
import { Graphics } from 'pixi.js';
|
||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import type { SceneManager } from './SceneManager';
|
import type { SceneManager } from './SceneManager';
|
||||||
import { VideoSprite } from './sprites/VideoSprite';
|
|
||||||
import { uploadImage, uploadImageFromUrl } from '../api';
|
import { uploadImage, uploadImageFromUrl } from '../api';
|
||||||
|
|
||||||
type OnChange = () => void;
|
type OnChange = () => void;
|
||||||
@@ -55,10 +54,7 @@ function handleUploadResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mediaType === 'video' && assetKey) {
|
if (mediaType === 'video' && assetKey) {
|
||||||
const url = imgData.public_url;
|
sceneManager.addVideoFromUpload(assetKey, finalW, finalH, x, y);
|
||||||
const video = new VideoSprite(assetKey, finalW, finalH, url);
|
|
||||||
video.position.set(x, y);
|
|
||||||
viewport.addChild(video);
|
|
||||||
} else if (assetKey) {
|
} else if (assetKey) {
|
||||||
sceneManager.addImageFromUpload(assetKey, finalW, finalH, x, y);
|
sceneManager.addImageFromUpload(assetKey, finalW, finalH, x, y);
|
||||||
} else {
|
} else {
|
||||||
@@ -122,18 +118,23 @@ export function setupDragDrop(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dropIndex = 0;
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) continue;
|
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) continue;
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
const placeholder = createPlaceholder(viewport, world.x, world.y);
|
// Offset each subsequent file so they don't overlap
|
||||||
|
const offsetX = dropIndex * 30;
|
||||||
|
const offsetY = dropIndex * 30;
|
||||||
|
const placeholder = createPlaceholder(viewport, world.x + offsetX, world.y + offsetY);
|
||||||
|
dropIndex++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await uploadImage(boardId, file);
|
const res = await uploadImage(boardId, file);
|
||||||
removePlaceholder(viewport, placeholder);
|
removePlaceholder(viewport, placeholder);
|
||||||
handleUploadResult(res, viewport, sceneManager, world.x, world.y, onChange);
|
handleUploadResult(res, viewport, sceneManager, world.x + offsetX, world.y + offsetY, onChange);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Image upload failed:', err);
|
console.error('Image upload failed:', err);
|
||||||
removePlaceholder(viewport, placeholder);
|
removePlaceholder(viewport, placeholder);
|
||||||
@@ -141,10 +142,22 @@ export function setupDragDrop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent browser default (opening file in new tab) on the whole document
|
||||||
|
function onDocDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
function onDocDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('dragover', onDocDragOver);
|
||||||
|
document.addEventListener('drop', onDocDrop);
|
||||||
container.addEventListener('dragover', onDragOver);
|
container.addEventListener('dragover', onDragOver);
|
||||||
container.addEventListener('drop', onDrop);
|
container.addEventListener('drop', onDrop);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('dragover', onDocDragOver);
|
||||||
|
document.removeEventListener('drop', onDocDrop);
|
||||||
container.removeEventListener('dragover', onDragOver);
|
container.removeEventListener('dragover', onDragOver);
|
||||||
container.removeEventListener('drop', onDrop);
|
container.removeEventListener('drop', onDrop);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -350,6 +350,94 @@ export function toggleLocked(objects: SceneItem[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Nudge ───
|
||||||
|
|
||||||
|
export function nudge(objects: SceneItem[], dx: number, dy: number) {
|
||||||
|
objects.forEach((item) => {
|
||||||
|
item.data.x += dx;
|
||||||
|
item.data.y += dy;
|
||||||
|
syncPosition(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scale (relative) ───
|
||||||
|
|
||||||
|
export function scaleBy(objects: SceneItem[], factor: number) {
|
||||||
|
objects.forEach((item) => {
|
||||||
|
const cx = item.data.x + scaledW(item) / 2;
|
||||||
|
const cy = item.data.y + scaledH(item) / 2;
|
||||||
|
item.data.sx *= factor;
|
||||||
|
item.data.sy *= factor;
|
||||||
|
// Keep center in place
|
||||||
|
item.data.x = cx - scaledW(item) / 2;
|
||||||
|
item.data.y = cy - scaledH(item) / 2;
|
||||||
|
syncTransform(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rotate (quick 90° snap) ───
|
||||||
|
|
||||||
|
export function rotate90(objects: SceneItem[], clockwise: boolean) {
|
||||||
|
objects.forEach((item) => {
|
||||||
|
item.data.angle = ((item.data.angle + (clockwise ? 90 : -90)) % 360 + 360) % 360;
|
||||||
|
item.displayObject.angle = item.data.angle;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Set Opacity ───
|
||||||
|
|
||||||
|
export function setOpacity(objects: SceneItem[], opacity: number) {
|
||||||
|
const clamped = Math.max(0, Math.min(1, opacity));
|
||||||
|
objects.forEach((item) => {
|
||||||
|
item.data.opacity = clamped;
|
||||||
|
item.displayObject.alpha = clamped;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Align Center ───
|
||||||
|
|
||||||
|
export function alignCenterH(objects: SceneItem[]) {
|
||||||
|
if (objects.length < 2) return;
|
||||||
|
const avgCX = objects.reduce((s, item) => s + item.data.x + scaledW(item) / 2, 0) / objects.length;
|
||||||
|
objects.forEach((item) => {
|
||||||
|
item.data.x = avgCX - scaledW(item) / 2;
|
||||||
|
syncPosition(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alignCenterV(objects: SceneItem[]) {
|
||||||
|
if (objects.length < 2) return;
|
||||||
|
const avgCY = objects.reduce((s, item) => s + item.data.y + scaledH(item) / 2, 0) / objects.length;
|
||||||
|
objects.forEach((item) => {
|
||||||
|
item.data.y = avgCY - scaledH(item) / 2;
|
||||||
|
syncPosition(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Equal Spacing ───
|
||||||
|
|
||||||
|
export function equalSpacingH(objects: SceneItem[], gap = 20) {
|
||||||
|
if (objects.length < 2) return;
|
||||||
|
const sorted = [...objects].sort((a, b) => a.data.x - b.data.x);
|
||||||
|
let x = sorted[0].data.x + scaledW(sorted[0]) + gap;
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
sorted[i].data.x = x;
|
||||||
|
syncPosition(sorted[i]);
|
||||||
|
x += scaledW(sorted[i]) + gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function equalSpacingV(objects: SceneItem[], gap = 20) {
|
||||||
|
if (objects.length < 2) return;
|
||||||
|
const sorted = [...objects].sort((a, b) => a.data.y - b.data.y);
|
||||||
|
let y = sorted[0].data.y + scaledH(sorted[0]) + gap;
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
sorted[i].data.y = y;
|
||||||
|
syncPosition(sorted[i]);
|
||||||
|
y += scaledH(sorted[i]) + gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Overlay / Compare ───
|
// ─── Overlay / Compare ───
|
||||||
|
|
||||||
export function overlayCompare(objects: SceneItem[]) {
|
export function overlayCompare(objects: SceneItem[]) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
export interface SceneObject {
|
export interface SceneObject {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'image' | 'video' | 'text' | 'group';
|
type: 'image' | 'video' | 'text' | 'group' | 'drawing';
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
w: number;
|
w: number;
|
||||||
@@ -42,12 +42,22 @@ export interface TextObject extends SceneObject {
|
|||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DrawingObject extends SceneObject {
|
||||||
|
type: 'drawing';
|
||||||
|
points: number[]; // flat array: [x0, y0, x1, y1, ...]
|
||||||
|
color: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupObject extends SceneObject {
|
export interface GroupObject extends SceneObject {
|
||||||
type: 'group';
|
type: 'group';
|
||||||
children: string[];
|
children: string[];
|
||||||
|
bgColor?: string; // frame background color (e.g. '#2a2a3a'), empty/undefined = transparent
|
||||||
|
label?: string; // frame title label
|
||||||
|
padding?: number; // inner padding around children (default 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnySceneObject = ImageObject | VideoObject | TextObject | GroupObject;
|
export type AnySceneObject = ImageObject | VideoObject | TextObject | DrawingObject | GroupObject;
|
||||||
|
|
||||||
export interface SceneData {
|
export interface SceneData {
|
||||||
v: 2;
|
v: 2;
|
||||||
|
|||||||
@@ -13,9 +13,103 @@
|
|||||||
* - Added Ctrl+S (prevent browser save-as)
|
* - Added Ctrl+S (prevent browser save-as)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ShortcutDef } from './shortcuts';
|
import { ShortcutDef, ShortcutContext } from './shortcuts';
|
||||||
|
import type { SceneItem, SceneManager } from './SceneManager';
|
||||||
|
import type { GroupObject } from './scene-format';
|
||||||
import * as ops from './operations';
|
import * as ops from './operations';
|
||||||
|
|
||||||
|
// Tracks when the last internal copy happened so paste can decide
|
||||||
|
// whether to use internal clipboard (just copied) vs system clipboard (external app).
|
||||||
|
let _lastInternalCopyTime = 0;
|
||||||
|
|
||||||
|
/** Mark that an internal copy just happened. Called by copy/cut handlers. */
|
||||||
|
export function markInternalCopy(): void {
|
||||||
|
_lastInternalCopyTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect selected items + their group children (for deep clone). */
|
||||||
|
function _collectGroupChildren(items: SceneItem[], scene: SceneManager): SceneItem[] {
|
||||||
|
const all: SceneItem[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.id)) continue;
|
||||||
|
seen.add(item.id);
|
||||||
|
all.push(item);
|
||||||
|
if (item.data.type === 'group') {
|
||||||
|
const gd = item.data as GroupObject;
|
||||||
|
for (const cid of gd.children) {
|
||||||
|
if (seen.has(cid)) continue;
|
||||||
|
seen.add(cid);
|
||||||
|
const child = scene.getById(cid);
|
||||||
|
if (child) all.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep-clone a list of scene items, remapping group children IDs. */
|
||||||
|
function _cloneItemsWithOffset(
|
||||||
|
items: { data: any }[],
|
||||||
|
scene: { nextZ: () => number },
|
||||||
|
offsetX = 20,
|
||||||
|
offsetY = 20,
|
||||||
|
): any[] {
|
||||||
|
// First pass: build old→new ID mapping for all items
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
for (const item of items) {
|
||||||
|
idMap.set(item.data.id, crypto.randomUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: clone data with new IDs and remapped children
|
||||||
|
const clones: any[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const newId = idMap.get(item.data.id)!;
|
||||||
|
const newData = {
|
||||||
|
...item.data,
|
||||||
|
id: newId,
|
||||||
|
x: item.data.x + offsetX,
|
||||||
|
y: item.data.y + offsetY,
|
||||||
|
z: scene.nextZ(),
|
||||||
|
};
|
||||||
|
// Remap group children references
|
||||||
|
if (newData.type === 'group' && Array.isArray(newData.children)) {
|
||||||
|
newData.children = newData.children
|
||||||
|
.map((cid: string) => idMap.get(cid) ?? cid)
|
||||||
|
.filter((cid: string) => idMap.has(cid) || items.some((i) => i.data.id === cid));
|
||||||
|
}
|
||||||
|
clones.push(newData);
|
||||||
|
}
|
||||||
|
return clones;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paste from internal clipboard — duplicates scene items with offset. */
|
||||||
|
async function _pasteInternal(ctx: ShortcutContext): Promise<void> {
|
||||||
|
const clones = _cloneItemsWithOffset(ctx.clipboardRef.current, ctx.scene);
|
||||||
|
const newItems: typeof ctx.clipboardRef.current = [];
|
||||||
|
|
||||||
|
// Create children first, then groups (so children exist when group is created)
|
||||||
|
const sorted = [...clones].sort((a, b) =>
|
||||||
|
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
|
||||||
|
|
||||||
|
for (const newData of sorted) {
|
||||||
|
await ctx.scene._createItem(newData, true);
|
||||||
|
const newItem = ctx.scene.getById(newData.id);
|
||||||
|
if (newItem) newItems.push(newItem);
|
||||||
|
}
|
||||||
|
ctx.clipboardRef.current = newItems;
|
||||||
|
ctx.scene._applyZOrder();
|
||||||
|
ctx.onChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run op on selected items → update transform box → fire onChange. */
|
||||||
|
function _opUpdate(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void {
|
||||||
|
const items = ctx.selection.getSelectedItems();
|
||||||
|
op(items);
|
||||||
|
ctx.selection.transformBox.update(items);
|
||||||
|
ctx.onChange();
|
||||||
|
}
|
||||||
|
|
||||||
export const shortcuts: ShortcutDef[] = [
|
export const shortcuts: ShortcutDef[] = [
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
@@ -25,34 +119,22 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
{
|
{
|
||||||
id: 'align-left', keys: { key: 'arrowleft', ctrl: true },
|
id: 'align-left', keys: { key: 'arrowleft', ctrl: true },
|
||||||
category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2,
|
category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.alignLeft),
|
||||||
ops.alignLeft(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'align-right', keys: { key: 'arrowright', ctrl: true },
|
id: 'align-right', keys: { key: 'arrowright', ctrl: true },
|
||||||
category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2,
|
category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.alignRight),
|
||||||
ops.alignRight(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'align-top', keys: { key: 'arrowup', ctrl: true },
|
id: 'align-top', keys: { key: 'arrowup', ctrl: true },
|
||||||
category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2,
|
category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.alignTop),
|
||||||
ops.alignTop(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true },
|
id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true },
|
||||||
category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2,
|
category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.alignBottom),
|
||||||
ops.alignBottom(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
@@ -62,18 +144,12 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
{
|
{
|
||||||
id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true },
|
id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true },
|
||||||
category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3,
|
category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.distributeHorizontal),
|
||||||
ops.distributeHorizontal(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true },
|
id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true },
|
||||||
category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3,
|
category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.distributeVertical),
|
||||||
ops.distributeVertical(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
@@ -83,34 +159,22 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
{
|
{
|
||||||
id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true },
|
id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true },
|
||||||
category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2,
|
category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.normalizeSize),
|
||||||
ops.normalizeSize(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true },
|
id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true },
|
||||||
category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2,
|
category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.normalizeScale),
|
||||||
ops.normalizeScale(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true },
|
id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true },
|
||||||
category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2,
|
category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.normalizeHeight),
|
||||||
ops.normalizeHeight(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true },
|
id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true },
|
||||||
category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2,
|
category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.normalizeWidth),
|
||||||
ops.normalizeWidth(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
@@ -120,42 +184,27 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
{
|
{
|
||||||
id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true },
|
id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true },
|
||||||
category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2,
|
category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.arrangeOptimal),
|
||||||
ops.arrangeOptimal(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true },
|
id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true },
|
||||||
category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2,
|
category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.arrangeByName),
|
||||||
ops.arrangeByName(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true },
|
id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true },
|
||||||
category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2,
|
category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.arrangeByZOrder),
|
||||||
ops.arrangeByZOrder(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true },
|
id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true },
|
||||||
category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2,
|
category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.arrangeRandomly),
|
||||||
ops.arrangeRandomly(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stack', keys: { key: 's', ctrl: true, alt: true },
|
id: 'stack', keys: { key: 's', ctrl: true, alt: true },
|
||||||
category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2,
|
category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.stackObjects),
|
||||||
ops.stackObjects(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
@@ -165,26 +214,17 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
{
|
{
|
||||||
id: 'flip-h', keys: { key: 'h', alt: true, shift: true },
|
id: 'flip-h', keys: { key: 'h', alt: true, shift: true },
|
||||||
category: 'image', description: 'Flip horizontal', needsSelection: true,
|
category: 'image', description: 'Flip horizontal', needsSelection: true,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.flipHorizontal),
|
||||||
ops.flipHorizontal(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'flip-v', keys: { key: 'v', alt: true, shift: true },
|
id: 'flip-v', keys: { key: 'v', alt: true, shift: true },
|
||||||
category: 'image', description: 'Flip vertical', needsSelection: true,
|
category: 'image', description: 'Flip vertical', needsSelection: true,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.flipVertical),
|
||||||
ops.flipVertical(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true },
|
id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true },
|
||||||
category: 'image', description: 'Reset transform', needsSelection: true,
|
category: 'image', description: 'Reset transform', needsSelection: true,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.resetTransform),
|
||||||
ops.resetTransform(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'toggle-grayscale', keys: { key: 'g', alt: true },
|
id: 'toggle-grayscale', keys: { key: 'g', alt: true },
|
||||||
@@ -199,16 +239,95 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
category: 'image', description: 'Toggle locked', needsSelection: true,
|
category: 'image', description: 'Toggle locked', needsSelection: true,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => {
|
||||||
ops.toggleLocked(ctx.selection.getSelectedItems());
|
ops.toggleLocked(ctx.selection.getSelectedItems());
|
||||||
|
ctx.onChange();
|
||||||
ctx.refreshLayers();
|
ctx.refreshLayers();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true },
|
id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true },
|
||||||
category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2,
|
category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => {
|
handler: (ctx) => _opUpdate(ctx, ops.overlayCompare),
|
||||||
ops.overlayCompare(ctx.selection.getSelectedItems());
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// NUDGE (Arrow keys with selection)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
// Shift+Arrow = 10px nudge (more specific, checked first)
|
||||||
|
{
|
||||||
|
id: 'nudge-left-10', keys: { key: 'arrowleft', shift: true },
|
||||||
|
category: 'arrangement', description: 'Nudge left 10px', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, -10, 0)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nudge-right-10', keys: { key: 'arrowright', shift: true },
|
||||||
|
category: 'arrangement', description: 'Nudge right 10px', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 10, 0)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nudge-up-10', keys: { key: 'arrowup', shift: true },
|
||||||
|
category: 'arrangement', description: 'Nudge up 10px', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -10)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nudge-down-10', keys: { key: 'arrowdown', shift: true },
|
||||||
|
category: 'arrangement', description: 'Nudge down 10px', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 10)),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// SCALE & ROTATE (selection)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'scale-up', keys: { key: '=', alt: true },
|
||||||
|
category: 'image', description: 'Scale up 10%', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1.1)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scale-down', keys: { key: '-', alt: true },
|
||||||
|
category: 'image', description: 'Scale down 10%', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1 / 1.1)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rotate-cw', keys: { key: 'r' },
|
||||||
|
category: 'image', description: 'Rotate 90° clockwise', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, true)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rotate-ccw', keys: { key: 'r', shift: true },
|
||||||
|
category: 'image', description: 'Rotate 90° counter-clockwise', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, false)),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// ALIGN CENTER
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'align-center-h', keys: { key: 'arrowleft', ctrl: true, shift: true },
|
||||||
|
category: 'alignment', description: 'Align center horizontal', needsSelection: true, minSelection: 2,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, ops.alignCenterH),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'align-center-v', keys: { key: 'arrowup', ctrl: true, shift: true },
|
||||||
|
category: 'alignment', description: 'Align center vertical', needsSelection: true, minSelection: 2,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, ops.alignCenterV),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// EQUAL SPACING
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'equal-spacing-h', keys: { key: 'h', ctrl: true, shift: true },
|
||||||
|
category: 'arrangement', description: 'Equal horizontal spacing', needsSelection: true, minSelection: 2,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, ops.equalSpacingH),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'equal-spacing-v', keys: { key: 'v', ctrl: true, shift: true },
|
||||||
|
category: 'arrangement', description: 'Equal vertical spacing', needsSelection: true, minSelection: 2,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, ops.equalSpacingV),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
@@ -227,32 +346,55 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
category: 'navigation', description: 'Fit all in view',
|
category: 'navigation', description: 'Fit all in view',
|
||||||
handler: (ctx) => ctx.fitAll(),
|
handler: (ctx) => ctx.fitAll(),
|
||||||
},
|
},
|
||||||
// Bare arrow Left/Right only cycle when nothing is selected.
|
|
||||||
// When something IS selected, they're no-ops (prevent accidental cycling).
|
|
||||||
// Layer ordering uses ] / [ to avoid arrow conflicts.
|
|
||||||
{
|
{
|
||||||
id: 'cycle-next', keys: { key: 'arrowright' },
|
id: 'focus-selection', keys: { key: 'f' },
|
||||||
category: 'navigation', description: 'Select next object',
|
category: 'navigation', description: 'Fit selection in view', needsSelection: true,
|
||||||
|
handler: (ctx) => ctx.fitSelection(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle-focus-mode', keys: { key: 'tab' },
|
||||||
|
category: 'view', description: 'Toggle focus mode (hide UI)',
|
||||||
|
handler: (ctx) => ctx.toggleFocusMode(),
|
||||||
|
},
|
||||||
|
// Bare arrows: nudge 1px if selection, cycle if no selection
|
||||||
|
{
|
||||||
|
id: 'nudge-or-cycle-right', keys: { key: 'arrowright' },
|
||||||
|
category: 'navigation', description: 'Nudge 1px / Select next',
|
||||||
handler: (ctx) => {
|
handler: (ctx) => {
|
||||||
if (ctx.selection.selectedIds.size > 0) return;
|
if (ctx.selection.selectedIds.size > 0) {
|
||||||
|
_opUpdate(ctx, (items) => ops.nudge(items, 1, 0));
|
||||||
|
} else {
|
||||||
const all = ctx.scene.getAllItems();
|
const all = ctx.scene.getAllItems();
|
||||||
if (all.length === 0) return;
|
if (all.length === 0) return;
|
||||||
// Sort by z to get consistent order
|
|
||||||
all.sort((a, b) => a.data.z - b.data.z);
|
all.sort((a, b) => a.data.z - b.data.z);
|
||||||
ctx.selection.selectOnly(all[0].id);
|
ctx.selection.selectOnly(all[0].id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cycle-prev', keys: { key: 'arrowleft' },
|
id: 'nudge-or-cycle-left', keys: { key: 'arrowleft' },
|
||||||
category: 'navigation', description: 'Select previous object',
|
category: 'navigation', description: 'Nudge 1px / Select prev',
|
||||||
handler: (ctx) => {
|
handler: (ctx) => {
|
||||||
if (ctx.selection.selectedIds.size > 0) return;
|
if (ctx.selection.selectedIds.size > 0) {
|
||||||
|
_opUpdate(ctx, (items) => ops.nudge(items, -1, 0));
|
||||||
|
} else {
|
||||||
const all = ctx.scene.getAllItems();
|
const all = ctx.scene.getAllItems();
|
||||||
if (all.length === 0) return;
|
if (all.length === 0) return;
|
||||||
all.sort((a, b) => a.data.z - b.data.z);
|
all.sort((a, b) => a.data.z - b.data.z);
|
||||||
ctx.selection.selectOnly(all[all.length - 1].id);
|
ctx.selection.selectOnly(all[all.length - 1].id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'nudge-up', keys: { key: 'arrowup' },
|
||||||
|
category: 'navigation', description: 'Nudge up 1px', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -1)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nudge-down', keys: { key: 'arrowdown' },
|
||||||
|
category: 'navigation', description: 'Nudge down 1px', needsSelection: true,
|
||||||
|
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 1)),
|
||||||
|
},
|
||||||
// Layer ordering: ] brings forward, [ sends backward
|
// Layer ordering: ] brings forward, [ sends backward
|
||||||
{
|
{
|
||||||
id: 'send-to-front', keys: { key: ']' },
|
id: 'send-to-front', keys: { key: ']' },
|
||||||
@@ -311,7 +453,9 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
handler: (ctx) => {
|
handler: (ctx) => {
|
||||||
const selected = ctx.selection.getSelectedItems();
|
const selected = ctx.selection.getSelectedItems();
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
ctx.clipboardRef.current = [...selected];
|
// Include group children in clipboard for proper paste
|
||||||
|
ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene);
|
||||||
|
markInternalCopy();
|
||||||
ctx.writeCanvasToClipboard(selected);
|
ctx.writeCanvasToClipboard(selected);
|
||||||
ctx.showToast('Copied');
|
ctx.showToast('Copied');
|
||||||
} else {
|
} else {
|
||||||
@@ -332,24 +476,36 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
id: 'paste', keys: { key: 'v', ctrl: true },
|
id: 'paste', keys: { key: 'v', ctrl: true },
|
||||||
category: 'editing', description: 'Paste',
|
category: 'editing', description: 'Paste',
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
if (ctx.clipboardRef.current.length === 0) return;
|
// Strategy: Check system clipboard for images first.
|
||||||
const newItems: typeof ctx.clipboardRef.current = [];
|
// - If system clipboard has an image AND we did NOT just do an internal copy
|
||||||
for (const original of ctx.clipboardRef.current) {
|
// (or it's been a while), paste from system clipboard (external image).
|
||||||
// Clone: duplicate the item data with new ID and offset position
|
// - If we just did an internal copy (lastInternalCopyTime is recent),
|
||||||
const newData = {
|
// use internal clipboard to duplicate scene items (preserves vector data).
|
||||||
...original.data,
|
// - If system clipboard has no images, fall back to internal clipboard.
|
||||||
id: crypto.randomUUID(),
|
|
||||||
x: original.data.x + 20,
|
const timeSinceInternalCopy = Date.now() - _lastInternalCopyTime;
|
||||||
y: original.data.y + 20,
|
const hasInternalItems = ctx.clipboardRef.current.length > 0;
|
||||||
z: ctx.scene.nextZ(),
|
const recentInternalCopy = hasInternalItems && timeSinceInternalCopy < 500;
|
||||||
};
|
|
||||||
await ctx.scene._createItem(newData, true);
|
// If we JUST did an internal copy (<500ms ago), use internal clipboard
|
||||||
const newItem = ctx.scene.getById(newData.id);
|
// (the system clipboard image is just the rasterized version of what we copied)
|
||||||
if (newItem) newItems.push(newItem);
|
if (recentInternalCopy) {
|
||||||
|
await _pasteInternal(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try system clipboard first
|
||||||
|
try {
|
||||||
|
const result = await ctx.pasteFromSystemClipboard();
|
||||||
|
if (result === 'Pasted image') return;
|
||||||
|
} catch {
|
||||||
|
// Clipboard API denied or unavailable — fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to internal clipboard
|
||||||
|
if (hasInternalItems) {
|
||||||
|
await _pasteInternal(ctx);
|
||||||
}
|
}
|
||||||
ctx.clipboardRef.current = newItems;
|
|
||||||
ctx.scene._applyZOrder();
|
|
||||||
ctx.onChange();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -358,7 +514,8 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
handler: (ctx) => {
|
handler: (ctx) => {
|
||||||
const selected = ctx.selection.getSelectedItems();
|
const selected = ctx.selection.getSelectedItems();
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
ctx.clipboardRef.current = [...selected];
|
ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene);
|
||||||
|
markInternalCopy();
|
||||||
ctx.writeCanvasToClipboard(selected);
|
ctx.writeCanvasToClipboard(selected);
|
||||||
for (const item of selected) {
|
for (const item of selected) {
|
||||||
ctx.scene.removeItem(item.id, true);
|
ctx.scene.removeItem(item.id, true);
|
||||||
@@ -374,14 +531,13 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const selected = ctx.selection.getSelectedItems();
|
const selected = ctx.selection.getSelectedItems();
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
for (const original of selected) {
|
|
||||||
const newData = {
|
// Collect group children and clone with remapped IDs
|
||||||
...original.data,
|
const allItems = _collectGroupChildren(selected, ctx.scene);
|
||||||
id: crypto.randomUUID(),
|
const clones = _cloneItemsWithOffset(allItems, ctx.scene);
|
||||||
x: original.data.x + 20,
|
const sorted = [...clones].sort((a: any, b: any) =>
|
||||||
y: original.data.y + 20,
|
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
|
||||||
z: ctx.scene.nextZ(),
|
for (const newData of sorted) {
|
||||||
};
|
|
||||||
await ctx.scene._createItem(newData, true);
|
await ctx.scene._createItem(newData, true);
|
||||||
}
|
}
|
||||||
ctx.selection.clear();
|
ctx.selection.clear();
|
||||||
@@ -455,6 +611,27 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
category: 'editing', description: 'Ungroup', needsSelection: true,
|
category: 'editing', description: 'Ungroup', needsSelection: true,
|
||||||
handler: (ctx) => ctx.handleUngroup(),
|
handler: (ctx) => ctx.handleUngroup(),
|
||||||
},
|
},
|
||||||
|
// Opacity: [ and ] with Alt
|
||||||
|
{
|
||||||
|
id: 'opacity-down', keys: { key: '[', alt: true },
|
||||||
|
category: 'image', description: 'Decrease opacity 10%', needsSelection: true,
|
||||||
|
handler: (ctx) => {
|
||||||
|
const items = ctx.selection.getSelectedItems();
|
||||||
|
const current = items[0]?.data.opacity ?? 1;
|
||||||
|
ops.setOpacity(items, Math.max(0.1, current - 0.1));
|
||||||
|
ctx.onChange();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opacity-up', keys: { key: ']', alt: true },
|
||||||
|
category: 'image', description: 'Increase opacity 10%', needsSelection: true,
|
||||||
|
handler: (ctx) => {
|
||||||
|
const items = ctx.selection.getSelectedItems();
|
||||||
|
const current = items[0]?.data.opacity ?? 1;
|
||||||
|
ops.setOpacity(items, Math.min(1, current + 0.1));
|
||||||
|
ctx.onChange();
|
||||||
|
},
|
||||||
|
},
|
||||||
// Block Ctrl+S from opening browser save-as dialog
|
// Block Ctrl+S from opening browser save-as dialog
|
||||||
{
|
{
|
||||||
id: 'save', keys: { key: 's', ctrl: true },
|
id: 'save', keys: { key: 's', ctrl: true },
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export interface ShortcutContext {
|
|||||||
handleUngroup: () => void;
|
handleUngroup: () => void;
|
||||||
toggleGrid: () => void;
|
toggleGrid: () => void;
|
||||||
toggleShowHelp: () => void;
|
toggleShowHelp: () => void;
|
||||||
|
toggleFocusMode: () => void;
|
||||||
|
fitSelection: () => void;
|
||||||
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
|
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
|
||||||
|
pasteFromSystemClipboard: () => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Graphics } from 'pixi.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DrawingSprite — renders a freehand stroke from a flat points array.
|
||||||
|
* Points stored as [x0, y0, x1, y1, ...] relative to the item's origin.
|
||||||
|
*
|
||||||
|
* Uses batched redraw during live drawing — accumulates points and redraws
|
||||||
|
* on the next animation frame. This avoids expensive per-pointermove redraws
|
||||||
|
* while keeping the stroke visually smooth.
|
||||||
|
*/
|
||||||
|
export class DrawingSprite extends Graphics {
|
||||||
|
private _points: number[] = [];
|
||||||
|
private _color: string;
|
||||||
|
private _strokeWidth: number;
|
||||||
|
private _rafId: number | null = null;
|
||||||
|
private _dirty = false;
|
||||||
|
|
||||||
|
constructor(points: number[], color: string, strokeWidth: number) {
|
||||||
|
super();
|
||||||
|
this._points = points;
|
||||||
|
this._color = color;
|
||||||
|
this._strokeWidth = strokeWidth;
|
||||||
|
this._redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
get points(): number[] {
|
||||||
|
return this._points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a point during live drawing — batches redraw to next rAF. */
|
||||||
|
addPoint(x: number, y: number): void {
|
||||||
|
this._points.push(x, y);
|
||||||
|
if (!this._dirty) {
|
||||||
|
this._dirty = true;
|
||||||
|
this._rafId = requestAnimationFrame(() => {
|
||||||
|
this._rafId = null;
|
||||||
|
this._dirty = false;
|
||||||
|
this._redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace all points and redraw immediately. Used for scene load / sync. */
|
||||||
|
setPoints(pts: number[]): void {
|
||||||
|
this._points = pts;
|
||||||
|
if (this._rafId !== null) {
|
||||||
|
cancelAnimationFrame(this._rafId);
|
||||||
|
this._rafId = null;
|
||||||
|
this._dirty = false;
|
||||||
|
}
|
||||||
|
this._redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _redraw(): void {
|
||||||
|
this.clear();
|
||||||
|
const pts = this._points;
|
||||||
|
if (pts.length < 4) return;
|
||||||
|
|
||||||
|
this.setStrokeStyle({
|
||||||
|
width: this._strokeWidth,
|
||||||
|
color: this._color,
|
||||||
|
cap: 'round',
|
||||||
|
join: 'round',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.moveTo(pts[0], pts[1]);
|
||||||
|
|
||||||
|
// Use quadratic curve smoothing for 3+ points
|
||||||
|
if (pts.length >= 6) {
|
||||||
|
for (let i = 2; i < pts.length - 2; i += 2) {
|
||||||
|
const mx = (pts[i] + pts[i + 2]) / 2;
|
||||||
|
const my = (pts[i + 1] + pts[i + 3]) / 2;
|
||||||
|
this.quadraticCurveTo(pts[i], pts[i + 1], mx, my);
|
||||||
|
}
|
||||||
|
// Last segment
|
||||||
|
this.lineTo(pts[pts.length - 2], pts[pts.length - 1]);
|
||||||
|
} else {
|
||||||
|
this.lineTo(pts[2], pts[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy(options?: any): void {
|
||||||
|
if (this._rafId !== null) {
|
||||||
|
cancelAnimationFrame(this._rafId);
|
||||||
|
this._rafId = null;
|
||||||
|
}
|
||||||
|
super.destroy(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,24 @@
|
|||||||
import { Sprite, Texture, Graphics } from "pixi.js";
|
import { Container, Sprite, Texture, Graphics } from "pixi.js";
|
||||||
import { DropShadowFilter } from "pixi-filters";
|
import { TextureManager } from "../TextureManager";
|
||||||
import { TextureManager, LODTier } from "../TextureManager";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Sprite subclass that manages LOD tier switching and lazy loading.
|
* A Container holding a shadow graphic + sprite with lazy texture loading.
|
||||||
* Shows a placeholder shimmer rect until the first texture tier loads,
|
* Uses a lightweight Graphics shadow instead of DropShadowFilter (GPU-heavy).
|
||||||
* then swaps textures as zoom level changes.
|
|
||||||
*/
|
*/
|
||||||
// Shadow defaults (resting state)
|
// Shadow defaults (resting state)
|
||||||
const SHADOW_REST = { offsetX: 3, offsetY: 3, blur: 4, alpha: 0.25 };
|
const SHADOW_REST = { offsetX: 3, offsetY: 3, alpha: 0.2 };
|
||||||
// Shadow lifted state (during drag)
|
// Shadow lifted state (during drag)
|
||||||
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, blur: 8, alpha: 0.35 };
|
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 };
|
||||||
|
|
||||||
export class ImageSprite extends Sprite {
|
export class ImageSprite extends Container {
|
||||||
readonly assetKey: string;
|
readonly assetKey: string;
|
||||||
readonly shadow: DropShadowFilter;
|
|
||||||
|
|
||||||
private textures: TextureManager;
|
private textures: TextureManager;
|
||||||
private currentTier: LODTier | null = null;
|
private loaded = false;
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private placeholder: Graphics | null = null;
|
private placeholder: Graphics | null = null;
|
||||||
|
private _sprite: Sprite;
|
||||||
|
private _shadow: Graphics;
|
||||||
private _naturalWidth: number;
|
private _naturalWidth: number;
|
||||||
private _naturalHeight: number;
|
private _naturalHeight: number;
|
||||||
|
|
||||||
@@ -29,24 +28,23 @@ export class ImageSprite extends Sprite {
|
|||||||
h: number,
|
h: number,
|
||||||
textures: TextureManager,
|
textures: TextureManager,
|
||||||
) {
|
) {
|
||||||
super(Texture.EMPTY);
|
super();
|
||||||
|
|
||||||
this.assetKey = assetKey;
|
this.assetKey = assetKey;
|
||||||
this.textures = textures;
|
this.textures = textures;
|
||||||
this._naturalWidth = w;
|
this._naturalWidth = w;
|
||||||
this._naturalHeight = h;
|
this._naturalHeight = h;
|
||||||
|
|
||||||
this.width = w;
|
// Shadow: simple dark rect behind the sprite (cheap, no GPU filter)
|
||||||
this.height = h;
|
this._shadow = new Graphics();
|
||||||
|
this._drawShadow(SHADOW_REST);
|
||||||
|
this.addChild(this._shadow);
|
||||||
|
|
||||||
// Drop shadow for photos-on-a-desk feel
|
// Main sprite
|
||||||
this.shadow = new DropShadowFilter({
|
this._sprite = new Sprite(Texture.EMPTY);
|
||||||
offset: { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY },
|
this._sprite.width = w;
|
||||||
blur: SHADOW_REST.blur,
|
this._sprite.height = h;
|
||||||
alpha: SHADOW_REST.alpha,
|
this.addChild(this._sprite);
|
||||||
color: 0x000000,
|
|
||||||
});
|
|
||||||
this.filters = [this.shadow];
|
|
||||||
|
|
||||||
// Create placeholder: dark rect shown until first texture loads
|
// Create placeholder: dark rect shown until first texture loads
|
||||||
const placeholder = new Graphics();
|
const placeholder = new Graphics();
|
||||||
@@ -54,40 +52,39 @@ export class ImageSprite extends Sprite {
|
|||||||
this.placeholder = placeholder;
|
this.placeholder = placeholder;
|
||||||
this.addChild(placeholder);
|
this.addChild(placeholder);
|
||||||
|
|
||||||
// Immediately start loading the thumbnail tier
|
// Immediately start loading the full texture
|
||||||
this.loadTier("thumb");
|
this.loadTexture();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
|
||||||
|
this._shadow.clear();
|
||||||
|
this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight);
|
||||||
|
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expand shadow for drag-lift effect. */
|
/** Expand shadow for drag-lift effect. */
|
||||||
liftShadow(): void {
|
liftShadow(): void {
|
||||||
this.shadow.offset = { x: SHADOW_LIFT.offsetX, y: SHADOW_LIFT.offsetY };
|
this._drawShadow(SHADOW_LIFT);
|
||||||
this.shadow.blur = SHADOW_LIFT.blur;
|
|
||||||
this.shadow.alpha = SHADOW_LIFT.alpha;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restore shadow to resting state. */
|
/** Restore shadow to resting state. */
|
||||||
dropShadow(): void {
|
dropShadow(): void {
|
||||||
this.shadow.offset = { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY };
|
this._drawShadow(SHADOW_REST);
|
||||||
this.shadow.blur = SHADOW_REST.blur;
|
|
||||||
this.shadow.alpha = SHADOW_REST.alpha;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Load the full-res texture. GPU handles scaling natively. */
|
||||||
* Load a specific LOD tier texture for this sprite.
|
async loadTexture(): Promise<void> {
|
||||||
* Skips if already at the requested tier or currently loading.
|
if (this.loaded || this.loading) return;
|
||||||
*/
|
|
||||||
async loadTier(tier: LODTier): Promise<void> {
|
|
||||||
if (this.currentTier === tier || this.loading) return;
|
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const tex = await this.textures.load(this.assetKey, tier);
|
const tex = await this.textures.load(this.assetKey);
|
||||||
this.texture = tex;
|
this._sprite.texture = tex;
|
||||||
this.width = this._naturalWidth;
|
this._sprite.width = this._naturalWidth;
|
||||||
this.height = this._naturalHeight;
|
this._sprite.height = this._naturalHeight;
|
||||||
this.currentTier = tier;
|
this.loaded = true;
|
||||||
|
|
||||||
// Remove placeholder after first successful load
|
// Remove placeholder after successful load
|
||||||
if (this.placeholder) {
|
if (this.placeholder) {
|
||||||
this.removeChild(this.placeholder);
|
this.removeChild(this.placeholder);
|
||||||
this.placeholder.destroy();
|
this.placeholder.destroy();
|
||||||
@@ -95,29 +92,11 @@ export class ImageSprite extends Sprite {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[ImageSprite] Failed to load tier "${tier}" for "${this.assetKey}":`,
|
`[ImageSprite] Failed to load "${this.assetKey}":`,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate the current zoom level and switch LOD tier if needed.
|
|
||||||
*/
|
|
||||||
updateLOD(zoom: number): void {
|
|
||||||
const needed = this.textures.tierForZoom(zoom);
|
|
||||||
if (needed !== this.currentTier) {
|
|
||||||
this.loadTier(needed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release the current texture (for off-screen sprites to free GPU memory).
|
|
||||||
*/
|
|
||||||
unloadTexture(): void {
|
|
||||||
this.texture = Texture.EMPTY;
|
|
||||||
this.currentTier = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+191
-46
@@ -1,47 +1,128 @@
|
|||||||
import type { Socket } from 'socket.io-client';
|
import type { Socket } from 'socket.io-client';
|
||||||
import type { SceneManager, SceneItem } from './SceneManager';
|
import type { SceneManager, SceneItem } from './SceneManager';
|
||||||
import type { SceneData } from './scene-format';
|
import type { SceneData, AnySceneObject } from './scene-format';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-scene sync (v2 format, PixiJS / SceneManager):
|
* Sync protocol v3 — Excalidraw-inspired incremental element sync.
|
||||||
*
|
*
|
||||||
* 1. On any change → broadcast serialized SceneData (throttled 300ms)
|
* Three event tiers:
|
||||||
* 2. On receive → sceneManager.loadScene() (diff-based, no flicker)
|
* 1. scene:update — full scene snapshot. Sent on join (INIT) and periodically
|
||||||
* 3. During drag → lightweight transform events (throttled 50ms)
|
* to correct any drift. Receiver does full reconciliation via loadScene().
|
||||||
|
* 2. element:update — array of changed elements only. Sent on any element
|
||||||
|
* change (add, modify, draw). Receiver merges incrementally — no full scene
|
||||||
|
* diff needed. Each element carries a `_v` version; receiver skips stale.
|
||||||
|
* 3. element:remove — array of removed element IDs. Receiver deletes them.
|
||||||
|
* 4. object:transform — ephemeral position/scale during drag (unchanged).
|
||||||
*
|
*
|
||||||
* Diff-based loading doesn't trigger change events for unchanged objects,
|
* During freehand drawing, only the single drawing element is sent via
|
||||||
* so no suppress/resume logic is needed.
|
* element:update every ~60ms (one frame) — tiny payload, no lag.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface SyncHandle {
|
||||||
|
cleanup: () => void;
|
||||||
|
broadcastTransform: (item: SceneItem) => void;
|
||||||
|
/** Force an immediate full scene broadcast (call after structural changes). */
|
||||||
|
broadcastSceneNow: () => void;
|
||||||
|
/** Broadcast only specific changed elements (lightweight). */
|
||||||
|
broadcastElements: (ids: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncOptions {
|
||||||
|
onRemoteTransform?: (item: SceneItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function setupSync(
|
export function setupSync(
|
||||||
sceneManager: SceneManager,
|
sceneManager: SceneManager,
|
||||||
socket: Socket,
|
socket: Socket,
|
||||||
boardId: string,
|
boardId: string,
|
||||||
): () => void {
|
options?: SyncOptions,
|
||||||
let sceneTimer: ReturnType<typeof setTimeout> | null = null;
|
): SyncHandle {
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let moveTimer: ReturnType<typeof setTimeout> | null = null;
|
let moveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let receiving = false; // true while applying a remote scene/transform
|
let receiving = false;
|
||||||
const SCENE_THROTTLE = 300; // ms
|
let localVersion = 0;
|
||||||
const MOVE_THROTTLE = 50; // ms
|
let remoteVersion = 0;
|
||||||
|
const DEBOUNCE_MS = 500;
|
||||||
|
const MOVE_THROTTLE = 50;
|
||||||
|
|
||||||
// ---- BROADCAST: full scene (throttled) --------------------------------
|
// Track broadcasted element versions (like Excalidraw's broadcastedElementVersions)
|
||||||
|
const broadcastedVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
function broadcastScene() {
|
// Per-element version counter — bumped whenever an element changes locally
|
||||||
if (receiving) return;
|
const elementVersions: Map<string, number> = new Map();
|
||||||
if (sceneTimer) clearTimeout(sceneTimer);
|
|
||||||
sceneTimer = setTimeout(() => {
|
function bumpElementVersion(id: string): number {
|
||||||
sceneTimer = null;
|
const v = (elementVersions.get(id) ?? 0) + 1;
|
||||||
if (receiving) return;
|
elementVersions.set(id, v);
|
||||||
const scene = sceneManager.serialize();
|
return v;
|
||||||
socket.emit('scene:update', { boardId, scene });
|
|
||||||
}, SCENE_THROTTLE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- BROADCAST: lightweight transform during drag ---------------------
|
// ---- BROADCAST: incremental element update --------------------------------
|
||||||
|
|
||||||
|
/** Send only the specified elements. Skips if element hasn't changed since last broadcast. */
|
||||||
|
function broadcastElements(ids: string[]) {
|
||||||
|
if (receiving) return;
|
||||||
|
|
||||||
|
const elements: (AnySceneObject & { _v: number })[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const item = sceneManager.getById(id);
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
const v = elementVersions.get(id) ?? 0;
|
||||||
|
const lastBroadcasted = broadcastedVersions.get(id) ?? -1;
|
||||||
|
if (v <= lastBroadcasted) continue;
|
||||||
|
|
||||||
|
elements.push({ ...item.data, _v: v });
|
||||||
|
broadcastedVersions.set(id, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.length === 0) return;
|
||||||
|
socket.emit('element:update', { boardId, elements });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast all elements that changed since last broadcast. */
|
||||||
|
function broadcastChangedElements() {
|
||||||
|
if (receiving) return;
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [id, v] of elementVersions) {
|
||||||
|
if (v > (broadcastedVersions.get(id) ?? -1)) {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.length > 0) broadcastElements(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BROADCAST: full scene (for INIT / periodic resync) -------------------
|
||||||
|
|
||||||
|
function broadcastSceneNow() {
|
||||||
|
if (receiving) return;
|
||||||
|
if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; }
|
||||||
|
localVersion++;
|
||||||
|
const scene = sceneManager.serialize();
|
||||||
|
|
||||||
|
// Update all broadcasted versions
|
||||||
|
for (const obj of scene.objects) {
|
||||||
|
const v = elementVersions.get(obj.id) ?? 0;
|
||||||
|
broadcastedVersions.set(obj.id, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit('scene:update', { boardId, scene, version: localVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastSceneDebounced() {
|
||||||
|
if (receiving) return;
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debounceTimer = null;
|
||||||
|
broadcastSceneNow();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BROADCAST: lightweight transform during drag -------------------------
|
||||||
|
|
||||||
function broadcastTransform(item: SceneItem) {
|
function broadcastTransform(item: SceneItem) {
|
||||||
if (receiving) return;
|
if (receiving) return;
|
||||||
if (moveTimer) return; // throttled
|
if (moveTimer) return;
|
||||||
const { id, data } = item;
|
const { id, data } = item;
|
||||||
socket.emit('object:transform', {
|
socket.emit('object:transform', {
|
||||||
boardId,
|
boardId,
|
||||||
@@ -52,22 +133,34 @@ export function setupSync(
|
|||||||
sy: data.sy,
|
sy: data.sy,
|
||||||
angle: data.angle,
|
angle: data.angle,
|
||||||
});
|
});
|
||||||
moveTimer = setTimeout(() => {
|
moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE);
|
||||||
moveTimer = null;
|
broadcastSceneDebounced();
|
||||||
}, MOVE_THROTTLE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- RECEIVE: full scene ----------------------------------------------
|
// ---- RECEIVE: full scene --------------------------------------------------
|
||||||
|
|
||||||
function onSceneReceived(payload: any) {
|
function onSceneReceived(payload: any) {
|
||||||
if (payload.boardId !== boardId) return;
|
if (payload.boardId !== boardId) return;
|
||||||
const scene: SceneData = payload.scene;
|
const scene: SceneData = payload.scene;
|
||||||
if (!scene || scene.v !== 2) return;
|
if (!scene || scene.v !== 2) return;
|
||||||
|
|
||||||
|
const incomingVersion = payload.version ?? 0;
|
||||||
|
if (incomingVersion > 0 && incomingVersion <= remoteVersion) return;
|
||||||
|
remoteVersion = incomingVersion;
|
||||||
|
if (incomingVersion >= localVersion) localVersion = incomingVersion;
|
||||||
|
|
||||||
receiving = true;
|
receiving = true;
|
||||||
sceneManager
|
sceneManager
|
||||||
.loadScene(scene)
|
.loadScene(scene)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
// Update element versions from received data
|
||||||
|
for (const obj of scene.objects) {
|
||||||
|
const v = (obj as any)._v;
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
const current = elementVersions.get(obj.id) ?? 0;
|
||||||
|
if (v > current) elementVersions.set(obj.id, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
receiving = false;
|
receiving = false;
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
@@ -76,42 +169,91 @@ export function setupSync(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- RECEIVE: lightweight transform -----------------------------------
|
// ---- RECEIVE: incremental element update ----------------------------------
|
||||||
|
|
||||||
|
function onElementUpdate(payload: any) {
|
||||||
|
if (payload.boardId !== boardId) return;
|
||||||
|
const elements: (AnySceneObject & { _v?: number })[] = payload.elements;
|
||||||
|
if (!Array.isArray(elements) || elements.length === 0) return;
|
||||||
|
|
||||||
|
for (const data of elements) {
|
||||||
|
const incomingV = data._v ?? 0;
|
||||||
|
|
||||||
|
// Clean the _v field before storing
|
||||||
|
const cleanData = { ...data };
|
||||||
|
delete (cleanData as any)._v;
|
||||||
|
|
||||||
|
const existing = sceneManager.getById(data.id);
|
||||||
|
if (existing) {
|
||||||
|
// Update existing — apply incremental merge
|
||||||
|
sceneManager._updateItem(existing, cleanData);
|
||||||
|
} else {
|
||||||
|
// New element — create it
|
||||||
|
sceneManager._createItem(cleanData, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the version
|
||||||
|
if (incomingV > 0) {
|
||||||
|
const current = elementVersions.get(data.id) ?? 0;
|
||||||
|
if (incomingV > current) elementVersions.set(data.id, incomingV);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneManager._applyZOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RECEIVE: element removal ---------------------------------------------
|
||||||
|
|
||||||
|
function onElementRemove(payload: any) {
|
||||||
|
if (payload.boardId !== boardId) return;
|
||||||
|
const ids: string[] = payload.ids;
|
||||||
|
if (!Array.isArray(ids)) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
sceneManager.removeItem(id, true);
|
||||||
|
elementVersions.delete(id);
|
||||||
|
broadcastedVersions.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RECEIVE: lightweight transform ---------------------------------------
|
||||||
|
|
||||||
function onTransformReceived(payload: any) {
|
function onTransformReceived(payload: any) {
|
||||||
if (payload.boardId !== boardId) return;
|
if (payload.boardId !== boardId) return;
|
||||||
const item = sceneManager.getById(payload.objectId);
|
const item = sceneManager.getById(payload.objectId);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
// Update data model
|
|
||||||
item.data.x = payload.x;
|
item.data.x = payload.x;
|
||||||
item.data.y = payload.y;
|
item.data.y = payload.y;
|
||||||
item.data.sx = payload.sx;
|
item.data.sx = payload.sx;
|
||||||
item.data.sy = payload.sy;
|
item.data.sy = payload.sy;
|
||||||
item.data.angle = payload.angle;
|
item.data.angle = payload.angle;
|
||||||
|
|
||||||
// Update display object
|
|
||||||
const obj = item.displayObject;
|
const obj = item.displayObject;
|
||||||
obj.position.set(payload.x, payload.y);
|
obj.position.set(payload.x, payload.y);
|
||||||
obj.scale.set(payload.sx, payload.sy);
|
obj.scale.set(payload.sx, payload.sy);
|
||||||
obj.angle = payload.angle;
|
obj.angle = payload.angle;
|
||||||
// No onChange — this is a remote update
|
|
||||||
|
options?.onRemoteTransform?.(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Wire up SceneManager onChange → broadcastScene --------------------
|
// ---- Wire up SceneManager onChange → debounced full sync -------------------
|
||||||
|
|
||||||
const prevOnChange = sceneManager.onChange;
|
const prevOnChange = sceneManager.onChange;
|
||||||
sceneManager.onChange = () => {
|
sceneManager.onChange = () => {
|
||||||
prevOnChange?.();
|
prevOnChange?.();
|
||||||
broadcastScene();
|
// Structural change — schedule a full scene sync (debounced).
|
||||||
|
// Incremental element sync is handled explicitly by tools via broadcastElements().
|
||||||
|
broadcastSceneDebounced();
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Bind socket events -----------------------------------------------
|
// ---- Bind socket events ---------------------------------------------------
|
||||||
|
|
||||||
socket.on('scene:update', onSceneReceived);
|
socket.on('scene:update', onSceneReceived);
|
||||||
|
socket.on('element:update', onElementUpdate);
|
||||||
|
socket.on('element:remove', onElementRemove);
|
||||||
socket.on('object:transform', onTransformReceived);
|
socket.on('object:transform', onTransformReceived);
|
||||||
|
|
||||||
// ---- Join room --------------------------------------------------------
|
// ---- Join room ------------------------------------------------------------
|
||||||
|
|
||||||
socket.emit('board:join', { boardId }, (response: any) => {
|
socket.emit('board:join', { boardId }, (response: any) => {
|
||||||
if (response?.users) {
|
if (response?.users) {
|
||||||
@@ -119,21 +261,24 @@ export function setupSync(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Cleanup ----------------------------------------------------------
|
// ---- Return handle --------------------------------------------------------
|
||||||
|
|
||||||
return () => {
|
return {
|
||||||
// Restore previous onChange
|
broadcastTransform,
|
||||||
|
broadcastSceneNow,
|
||||||
|
broadcastElements(ids: string[]) {
|
||||||
|
for (const id of ids) bumpElementVersion(id);
|
||||||
|
broadcastElements(ids);
|
||||||
|
},
|
||||||
|
cleanup: () => {
|
||||||
sceneManager.onChange = prevOnChange;
|
sceneManager.onChange = prevOnChange;
|
||||||
|
|
||||||
socket.off('scene:update', onSceneReceived);
|
socket.off('scene:update', onSceneReceived);
|
||||||
|
socket.off('element:update', onElementUpdate);
|
||||||
|
socket.off('element:remove', onElementRemove);
|
||||||
socket.off('object:transform', onTransformReceived);
|
socket.off('object:transform', onTransformReceived);
|
||||||
|
|
||||||
socket.emit('board:leave', { boardId });
|
socket.emit('board:leave', { boardId });
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
if (sceneTimer) clearTimeout(sceneTimer);
|
|
||||||
if (moveTimer) clearTimeout(moveTimer);
|
if (moveTimer) clearTimeout(moveTimer);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: broadcastTransform for drag events will be wired via Editor integration.
|
|
||||||
// SelectionManager / TransformBox will call it directly once connected.
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type { Viewport } from 'pixi-viewport';
|
|||||||
import type { SceneManager } from './SceneManager';
|
import type { SceneManager } from './SceneManager';
|
||||||
import type { SelectionManager } from './SelectionManager';
|
import type { SelectionManager } from './SelectionManager';
|
||||||
import { Text, TextStyle } from 'pixi.js';
|
import { Text, TextStyle } from 'pixi.js';
|
||||||
|
import { DrawingSprite } from './sprites/DrawingSprite';
|
||||||
|
import type { DrawingObject } from './scene-format';
|
||||||
|
|
||||||
export enum ToolType {
|
export enum ToolType {
|
||||||
SELECT = 'SELECT',
|
SELECT = 'SELECT',
|
||||||
@@ -40,6 +42,8 @@ export interface ToolContext {
|
|||||||
selection: SelectionManager;
|
selection: SelectionManager;
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
|
/** Broadcast only specific changed elements (lightweight, for live drawing). */
|
||||||
|
broadcastElements?: (ids: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activateTool(
|
export function activateTool(
|
||||||
@@ -53,6 +57,9 @@ export function activateTool(
|
|||||||
// Reset cursor
|
// Reset cursor
|
||||||
container.style.cursor = '';
|
container.style.cursor = '';
|
||||||
|
|
||||||
|
// Enable/disable SelectionManager based on tool
|
||||||
|
selection.setEnabled(tool === ToolType.SELECT);
|
||||||
|
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case ToolType.SELECT: {
|
case ToolType.SELECT: {
|
||||||
container.style.cursor = 'default';
|
container.style.cursor = 'default';
|
||||||
@@ -96,6 +103,7 @@ export function activateTool(
|
|||||||
|
|
||||||
scene._createItem(textData, true);
|
scene._createItem(textData, true);
|
||||||
scene._applyZOrder();
|
scene._applyZOrder();
|
||||||
|
ctx.broadcastElements?.([textData.id]);
|
||||||
ctx.onChange();
|
ctx.onChange();
|
||||||
|
|
||||||
// Remove handler after placing text
|
// Remove handler after placing text
|
||||||
@@ -110,12 +118,17 @@ export function activateTool(
|
|||||||
|
|
||||||
case ToolType.ERASER: {
|
case ToolType.ERASER: {
|
||||||
container.style.cursor = 'crosshair';
|
container.style.cursor = 'crosshair';
|
||||||
|
// Clear any existing selection/transform box when switching to eraser
|
||||||
|
selection.clear();
|
||||||
|
selection.transformBox.update([]);
|
||||||
|
|
||||||
const onClick = (e: PointerEvent) => {
|
const onClick = (e: PointerEvent) => {
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
const hit = selection._hitTest(world.x, world.y);
|
const hit = selection._hitTest(world.x, world.y);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
|
selection.selectedIds.delete(hit.id);
|
||||||
|
selection.transformBox.update([]);
|
||||||
scene.removeItem(hit.id, true);
|
scene.removeItem(hit.id, true);
|
||||||
ctx.onChange();
|
ctx.onChange();
|
||||||
}
|
}
|
||||||
@@ -129,8 +142,131 @@ export function activateTool(
|
|||||||
|
|
||||||
case ToolType.PEN: {
|
case ToolType.PEN: {
|
||||||
container.style.cursor = 'crosshair';
|
container.style.cursor = 'crosshair';
|
||||||
// Drawing mode stub — will be implemented later
|
|
||||||
return null;
|
let drawing = false;
|
||||||
|
let currentSprite: DrawingSprite | null = null;
|
||||||
|
let originX = 0;
|
||||||
|
let originY = 0;
|
||||||
|
let itemId = '';
|
||||||
|
let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const SYNC_INTERVAL = 100; // ~10fps — lightweight element-only sync
|
||||||
|
|
||||||
|
const scheduleLiveSync = () => {
|
||||||
|
if (syncTimer) return;
|
||||||
|
syncTimer = setTimeout(() => {
|
||||||
|
syncTimer = null;
|
||||||
|
if (!drawing) return;
|
||||||
|
// Copy current points into item.data for serialization
|
||||||
|
const item = scene.getById(itemId);
|
||||||
|
if (item && currentSprite) {
|
||||||
|
(item.data as DrawingObject).points = [...currentSprite.points];
|
||||||
|
}
|
||||||
|
// Send only the drawing element, not the entire scene
|
||||||
|
ctx.broadcastElements?.([itemId]);
|
||||||
|
}, SYNC_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
drawing = true;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
|
originX = world.x;
|
||||||
|
originY = world.y;
|
||||||
|
|
||||||
|
itemId = crypto.randomUUID();
|
||||||
|
const drawData: DrawingObject = {
|
||||||
|
id: itemId,
|
||||||
|
type: 'drawing',
|
||||||
|
x: originX,
|
||||||
|
y: originY,
|
||||||
|
w: 1,
|
||||||
|
h: 1,
|
||||||
|
sx: 1,
|
||||||
|
sy: 1,
|
||||||
|
angle: 0,
|
||||||
|
z: scene.nextZ(),
|
||||||
|
opacity: 1,
|
||||||
|
locked: false,
|
||||||
|
name: '',
|
||||||
|
visible: true,
|
||||||
|
points: [0, 0],
|
||||||
|
color: opts.color!,
|
||||||
|
strokeWidth: opts.strokeWidth!,
|
||||||
|
};
|
||||||
|
|
||||||
|
scene._createItem(drawData, false);
|
||||||
|
const item = scene.getById(itemId);
|
||||||
|
if (item && item.displayObject instanceof DrawingSprite) {
|
||||||
|
currentSprite = item.displayObject as DrawingSprite;
|
||||||
|
}
|
||||||
|
container.setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
if (!drawing || !currentSprite) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
|
currentSprite.addPoint(world.x - originX, world.y - originY);
|
||||||
|
scheduleLiveSync();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
if (!drawing) return;
|
||||||
|
drawing = false;
|
||||||
|
if (syncTimer) { clearTimeout(syncTimer); syncTimer = null; }
|
||||||
|
|
||||||
|
// Compute bounding box and normalize points so origin = top-left of stroke
|
||||||
|
const item = scene.getById(itemId);
|
||||||
|
if (item && currentSprite) {
|
||||||
|
const pts = currentSprite.points;
|
||||||
|
if (pts.length < 4) {
|
||||||
|
scene.removeItem(itemId, false);
|
||||||
|
} else {
|
||||||
|
// Find bounds of all points
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
|
minX = Math.min(minX, pts[i]);
|
||||||
|
minY = Math.min(minY, pts[i + 1]);
|
||||||
|
maxX = Math.max(maxX, pts[i]);
|
||||||
|
maxY = Math.max(maxY, pts[i + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sw = opts.strokeWidth!;
|
||||||
|
// Shift all points so min = strokeWidth/2 (padding for stroke)
|
||||||
|
const normalized: number[] = [];
|
||||||
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
|
normalized.push(pts[i] - minX + sw / 2);
|
||||||
|
normalized.push(pts[i + 1] - minY + sw / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update origin to account for the shift
|
||||||
|
item.data.x = originX + minX - sw / 2;
|
||||||
|
item.data.y = originY + minY - sw / 2;
|
||||||
|
item.data.w = Math.max(maxX - minX + sw, 1);
|
||||||
|
item.data.h = Math.max(maxY - minY + sw, 1);
|
||||||
|
(item.data as DrawingObject).points = normalized;
|
||||||
|
|
||||||
|
// Redraw with normalized points and reposition
|
||||||
|
currentSprite.setPoints(normalized);
|
||||||
|
item.displayObject.position.set(item.data.x, item.data.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSprite = null;
|
||||||
|
scene._applyZOrder();
|
||||||
|
ctx.onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('pointerdown', onDown);
|
||||||
|
container.addEventListener('pointermove', onMove);
|
||||||
|
container.addEventListener('pointerup', onUp);
|
||||||
|
return () => {
|
||||||
|
if (syncTimer) clearTimeout(syncTimer);
|
||||||
|
container.removeEventListener('pointerdown', onDown);
|
||||||
|
container.removeEventListener('pointermove', onMove);
|
||||||
|
container.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React, { useRef, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface MinimapItem {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Bounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MinimapProps {
|
||||||
|
items: MinimapItem[];
|
||||||
|
viewportBounds: Bounds; // world-space visible area
|
||||||
|
contentBounds: Bounds; // world-space all-content bounds
|
||||||
|
onNavigate: (worldX: number, worldY: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAP_W = 180;
|
||||||
|
const MAP_H = 120;
|
||||||
|
const PADDING_RATIO = 0.1;
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
image: '#4a9eff',
|
||||||
|
video: '#ff6b6b',
|
||||||
|
text: '#69db7c',
|
||||||
|
drawing: '#ffd43b',
|
||||||
|
group: '#7950f2',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getColor(type: string): string {
|
||||||
|
return TYPE_COLORS[type] ?? '#888';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute the union of two bounding boxes. */
|
||||||
|
function unionBounds(a: Bounds, b: Bounds): Bounds {
|
||||||
|
const x1 = Math.min(a.x, b.x);
|
||||||
|
const y1 = Math.min(a.y, b.y);
|
||||||
|
const x2 = Math.max(a.x + a.w, b.x + b.w);
|
||||||
|
const y2 = Math.max(a.y + a.h, b.y + b.h);
|
||||||
|
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Minimap({ items, viewportBounds, contentBounds, onNavigate }: MinimapProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const draggingRef = useRef(false);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Compute the mapping from world space to minimap pixel space.
|
||||||
|
// Returns { offsetX, offsetY, scale } so that:
|
||||||
|
// minimapX = (worldX - offsetX) * scale
|
||||||
|
// minimapY = (worldY - offsetY) * scale
|
||||||
|
const getMapping = useCallback(() => {
|
||||||
|
const scene = unionBounds(contentBounds, viewportBounds);
|
||||||
|
|
||||||
|
// Add 10% padding
|
||||||
|
const padX = scene.w * PADDING_RATIO;
|
||||||
|
const padY = scene.h * PADDING_RATIO;
|
||||||
|
const padded: Bounds = {
|
||||||
|
x: scene.x - padX,
|
||||||
|
y: scene.y - padY,
|
||||||
|
w: scene.w + padX * 2,
|
||||||
|
h: scene.h + padY * 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Avoid division by zero
|
||||||
|
if (padded.w === 0 || padded.h === 0) {
|
||||||
|
return { offsetX: padded.x, offsetY: padded.y, scale: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.min(MAP_W / padded.w, MAP_H / padded.h);
|
||||||
|
return { offsetX: padded.x, offsetY: padded.y, scale };
|
||||||
|
}, [contentBounds, viewportBounds]);
|
||||||
|
|
||||||
|
// Convert minimap pixel coords to world coords
|
||||||
|
const minimapToWorld = useCallback((mx: number, my: number): { wx: number; wy: number } => {
|
||||||
|
const { offsetX, offsetY, scale } = getMapping();
|
||||||
|
return {
|
||||||
|
wx: mx / scale + offsetX,
|
||||||
|
wy: my / scale + offsetY,
|
||||||
|
};
|
||||||
|
}, [getMapping]);
|
||||||
|
|
||||||
|
// Handle pointer interaction (click / drag to navigate)
|
||||||
|
const handlePointerEvent = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
const { wx, wy } = minimapToWorld(mx, my);
|
||||||
|
onNavigate(wx, wy);
|
||||||
|
}, [minimapToWorld, onNavigate]);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
draggingRef.current = true;
|
||||||
|
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
|
||||||
|
handlePointerEvent(e);
|
||||||
|
}, [handlePointerEvent]);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!draggingRef.current) return;
|
||||||
|
handlePointerEvent(e);
|
||||||
|
}, [handlePointerEvent]);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
draggingRef.current = false;
|
||||||
|
(e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Draw minimap
|
||||||
|
useEffect(() => {
|
||||||
|
if (rafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
rafRef.current = null;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = MAP_W * dpr;
|
||||||
|
canvas.height = MAP_H * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
ctx.clearRect(0, 0, MAP_W, MAP_H);
|
||||||
|
|
||||||
|
const { offsetX, offsetY, scale } = getMapping();
|
||||||
|
|
||||||
|
const toX = (wx: number) => (wx - offsetX) * scale;
|
||||||
|
const toY = (wy: number) => (wy - offsetY) * scale;
|
||||||
|
|
||||||
|
// Draw items
|
||||||
|
for (const item of items) {
|
||||||
|
const rx = toX(item.x);
|
||||||
|
const ry = toY(item.y);
|
||||||
|
const rw = Math.max(item.w * scale, 2);
|
||||||
|
const rh = Math.max(item.h * scale, 2);
|
||||||
|
ctx.fillStyle = getColor(item.type);
|
||||||
|
ctx.fillRect(rx, ry, rw, rh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw viewport frustum
|
||||||
|
const vx = toX(viewportBounds.x);
|
||||||
|
const vy = toY(viewportBounds.y);
|
||||||
|
const vw = viewportBounds.w * scale;
|
||||||
|
const vh = viewportBounds.h * scale;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
||||||
|
ctx.fillRect(vx, vy, vw, vh);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(vx + 0.5, vy + 0.5, vw, vh);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [items, viewportBounds, contentBounds, getMapping]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 12,
|
||||||
|
right: 12,
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
background: 'rgba(20, 20, 20, 0.9)',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'crosshair',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SelectionToolbarProps {
|
||||||
|
/** Screen-space position of the selection's top-center */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
count: number;
|
||||||
|
onAlignLeft: () => void;
|
||||||
|
onAlignCenterH: () => void;
|
||||||
|
onAlignRight: () => void;
|
||||||
|
onAlignTop: () => void;
|
||||||
|
onAlignCenterV: () => void;
|
||||||
|
onAlignBottom: () => void;
|
||||||
|
onDistributeH: () => void;
|
||||||
|
onDistributeV: () => void;
|
||||||
|
onPack: () => void;
|
||||||
|
onGrid: () => void;
|
||||||
|
onRow: () => void;
|
||||||
|
onColumn: () => void;
|
||||||
|
onStack: () => void;
|
||||||
|
onFlipH: () => void;
|
||||||
|
onFlipV: () => void;
|
||||||
|
onGroup: () => void;
|
||||||
|
onNormSize: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny SVG icons for each action
|
||||||
|
function IcoAlignL() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="2" y1="1" x2="2" y2="13" /><rect x="4" y="3" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="4" y="8" width="5" height="3" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoAlignCH() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="7" y1="1" x2="7" y2="13" strokeDasharray="1.5 1.5" /><rect x="2" y="3" width="10" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="3.5" y="8" width="7" height="3" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoAlignR() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="12" y1="1" x2="12" y2="13" /><rect x="2" y="3" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="5" y="8" width="5" height="3" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoAlignT() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="1" y1="2" x2="13" y2="2" /><rect x="3" y="4" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="8" y="4" width="3" height="5" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoAlignCV() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="1" y1="7" x2="13" y2="7" strokeDasharray="1.5 1.5" /><rect x="3" y="2" width="3" height="10" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="8" y="3.5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoAlignB() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="1" y1="12" x2="13" y2="12" /><rect x="3" y="2" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="8" y="5" width="3" height="5" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoDistH() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="3" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="5.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="10" y="3" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><path d="M4.5 7h1M9 7h1" strokeWidth="1" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoDistV() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="3" y="1" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="3" y="5.5" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="3" y="10" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><path d="M7 4.5v1M7 9v1" strokeWidth="1" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoPack() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="1" width="5" height="6" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="7" y="1" width="6" height="4" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="1" y="8" width="4" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="6" y="6" width="7" height="7" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoGrid() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="1" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="8" y="1" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="1" y="8" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="8" y="8" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoRow() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="4" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="5.5" y="4" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="10" y="4" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoCol() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="4" y="1" width="6" height="3" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="4" y="5.5" width="6" height="3" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="4" y="10" width="6" height="3" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoStack() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="2" y="2" width="10" height="10" rx="0.5" fill="currentColor" opacity="0.15" /><rect x="3" y="3" width="8" height="8" rx="0.5" fill="currentColor" opacity="0.15" /><rect x="4" y="4" width="6" height="6" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoFlipH() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3"><line x1="7" y1="1" x2="7" y2="13" strokeDasharray="2 1" /><path d="M5 4H2L5 10V4Z" fill="currentColor" opacity="0.3" /><path d="M9 4H12L9 10V4Z" fill="currentColor" opacity="0.15" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoFlipV() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3"><line x1="1" y1="7" x2="13" y2="7" strokeDasharray="2 1" /><path d="M4 5V2L10 5H4Z" fill="currentColor" opacity="0.3" /><path d="M4 9V12L10 9H4Z" fill="currentColor" opacity="0.15" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoGroup() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="1" width="12" height="12" rx="1.5" strokeDasharray="2 1.5" /><rect x="3" y="3" width="4" height="4" rx="0.5" fill="currentColor" opacity="0.25" /><rect x="7" y="7" width="4" height="4" rx="0.5" fill="currentColor" opacity="0.25" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoNormSize() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="4" width="5" height="8" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="8" y="2" width="5" height="10" rx="0.5" fill="currentColor" opacity="0.2" /><path d="M3.5 1v2M10.5 1v2" strokeLinecap="round" /><line x1="3.5" y1="1.5" x2="10.5" y2="1.5" strokeLinecap="round" /></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BtnDef {
|
||||||
|
icon: React.FC;
|
||||||
|
label: string;
|
||||||
|
shortcut: string;
|
||||||
|
onClick: () => void;
|
||||||
|
minItems?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectionToolbar(props: SelectionToolbarProps) {
|
||||||
|
const { x, y, count } = props;
|
||||||
|
|
||||||
|
const groups: { label: string; items: BtnDef[] }[] = [
|
||||||
|
{
|
||||||
|
label: 'Align',
|
||||||
|
items: [
|
||||||
|
{ icon: IcoAlignL, label: 'Align left', shortcut: 'Ctrl+\u2190', onClick: props.onAlignLeft },
|
||||||
|
{ icon: IcoAlignCH, label: 'Align center H', shortcut: 'Ctrl+Alt+H', onClick: props.onAlignCenterH },
|
||||||
|
{ icon: IcoAlignR, label: 'Align right', shortcut: 'Ctrl+\u2192', onClick: props.onAlignRight },
|
||||||
|
{ icon: IcoAlignT, label: 'Align top', shortcut: 'Ctrl+\u2191', onClick: props.onAlignTop },
|
||||||
|
{ icon: IcoAlignCV, label: 'Align center V', shortcut: 'Ctrl+Alt+V', onClick: props.onAlignCenterV },
|
||||||
|
{ icon: IcoAlignB, label: 'Align bottom', shortcut: 'Ctrl+\u2193', onClick: props.onAlignBottom },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Distribute',
|
||||||
|
items: [
|
||||||
|
{ icon: IcoDistH, label: 'Distribute H', shortcut: 'Ctrl+Shift+H', onClick: props.onDistributeH, minItems: 3 },
|
||||||
|
{ icon: IcoDistV, label: 'Distribute V', shortcut: 'Ctrl+Shift+V', onClick: props.onDistributeV, minItems: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Arrange',
|
||||||
|
items: [
|
||||||
|
{ icon: IcoPack, label: 'Pack', shortcut: 'Ctrl+Shift+P', onClick: props.onPack },
|
||||||
|
{ icon: IcoGrid, label: 'Grid', shortcut: '', onClick: props.onGrid },
|
||||||
|
{ icon: IcoRow, label: 'Row', shortcut: '', onClick: props.onRow },
|
||||||
|
{ icon: IcoCol, label: 'Column', shortcut: '', onClick: props.onColumn },
|
||||||
|
{ icon: IcoStack, label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: props.onStack },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Transform',
|
||||||
|
items: [
|
||||||
|
{ icon: IcoFlipH, label: 'Flip H', shortcut: 'Alt+Shift+H', onClick: props.onFlipH },
|
||||||
|
{ icon: IcoFlipV, label: 'Flip V', shortcut: 'Alt+Shift+V', onClick: props.onFlipV },
|
||||||
|
{ icon: IcoGroup, label: 'Group', shortcut: 'Ctrl+G', onClick: props.onGroup },
|
||||||
|
{ icon: IcoNormSize, label: 'Same size', shortcut: '', onClick: props.onNormSize },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: x,
|
||||||
|
top: y - 8,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1px',
|
||||||
|
padding: '3px',
|
||||||
|
background: 'rgba(22, 22, 22, 0.96)',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 100,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{groups.map((group, gi) => (
|
||||||
|
<React.Fragment key={group.label}>
|
||||||
|
{gi > 0 && (
|
||||||
|
<div style={{ width: '1px', background: '#333', margin: '2px 2px', flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '1px' }}>
|
||||||
|
{group.items.map((btn) => {
|
||||||
|
const disabled = (btn.minItems ?? 2) > count;
|
||||||
|
const Icon = btn.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={btn.label}
|
||||||
|
onClick={btn.onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={btn.shortcut ? `${btn.label} (${btn.shortcut})` : btn.label}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '26px',
|
||||||
|
height: '26px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '5px',
|
||||||
|
color: disabled ? '#444' : '#999',
|
||||||
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
transition: 'all 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!disabled) {
|
||||||
|
e.currentTarget.style.background = '#333';
|
||||||
|
e.currentTarget.style.color = '#fff';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
e.currentTarget.style.color = disabled ? '#444' : '#999';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ interface ToolbarProps {
|
|||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
onlineUsers: OnlineUser[];
|
onlineUsers: OnlineUser[];
|
||||||
|
onUserClick?: (userId: string, displayName: string) => void;
|
||||||
|
followingUserId?: string | null;
|
||||||
onShareClick?: () => void;
|
onShareClick?: () => void;
|
||||||
onToggleLayers?: () => void;
|
onToggleLayers?: () => void;
|
||||||
showLayers?: boolean;
|
showLayers?: boolean;
|
||||||
@@ -139,6 +141,8 @@ export default function Toolbar({
|
|||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onlineUsers,
|
onlineUsers,
|
||||||
|
onUserClick,
|
||||||
|
followingUserId,
|
||||||
onShareClick,
|
onShareClick,
|
||||||
onToggleLayers,
|
onToggleLayers,
|
||||||
showLayers,
|
showLayers,
|
||||||
@@ -306,11 +310,16 @@ export default function Toolbar({
|
|||||||
background: `linear-gradient(135deg, ${u.color}, ${u.color}dd)`,
|
background: `linear-gradient(135deg, ${u.color}, ${u.color}dd)`,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: '10px', fontWeight: 700, color: '#fff',
|
fontSize: '10px', fontWeight: 700, color: '#fff',
|
||||||
border: '2px solid #1a1a1a',
|
border: followingUserId === u.userId ? '2px solid #4a9eff' : '2px solid #1a1a1a',
|
||||||
marginLeft: i > 0 ? '-6px' : '0',
|
marginLeft: i > 0 ? '-6px' : '0',
|
||||||
zIndex: 5 - i,
|
zIndex: 5 - i,
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
boxShadow: followingUserId === u.userId ? '0 0 6px rgba(74,144,217,0.6)' : '0 1px 3px rgba(0,0,0,0.3)',
|
||||||
}}>
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
|
}}
|
||||||
|
onClick={() => onUserClick?.(u.userId, u.displayName)}
|
||||||
|
title={`${u.displayName}${followingUserId === u.userId ? ' (following)' : ' — click to follow'}`}
|
||||||
|
>
|
||||||
{(u.displayName || '?')[0].toUpperCase()}
|
{(u.displayName || '?')[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -91,10 +91,11 @@ export default function UserCursors({ socket, boardId, canvasTransform }: UserCu
|
|||||||
key={cursor.userId}
|
key={cursor.userId}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: screenX,
|
left: 0,
|
||||||
top: screenY,
|
top: 0,
|
||||||
transform: 'translate(-2px, -2px)',
|
transform: `translate(${screenX - 2}px, ${screenY - 2}px)`,
|
||||||
transition: 'left 0.1s, top 0.1s',
|
transition: 'transform 50ms linear',
|
||||||
|
willChange: 'transform',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
|
<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getBoard } from '../api';
|
||||||
|
|
||||||
|
interface BoardLoaderResult {
|
||||||
|
boardData: any;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads board data by ID with cancellation support.
|
||||||
|
*/
|
||||||
|
export function useBoardLoader(boardId: string | undefined): BoardLoaderResult {
|
||||||
|
const [boardData, setBoardData] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
if (!boardId) {
|
||||||
|
setError('No board specified');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await getBoard(boardId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setBoardData(res.data);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to load board');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [boardId]);
|
||||||
|
|
||||||
|
return { boardData, loading, error };
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { setupSync, SyncHandle } from '../canvas/sync';
|
|||||||
import { setupDragDrop, setupPaste } from '../canvas/image-drop';
|
import { setupDragDrop, setupPaste } from '../canvas/image-drop';
|
||||||
import { UndoManager } from '../canvas/history';
|
import { UndoManager } from '../canvas/history';
|
||||||
import { InboxZone } from '../canvas/InboxZone';
|
import { InboxZone } from '../canvas/InboxZone';
|
||||||
|
import { LaserPointer } from '../canvas/LaserPointer';
|
||||||
|
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
||||||
import { connectSocket, disconnectSocket } from '../socket';
|
import { connectSocket, disconnectSocket } from '../socket';
|
||||||
|
|
||||||
interface OnlineUser {
|
interface OnlineUser {
|
||||||
@@ -57,6 +59,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
|
|
||||||
const dropCleanupRef = useRef<(() => void) | null>(null);
|
const dropCleanupRef = useRef<(() => void) | null>(null);
|
||||||
const pasteCleanupRef = useRef<(() => void) | null>(null);
|
const pasteCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const laserCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!boardData || !resolvedBoardId) return;
|
if (!boardData || !resolvedBoardId) return;
|
||||||
@@ -70,6 +73,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
const selection = new SelectionManager(viewport, scene);
|
const selection = new SelectionManager(viewport, scene);
|
||||||
selectionRef.current = selection;
|
selectionRef.current = selection;
|
||||||
|
|
||||||
|
// Wire snap guides to transform box
|
||||||
|
selection.transformBox.setSnapGuides(selection.snapGuides);
|
||||||
|
|
||||||
// Wire selection change to update layer panel state
|
// Wire selection change to update layer panel state
|
||||||
selection.onSelectionChange = (ids: string[]) => {
|
selection.onSelectionChange = (ids: string[]) => {
|
||||||
setSelectedLayerIds(ids);
|
setSelectedLayerIds(ids);
|
||||||
@@ -176,6 +182,65 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
cursorTimer = setTimeout(() => { cursorTimer = null; }, 33);
|
cursorTimer = setTimeout(() => { cursorTimer = null; }, 33);
|
||||||
};
|
};
|
||||||
viewport.on('pointermove', onPointerMove);
|
viewport.on('pointermove', onPointerMove);
|
||||||
|
|
||||||
|
// ---- Laser pointer (hold L key) ----
|
||||||
|
const laserColorHex = userColor(user?.id || '');
|
||||||
|
const laserColorNum = parseInt(laserColorHex.replace('#', ''), 16);
|
||||||
|
const laser = new LaserPointer(viewport, laserColorNum);
|
||||||
|
|
||||||
|
let laserTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const onLaserMove = (e: any) => {
|
||||||
|
if (!laser.isActive) return;
|
||||||
|
const world = viewport.toWorld(e.global.x, e.global.y);
|
||||||
|
laser.addPoint(world.x, world.y);
|
||||||
|
// Throttle broadcast to ~50ms
|
||||||
|
if (laserTimer) return;
|
||||||
|
socket.volatile.emit('laser:move', {
|
||||||
|
boardId: resolvedBoardId,
|
||||||
|
points: [{ x: world.x, y: world.y }],
|
||||||
|
});
|
||||||
|
laserTimer = setTimeout(() => { laserTimer = null; }, 50);
|
||||||
|
};
|
||||||
|
viewport.on('pointermove', onLaserMove);
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'l' || e.key === 'L') {
|
||||||
|
if (laser.isActive) return;
|
||||||
|
laser.start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'l' || e.key === 'L') {
|
||||||
|
laser.stop();
|
||||||
|
socket.emit('laser:stop', { boardId: resolvedBoardId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
window.addEventListener('keyup', onKeyUp);
|
||||||
|
|
||||||
|
laserCleanupRef.current = () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
window.removeEventListener('keyup', onKeyUp);
|
||||||
|
laser.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for remote laser events
|
||||||
|
socket.on('laser:move', (data: any) => {
|
||||||
|
if (data.boardId !== resolvedBoardId) return;
|
||||||
|
const uid = data.userId;
|
||||||
|
if (!uid) return;
|
||||||
|
const color = parseInt(userColor(uid).replace('#', ''), 16);
|
||||||
|
laser.addRemotePoints(uid, data.points, color);
|
||||||
|
});
|
||||||
|
socket.on('laser:stop', (_data: any) => {
|
||||||
|
// Remote user stopped — points will fade naturally
|
||||||
|
});
|
||||||
|
socket.on('user:left', (data: any) => {
|
||||||
|
const uid = data.userId || data.id;
|
||||||
|
if (uid) {
|
||||||
|
laser.removeRemote(uid);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup drag/drop and paste
|
// Setup drag/drop and paste
|
||||||
@@ -208,6 +273,8 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
inboxZoneRef.current = null;
|
inboxZoneRef.current = null;
|
||||||
}
|
}
|
||||||
syncRef.current?.cleanup();
|
syncRef.current?.cleanup();
|
||||||
|
laserCleanupRef.current?.();
|
||||||
|
laserCleanupRef.current = null;
|
||||||
dropCleanupRef.current?.();
|
dropCleanupRef.current?.();
|
||||||
pasteCleanupRef.current?.();
|
pasteCleanupRef.current?.();
|
||||||
disconnectSocket();
|
disconnectSocket();
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import type { Socket } from 'socket.io-client';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
|
||||||
|
interface FollowState {
|
||||||
|
followingUserId: string | null;
|
||||||
|
followingDisplayName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFollowModeProps {
|
||||||
|
socket: Socket | null;
|
||||||
|
boardId: string | undefined;
|
||||||
|
getViewport: () => Viewport | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFollowModeReturn {
|
||||||
|
followingUserId: string | null;
|
||||||
|
followingDisplayName: string | null;
|
||||||
|
startFollowing: (userId: string, displayName: string) => void;
|
||||||
|
stopFollowing: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow mode — click a user's avatar to track their viewport in real-time.
|
||||||
|
* Broadcasts own viewport position so others can follow us.
|
||||||
|
* Pan/zoom manually to break free.
|
||||||
|
*/
|
||||||
|
export function useFollowMode({ socket, boardId, getViewport }: UseFollowModeProps): UseFollowModeReturn {
|
||||||
|
const [state, setState] = useState<FollowState>({
|
||||||
|
followingUserId: null,
|
||||||
|
followingDisplayName: null,
|
||||||
|
});
|
||||||
|
const followRef = useRef<string | null>(null);
|
||||||
|
const ignoreNextMove = useRef(false);
|
||||||
|
|
||||||
|
// Broadcast own viewport position (always, so others can follow us)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket || !boardId) return;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const vp = getViewport();
|
||||||
|
if (!vp) return;
|
||||||
|
socket.volatile.emit('viewport:sync', {
|
||||||
|
boardId,
|
||||||
|
x: vp.center.x,
|
||||||
|
y: vp.center.y,
|
||||||
|
scale: vp.scale.x,
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [socket, boardId, getViewport]);
|
||||||
|
|
||||||
|
// Listen for viewport:sync from followed user and animate to match
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const onViewportSync = (data: { boardId: string; userId: string; x: number; y: number; scale: number }) => {
|
||||||
|
if (data.boardId !== boardId) return;
|
||||||
|
const targetId = followRef.current;
|
||||||
|
if (!targetId || data.userId !== targetId) return;
|
||||||
|
|
||||||
|
const vp = getViewport();
|
||||||
|
if (!vp) return;
|
||||||
|
|
||||||
|
ignoreNextMove.current = true;
|
||||||
|
vp.animate({
|
||||||
|
time: 200,
|
||||||
|
position: { x: data.x, y: data.y },
|
||||||
|
scale: data.scale,
|
||||||
|
ease: 'easeOutQuad',
|
||||||
|
callbackOnComplete: () => {
|
||||||
|
// Brief delay before re-enabling break-free detection
|
||||||
|
setTimeout(() => { ignoreNextMove.current = false; }, 50);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('viewport:sync', onViewportSync);
|
||||||
|
return () => { socket.off('viewport:sync', onViewportSync); };
|
||||||
|
}, [socket, boardId, getViewport]);
|
||||||
|
|
||||||
|
// Detect manual pan/zoom to break free from follow mode
|
||||||
|
useEffect(() => {
|
||||||
|
const vp = getViewport();
|
||||||
|
if (!vp) return;
|
||||||
|
|
||||||
|
const onMoved = () => {
|
||||||
|
if (ignoreNextMove.current) return;
|
||||||
|
if (followRef.current) {
|
||||||
|
followRef.current = null;
|
||||||
|
setState({ followingUserId: null, followingDisplayName: null });
|
||||||
|
socket?.emit('follow:stop', { boardId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
vp.on('moved-end', onMoved);
|
||||||
|
return () => { vp.off('moved-end', onMoved); };
|
||||||
|
}, [getViewport, socket, boardId]);
|
||||||
|
|
||||||
|
const startFollowing = useCallback((userId: string, displayName: string) => {
|
||||||
|
// Toggle: if already following this user, stop
|
||||||
|
if (followRef.current === userId) {
|
||||||
|
followRef.current = null;
|
||||||
|
setState({ followingUserId: null, followingDisplayName: null });
|
||||||
|
socket?.emit('follow:stop', { boardId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
followRef.current = userId;
|
||||||
|
setState({ followingUserId: userId, followingDisplayName: displayName });
|
||||||
|
socket?.emit('follow:start', { boardId, targetUserId: userId });
|
||||||
|
}, [socket, boardId]);
|
||||||
|
|
||||||
|
const stopFollowing = useCallback(() => {
|
||||||
|
followRef.current = null;
|
||||||
|
setState({ followingUserId: null, followingDisplayName: null });
|
||||||
|
socket?.emit('follow:stop', { boardId });
|
||||||
|
}, [socket, boardId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
followingUserId: state.followingUserId,
|
||||||
|
followingDisplayName: state.followingDisplayName,
|
||||||
|
startFollowing,
|
||||||
|
stopFollowing,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
|
||||||
|
import type { SelectionManager } from '../canvas/SelectionManager';
|
||||||
|
import type { SceneItem } from '../canvas/SceneManager';
|
||||||
|
import type { GroupObject } from '../canvas/scene-format';
|
||||||
|
|
||||||
|
interface LayerItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
isGroup: boolean;
|
||||||
|
children?: LayerItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayerPanelDeps {
|
||||||
|
canvasRef: React.RefObject<PixiCanvasHandle | null>;
|
||||||
|
selectionRef: React.RefObject<SelectionManager | null>;
|
||||||
|
onCanvasChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoName(item: SceneItem, i: number, counters: Record<string, number>): string {
|
||||||
|
if (item.data.name) return item.data.name;
|
||||||
|
const type = item.data.type;
|
||||||
|
if (type === 'image') {
|
||||||
|
counters.image = (counters.image || 0) + 1;
|
||||||
|
return `Image ${counters.image}`;
|
||||||
|
}
|
||||||
|
if (type === 'text') {
|
||||||
|
const textData = item.data as any;
|
||||||
|
return (textData.text || 'Text').slice(0, 20);
|
||||||
|
}
|
||||||
|
if (type === 'group') {
|
||||||
|
const groupData = item.data as GroupObject;
|
||||||
|
return `Group (${groupData.children?.length || 0})`;
|
||||||
|
}
|
||||||
|
return `${type} ${i + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLayerItem(
|
||||||
|
item: SceneItem,
|
||||||
|
i: number,
|
||||||
|
counters: Record<string, number>,
|
||||||
|
getScene: () => ReturnType<PixiCanvasHandle['getScene']>,
|
||||||
|
): LayerItem {
|
||||||
|
const isGroup = item.data.type === 'group';
|
||||||
|
let children: LayerItem[] | undefined;
|
||||||
|
if (isGroup) {
|
||||||
|
const groupData = item.data as GroupObject;
|
||||||
|
const scene = getScene();
|
||||||
|
if (scene && groupData.children) {
|
||||||
|
children = groupData.children.map((childId, ci) => {
|
||||||
|
const childItem = scene.getById(childId);
|
||||||
|
if (childItem) return buildLayerItem(childItem, ci, counters, getScene);
|
||||||
|
return { id: childId, name: `Missing ${childId.slice(0, 6)}`, type: 'unknown', visible: true, locked: false, isGroup: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: autoName(item, i, counters),
|
||||||
|
type: item.data.type,
|
||||||
|
visible: item.data.visible,
|
||||||
|
locked: item.data.locked,
|
||||||
|
isGroup,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer panel handlers — provides refresh function and all layer panel callbacks.
|
||||||
|
*/
|
||||||
|
export function useLayerPanel(deps: LayerPanelDeps) {
|
||||||
|
const { canvasRef, selectionRef, onCanvasChange } = deps;
|
||||||
|
const nameCounters = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
const refreshLayers = useCallback((): LayerItem[] => {
|
||||||
|
const scene = canvasRef.current?.getScene();
|
||||||
|
if (!scene) return [];
|
||||||
|
nameCounters.current = {};
|
||||||
|
const items = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
|
||||||
|
return items.map((item, i) =>
|
||||||
|
buildLayerItem(item, i, nameCounters.current, () => canvasRef.current?.getScene() ?? null),
|
||||||
|
);
|
||||||
|
}, [canvasRef]);
|
||||||
|
|
||||||
|
const onSelect = useCallback((id: string) => {
|
||||||
|
selectionRef.current?.selectOnly(id);
|
||||||
|
}, [selectionRef]);
|
||||||
|
|
||||||
|
const onToggleVisible = useCallback((id: string) => {
|
||||||
|
const scene = canvasRef.current?.getScene();
|
||||||
|
if (!scene) return;
|
||||||
|
const item = scene.getById(id);
|
||||||
|
if (item) {
|
||||||
|
item.data.visible = !item.data.visible;
|
||||||
|
item.displayObject.visible = item.data.visible;
|
||||||
|
onCanvasChange();
|
||||||
|
}
|
||||||
|
}, [canvasRef, onCanvasChange]);
|
||||||
|
|
||||||
|
const onToggleLock = useCallback((id: string) => {
|
||||||
|
const scene = canvasRef.current?.getScene();
|
||||||
|
if (!scene) return;
|
||||||
|
const item = scene.getById(id);
|
||||||
|
if (item) {
|
||||||
|
item.data.locked = !item.data.locked;
|
||||||
|
item.displayObject.eventMode = item.data.locked ? 'none' : 'static';
|
||||||
|
}
|
||||||
|
}, [canvasRef]);
|
||||||
|
|
||||||
|
const onReorder = useCallback((from: number, to: number) => {
|
||||||
|
const scene = canvasRef.current?.getScene();
|
||||||
|
if (!scene) return;
|
||||||
|
const items = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
|
||||||
|
if (from < 0 || from >= items.length || to < 0 || to >= items.length) return;
|
||||||
|
const tmp = items[from].data.z;
|
||||||
|
items[from].data.z = items[to].data.z;
|
||||||
|
items[to].data.z = tmp;
|
||||||
|
scene._applyZOrder();
|
||||||
|
onCanvasChange();
|
||||||
|
}, [canvasRef, onCanvasChange]);
|
||||||
|
|
||||||
|
const onDelete = useCallback((id: string) => {
|
||||||
|
const scene = canvasRef.current?.getScene();
|
||||||
|
if (!scene) return;
|
||||||
|
scene.removeItem(id, true);
|
||||||
|
selectionRef.current?.clear();
|
||||||
|
onCanvasChange();
|
||||||
|
}, [canvasRef, selectionRef, onCanvasChange]);
|
||||||
|
|
||||||
|
const onRename = useCallback((id: string, name: string) => {
|
||||||
|
const scene = canvasRef.current?.getScene();
|
||||||
|
if (!scene) return;
|
||||||
|
const item = scene.getById(id);
|
||||||
|
if (item) {
|
||||||
|
item.data.name = name;
|
||||||
|
// Also update frame label for groups
|
||||||
|
if (item.data.type === 'group') {
|
||||||
|
(item.data as any).label = name;
|
||||||
|
if (item.displayObject && 'setLabel' in item.displayObject) {
|
||||||
|
(item.displayObject as any).setLabel(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onCanvasChange(); // Broadcast rename to other clients
|
||||||
|
}
|
||||||
|
}, [canvasRef, onCanvasChange]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
refreshLayers,
|
||||||
|
layerHandlers: {
|
||||||
|
onSelect,
|
||||||
|
onToggleVisible,
|
||||||
|
onToggleLock,
|
||||||
|
onReorder,
|
||||||
|
onDelete,
|
||||||
|
onRename,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { saveCanvas } from '../api';
|
||||||
|
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
|
||||||
|
import type { SaveStatus } from '../components/StatusBar';
|
||||||
|
|
||||||
|
interface SaveManagerOptions {
|
||||||
|
resolvedBoardId: string | undefined;
|
||||||
|
isPublicView?: boolean;
|
||||||
|
canvasRef: React.RefObject<PixiCanvasHandle | null>;
|
||||||
|
setSaveStatus: (s: SaveStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced save with thumbnail generation from PixiJS renderer.
|
||||||
|
*/
|
||||||
|
export function useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSaveStatus }: SaveManagerOptions) {
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const scheduleSave = useCallback(() => {
|
||||||
|
if (!resolvedBoardId || isPublicView) return;
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(async () => {
|
||||||
|
const scene = canvasRef.current?.getScene();
|
||||||
|
if (!scene) return;
|
||||||
|
setSaveStatus('saving');
|
||||||
|
try {
|
||||||
|
const state = JSON.stringify(scene.serialize());
|
||||||
|
|
||||||
|
// Generate thumbnail from PixiJS renderer
|
||||||
|
let thumbnail: string | undefined;
|
||||||
|
try {
|
||||||
|
const app = canvasRef.current?.getApp();
|
||||||
|
const viewport = canvasRef.current?.getViewport();
|
||||||
|
if (app?.renderer?.extract && viewport) {
|
||||||
|
const fullCanvas = app.renderer.extract.canvas(viewport) as HTMLCanvasElement;
|
||||||
|
const thumbMax = 400;
|
||||||
|
const sw = fullCanvas.width;
|
||||||
|
const sh = fullCanvas.height;
|
||||||
|
if (sw > 0 && sh > 0) {
|
||||||
|
const ratio = Math.min(thumbMax / sw, thumbMax / sh, 1);
|
||||||
|
const tw = Math.round(sw * ratio);
|
||||||
|
const th = Math.round(sh * ratio);
|
||||||
|
const offscreen = document.createElement('canvas');
|
||||||
|
offscreen.width = tw;
|
||||||
|
offscreen.height = th;
|
||||||
|
const ctx = offscreen.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(fullCanvas, 0, 0, tw, th);
|
||||||
|
thumbnail = offscreen.toDataURL('image/webp', 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (thumbErr) {
|
||||||
|
console.warn('Thumbnail generation failed:', thumbErr);
|
||||||
|
}
|
||||||
|
await saveCanvas(resolvedBoardId, state, thumbnail);
|
||||||
|
setSaveStatus('saved');
|
||||||
|
} catch {
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}, [resolvedBoardId, isPublicView, canvasRef, setSaveStatus]);
|
||||||
|
|
||||||
|
return { scheduleSave, saveTimerRef };
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
|
||||||
|
import type { SelectionManager } from '../canvas/SelectionManager';
|
||||||
|
import type { UndoManager } from '../canvas/history';
|
||||||
|
import type { SceneItem } from '../canvas/SceneManager';
|
||||||
|
import { getItemWorldBounds } from '../canvas/SceneManager';
|
||||||
|
import { ToolType, toolShortcuts } from '../canvas/tools';
|
||||||
|
import { ShortcutContext, matchesShortcut, sortBySpecificity } from '../canvas/shortcuts';
|
||||||
|
import { shortcuts as shortcutDefs } from '../canvas/shortcut-definitions';
|
||||||
|
import { writeCanvasToClipboard, pasteFromSystemClipboard } from '../canvas/clipboard';
|
||||||
|
|
||||||
|
// Pre-sort shortcuts by specificity (most modifiers first) for correct matching
|
||||||
|
const sortedShortcuts = sortBySpecificity(shortcutDefs);
|
||||||
|
|
||||||
|
interface ShortcutHandlerDeps {
|
||||||
|
canvasRef: React.RefObject<PixiCanvasHandle | null>;
|
||||||
|
selectionRef: React.RefObject<SelectionManager | null>;
|
||||||
|
undoRef: React.RefObject<UndoManager | null>;
|
||||||
|
clipboardRef: React.MutableRefObject<SceneItem[]>;
|
||||||
|
resolvedBoardId: string | undefined;
|
||||||
|
onCanvasChange: () => void;
|
||||||
|
showToast: (msg: string) => void;
|
||||||
|
refreshLayers: () => void;
|
||||||
|
handleGroup: () => void;
|
||||||
|
handleUngroup: () => void;
|
||||||
|
setActiveTool: (t: ToolType) => void;
|
||||||
|
setCanUndo: (v: boolean) => void;
|
||||||
|
setCanRedo: (v: boolean) => void;
|
||||||
|
setZoom: (z: number) => void;
|
||||||
|
setShowGrid: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setFocusMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard shortcut handler — registry-based, delegates to shortcut-definitions.
|
||||||
|
*/
|
||||||
|
export function useShortcutHandler(deps: ShortcutHandlerDeps) {
|
||||||
|
const {
|
||||||
|
canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId,
|
||||||
|
onCanvasChange, showToast, refreshLayers,
|
||||||
|
handleGroup, handleUngroup,
|
||||||
|
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
||||||
|
setShowGrid, setShowHelp, setFocusMode,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
|
||||||
|
const scene = canvasRef.current?.getScene();
|
||||||
|
const selection = selectionRef.current;
|
||||||
|
const viewport = canvasRef.current?.getViewport();
|
||||||
|
if (!scene || !selection || !viewport) return;
|
||||||
|
|
||||||
|
// Tool shortcuts (bare keys 1-5, v/h/p/t/e -- no modifiers)
|
||||||
|
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
|
const tool = toolShortcuts[e.key.toLowerCase()];
|
||||||
|
if (tool) {
|
||||||
|
setActiveTool(tool);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build context for shortcut handlers
|
||||||
|
const ctx: ShortcutContext = {
|
||||||
|
scene,
|
||||||
|
selection,
|
||||||
|
history: undoRef.current!,
|
||||||
|
viewport,
|
||||||
|
onChange: onCanvasChange,
|
||||||
|
clipboardRef,
|
||||||
|
writeCanvasToClipboard: async (items?: SceneItem[]) => {
|
||||||
|
try {
|
||||||
|
const app = canvasRef.current?.getApp() ?? null;
|
||||||
|
await writeCanvasToClipboard(app, viewport, items);
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('Copy failed: ' + (err.message || 'clipboard not available'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showToast,
|
||||||
|
fitAll: () => canvasRef.current?.fitAll(),
|
||||||
|
setActiveTool,
|
||||||
|
setCanUndo,
|
||||||
|
setCanRedo,
|
||||||
|
refreshLayers,
|
||||||
|
handleGroup,
|
||||||
|
handleUngroup,
|
||||||
|
toggleGrid: () => setShowGrid((v) => !v),
|
||||||
|
toggleShowHelp: () => setShowHelp((v) => !v),
|
||||||
|
toggleFocusMode: () => setFocusMode((v) => !v),
|
||||||
|
pasteFromSystemClipboard: async (): Promise<string> => {
|
||||||
|
if (!resolvedBoardId) return 'No board';
|
||||||
|
const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange);
|
||||||
|
if (msg !== 'No image in clipboard') showToast(msg);
|
||||||
|
return msg;
|
||||||
|
},
|
||||||
|
fitSelection: () => {
|
||||||
|
const vp = canvasRef.current?.getViewport();
|
||||||
|
if (!vp || !selection) return;
|
||||||
|
const items = selection.getSelectedItems();
|
||||||
|
if (items.length === 0) return;
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const item of items) {
|
||||||
|
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||||
|
if (ix < minX) minX = ix;
|
||||||
|
if (iy < minY) minY = iy;
|
||||||
|
if (ix + iw > maxX) maxX = ix + iw;
|
||||||
|
if (iy + ih > maxY) maxY = iy + ih;
|
||||||
|
}
|
||||||
|
const padding = 60;
|
||||||
|
const contentW = maxX - minX + padding * 2;
|
||||||
|
const contentH = maxY - minY + padding * 2;
|
||||||
|
const scaleX = vp.screenWidth / contentW;
|
||||||
|
const scaleY = vp.screenHeight / contentH;
|
||||||
|
const targetScale = Math.min(scaleX, scaleY, 5);
|
||||||
|
vp.animate({
|
||||||
|
position: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
|
||||||
|
scale: targetScale,
|
||||||
|
time: 300,
|
||||||
|
ease: 'easeOutQuad',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Match against sorted registry (most-specific first)
|
||||||
|
for (const def of sortedShortcuts) {
|
||||||
|
if (!matchesShortcut(e, def)) continue;
|
||||||
|
|
||||||
|
// Check selection requirements
|
||||||
|
const activeCount = selection.selectedIds.size;
|
||||||
|
if (def.needsSelection && activeCount === 0) continue;
|
||||||
|
if (def.minSelection && activeCount < def.minSelection) continue;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
await def.handler(ctx);
|
||||||
|
|
||||||
|
// Update zoom display after any action
|
||||||
|
setZoom(canvasRef.current?.getZoom() ?? 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [
|
||||||
|
canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId,
|
||||||
|
onCanvasChange, showToast, refreshLayers,
|
||||||
|
handleGroup, handleUngroup,
|
||||||
|
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
||||||
|
setShowGrid, setShowHelp, setFocusMode,
|
||||||
|
]);
|
||||||
|
}
|
||||||
+143
-50
@@ -22,6 +22,7 @@ import SelectionToolbar from '../components/SelectionToolbar';
|
|||||||
import VideoControls from '../components/VideoControls';
|
import VideoControls from '../components/VideoControls';
|
||||||
import ShortcutsHelp from '../components/ShortcutsHelp';
|
import ShortcutsHelp from '../components/ShortcutsHelp';
|
||||||
import MattermostImport from '../components/MattermostImport';
|
import MattermostImport from '../components/MattermostImport';
|
||||||
|
import Minimap from '../components/Minimap';
|
||||||
import { InboxZone } from '../canvas/InboxZone';
|
import { InboxZone } from '../canvas/InboxZone';
|
||||||
import { getItemWorldBounds } from '../canvas/SceneManager';
|
import { getItemWorldBounds } from '../canvas/SceneManager';
|
||||||
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
||||||
@@ -33,6 +34,7 @@ import { useSaveManager } from '../hooks/useSaveManager';
|
|||||||
import { useCanvasSetup } from '../hooks/useCanvasSetup';
|
import { useCanvasSetup } from '../hooks/useCanvasSetup';
|
||||||
import { useShortcutHandler } from '../hooks/useShortcutHandler';
|
import { useShortcutHandler } from '../hooks/useShortcutHandler';
|
||||||
import { useLayerPanel } from '../hooks/useLayerPanel';
|
import { useLayerPanel } from '../hooks/useLayerPanel';
|
||||||
|
import { useFollowMode } from '../hooks/useFollowMode';
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
isPublicView?: boolean;
|
isPublicView?: boolean;
|
||||||
@@ -73,7 +75,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]);
|
const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]);
|
||||||
const [showLayers, setShowLayers] = useState(false);
|
const [showLayers, setShowLayers] = useState(false);
|
||||||
const [showGrid, setShowGrid] = useState(false);
|
const [showGrid, setShowGrid] = useState(true);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [showMmImport, setShowMmImport] = useState(false);
|
const [showMmImport, setShowMmImport] = useState(false);
|
||||||
const [focusMode, setFocusMode] = useState(false);
|
const [focusMode, setFocusMode] = useState(false);
|
||||||
@@ -81,6 +83,10 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||||
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
|
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
|
||||||
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
|
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
|
||||||
|
const [showMinimap, setShowMinimap] = useState(true);
|
||||||
|
const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({
|
||||||
|
items: [], viewportBounds: { x: 0, y: 0, w: 1, h: 1 }, contentBounds: { x: 0, y: 0, w: 1, h: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
// Derived
|
// Derived
|
||||||
const { boardData, loading, error } = useBoardLoader(boardId);
|
const { boardData, loading, error } = useBoardLoader(boardId);
|
||||||
@@ -112,6 +118,14 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
if (vp) setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]);
|
if (vp) setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]);
|
||||||
}, [scheduleSave]);
|
}, [scheduleSave]);
|
||||||
|
|
||||||
|
// Follow mode
|
||||||
|
const getViewport = useCallback(() => canvasRef.current?.getViewport() ?? null, []);
|
||||||
|
const { followingUserId, followingDisplayName, startFollowing, stopFollowing } = useFollowMode({
|
||||||
|
socket: getSocket(),
|
||||||
|
boardId: resolvedBoardId,
|
||||||
|
getViewport,
|
||||||
|
});
|
||||||
|
|
||||||
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox)
|
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox)
|
||||||
useCanvasSetup({
|
useCanvasSetup({
|
||||||
boardData, resolvedBoardId, user, isPublicView,
|
boardData, resolvedBoardId, user, isPublicView,
|
||||||
@@ -156,11 +170,8 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
}, [refreshLayerData]);
|
}, [refreshLayerData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showLayers) return;
|
if (showLayers) refreshLayers();
|
||||||
refreshLayers();
|
}, [showLayers, refreshLayers, selectedLayerIds]);
|
||||||
const interval = setInterval(refreshLayers, 500);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [showLayers, refreshLayers]);
|
|
||||||
|
|
||||||
// Grouping
|
// Grouping
|
||||||
const handleGroup = useCallback(() => {
|
const handleGroup = useCallback(() => {
|
||||||
@@ -240,17 +251,16 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
}, [resolvedBoardId]);
|
}, [resolvedBoardId]);
|
||||||
|
|
||||||
// Zoom poll + selection toolbar position tracking
|
// Update UI overlays (selection toolbar, video controls, minimap) on demand
|
||||||
useEffect(() => {
|
const updateOverlays = useCallback(() => {
|
||||||
const interval = setInterval(() => {
|
|
||||||
setZoom(canvasRef.current?.getZoom() ?? 1);
|
|
||||||
|
|
||||||
// Update selection toolbar position
|
|
||||||
const selection = selectionRef.current;
|
const selection = selectionRef.current;
|
||||||
const vp = canvasRef.current?.getViewport();
|
const vp = canvasRef.current?.getViewport();
|
||||||
if (!selection || !vp) { setSelToolbar(null); setVideoCtrl(null); return; }
|
if (!selection || !vp) { setSelToolbar(null); setVideoCtrl(null); return; }
|
||||||
const items = selection.getSelectedItems();
|
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
|
// Video controls: show when exactly 1 video is selected
|
||||||
if (items.length === 1 && items[0].type === 'video' && items[0].displayObject instanceof VideoSprite) {
|
if (items.length === 1 && items[0].type === 'video' && items[0].displayObject instanceof VideoSprite) {
|
||||||
const vs = items[0].displayObject as VideoSprite;
|
const vs = items[0].displayObject as VideoSprite;
|
||||||
@@ -270,8 +280,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
setVideoCtrl(null);
|
setVideoCtrl(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.length < 2) { setSelToolbar(null); return; }
|
if (items.length < 2) { setSelToolbar(null); } else {
|
||||||
|
|
||||||
// Compute world bounding box of selection
|
// Compute world bounding box of selection
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -280,17 +289,65 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
if (b.y < minY) minY = b.y;
|
if (b.y < minY) minY = b.y;
|
||||||
if (b.x + b.w > maxX) maxX = b.x + b.w;
|
if (b.x + b.w > maxX) maxX = b.x + b.w;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to screen
|
|
||||||
const screenTL = vp.toScreen(minX, minY);
|
const screenTL = vp.toScreen(minX, minY);
|
||||||
const screenTR = vp.toScreen(maxX, minY);
|
const screenTR = vp.toScreen(maxX, minY);
|
||||||
const sx = (screenTL.x + screenTR.x) / 2;
|
setSelToolbar({ x: (screenTL.x + screenTR.x) / 2, y: screenTL.y, count: items.length });
|
||||||
const sy = screenTL.y;
|
}
|
||||||
setSelToolbar({ x: sx, y: sy, count: items.length });
|
|
||||||
}, 100);
|
// Minimap data
|
||||||
return () => clearInterval(interval);
|
const scene = canvasRef.current?.getScene();
|
||||||
|
if (scene) {
|
||||||
|
const allItems = scene.getTopLevelItems();
|
||||||
|
const mapItems = allItems.map((it) => {
|
||||||
|
const b = getItemWorldBounds(it);
|
||||||
|
return { x: b.x, y: b.y, w: b.w, h: b.h, type: it.type, id: it.id };
|
||||||
|
});
|
||||||
|
const topLeft = vp.toWorld(0, 0);
|
||||||
|
const botRight = vp.toWorld(vp.screenWidth, vp.screenHeight);
|
||||||
|
const vpBounds = { x: topLeft.x, y: topLeft.y, w: botRight.x - topLeft.x, h: botRight.y - topLeft.y };
|
||||||
|
let cMinX = Infinity, cMinY = Infinity, cMaxX = -Infinity, cMaxY = -Infinity;
|
||||||
|
for (const it of mapItems) {
|
||||||
|
if (it.x < cMinX) cMinX = it.x;
|
||||||
|
if (it.y < cMinY) cMinY = it.y;
|
||||||
|
if (it.x + it.w > cMaxX) cMaxX = it.x + it.w;
|
||||||
|
if (it.y + it.h > cMaxY) cMaxY = it.y + it.h;
|
||||||
|
}
|
||||||
|
if (mapItems.length === 0) { cMinX = 0; cMinY = 0; cMaxX = 1; cMaxY = 1; }
|
||||||
|
setMinimapData({
|
||||||
|
items: mapItems,
|
||||||
|
viewportBounds: vpBounds,
|
||||||
|
contentBounds: { x: cMinX, y: cMinY, w: cMaxX - cMinX, h: cMaxY - cMinY },
|
||||||
|
});
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Listen to viewport moved event for overlay updates (throttled)
|
||||||
|
// Depends on objectCount so it re-runs after canvas init (viewport becomes available)
|
||||||
|
useEffect(() => {
|
||||||
|
const vp = canvasRef.current?.getViewport();
|
||||||
|
if (!vp) return;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
const onMoved = () => {
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(() => { rafId = null; updateOverlays(); });
|
||||||
|
};
|
||||||
|
vp.on('moved', onMoved);
|
||||||
|
// Also listen to wheel events directly for immediate zoom feedback
|
||||||
|
const onWheel = () => {
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(() => { rafId = null; updateOverlays(); });
|
||||||
|
};
|
||||||
|
vp.on('wheel-scroll', onWheel);
|
||||||
|
return () => { vp.off('moved', onMoved); vp.off('wheel-scroll', onWheel); if (rafId) cancelAnimationFrame(rafId); };
|
||||||
|
}, [updateOverlays, objectCount]);
|
||||||
|
|
||||||
|
// Also update overlays when selection or scene changes
|
||||||
|
useEffect(() => {
|
||||||
|
updateOverlays();
|
||||||
|
}, [selectedLayerIds, updateOverlays]);
|
||||||
|
|
||||||
|
// (PresenceOverlay removed — selection broadcast no longer needed)
|
||||||
|
|
||||||
// -- Render --
|
// -- Render --
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -399,6 +456,8 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
setZoom(z);
|
setZoom(z);
|
||||||
}}
|
}}
|
||||||
onlineUsers={onlineUsers}
|
onlineUsers={onlineUsers}
|
||||||
|
onUserClick={startFollowing}
|
||||||
|
followingUserId={followingUserId}
|
||||||
onToggleLayers={() => setShowLayers((v) => !v)}
|
onToggleLayers={() => setShowLayers((v) => !v)}
|
||||||
showLayers={showLayers}
|
showLayers={showLayers}
|
||||||
onToggleHelp={() => setShowHelp((v) => !v)}
|
onToggleHelp={() => setShowHelp((v) => !v)}
|
||||||
@@ -421,23 +480,32 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
canvasTransform={canvasTransform}
|
canvasTransform={canvasTransform}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Grid overlay */}
|
{/* Dot grid overlay — adapts spacing at zoom levels like Figma */}
|
||||||
{showGrid && (
|
{showGrid && (() => {
|
||||||
<svg
|
const scale = canvasTransform[0] || 1;
|
||||||
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 1 }}
|
const tx = canvasTransform[4] || 0;
|
||||||
width="100%" height="100%"
|
const ty = canvasTransform[5] || 0;
|
||||||
>
|
// Adaptive spacing: base 20px, doubles when dots get too dense, halves when too sparse
|
||||||
|
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 (
|
||||||
|
<svg style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 1 }} width="100%" height="100%">
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="grid50" width={50 * (canvasTransform[0] || 1)} height={50 * (canvasTransform[3] || 1)} patternUnits="userSpaceOnUse"
|
<pattern id="dotgrid" width={screenSpacing} height={screenSpacing} patternUnits="userSpaceOnUse" x={ox} y={oy}>
|
||||||
x={(canvasTransform[4] || 0) % (50 * (canvasTransform[0] || 1))}
|
<circle cx={dotR} cy={dotR} r={dotR} fill={`rgba(255,255,255,${alpha})`} />
|
||||||
y={(canvasTransform[5] || 0) % (50 * (canvasTransform[3] || 1))}>
|
|
||||||
<line x1="0" y1="0" x2={50 * (canvasTransform[0] || 1)} y2="0" stroke="rgba(255,255,255,0.04)" strokeWidth="1" />
|
|
||||||
<line x1="0" y1="0" x2="0" y2={50 * (canvasTransform[3] || 1)} stroke="rgba(255,255,255,0.04)" strokeWidth="1" />
|
|
||||||
</pattern>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="100%" height="100%" fill="url(#grid50)" />
|
<rect width="100%" height="100%" fill="url(#dotgrid)" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Empty canvas guide */}
|
{/* Empty canvas guide */}
|
||||||
{objectCount === 0 && (
|
{objectCount === 0 && (
|
||||||
@@ -459,23 +527,23 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
x={selToolbar.x}
|
x={selToolbar.x}
|
||||||
y={selToolbar.y}
|
y={selToolbar.y}
|
||||||
count={selToolbar.count}
|
count={selToolbar.count}
|
||||||
onAlignLeft={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignLeft(s); onCanvasChange(); } }}
|
onAlignLeft={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignLeft(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onAlignCenterH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterH(s); onCanvasChange(); } }}
|
onAlignCenterH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterH(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onAlignRight={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignRight(s); onCanvasChange(); } }}
|
onAlignRight={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignRight(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onAlignTop={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignTop(s); onCanvasChange(); } }}
|
onAlignTop={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignTop(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onAlignCenterV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterV(s); onCanvasChange(); } }}
|
onAlignCenterV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterV(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onAlignBottom={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignBottom(s); onCanvasChange(); } }}
|
onAlignBottom={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignBottom(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onDistributeH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeHorizontal(s); onCanvasChange(); } }}
|
onDistributeH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onDistributeV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeVertical(s); onCanvasChange(); } }}
|
onDistributeV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onPack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeOptimal(s); onCanvasChange(); } }}
|
onPack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeOptimal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onGrid={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeGrid(s); onCanvasChange(); } }}
|
onGrid={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeGrid(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onRow={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeRow(s); onCanvasChange(); } }}
|
onRow={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeRow(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onColumn={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeColumn(s); onCanvasChange(); } }}
|
onColumn={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeColumn(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onStack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.stackObjects(s); onCanvasChange(); } }}
|
onStack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.stackObjects(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onFlipH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipHorizontal(s); onCanvasChange(); } }}
|
onFlipH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onFlipV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipVertical(s); onCanvasChange(); } }}
|
onFlipV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
onGroup={handleGroup}
|
onGroup={handleGroup}
|
||||||
onNormSize={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.normalizeSize(s); onCanvasChange(); } }}
|
onNormSize={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.normalizeSize(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -487,6 +555,31 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Minimap */}
|
||||||
|
{showMinimap && !focusMode && objectCount > 0 && (
|
||||||
|
<Minimap
|
||||||
|
items={minimapData.items}
|
||||||
|
viewportBounds={minimapData.viewportBounds}
|
||||||
|
contentBounds={minimapData.contentBounds}
|
||||||
|
onNavigate={(wx, wy) => {
|
||||||
|
const vp = canvasRef.current?.getViewport();
|
||||||
|
if (vp) vp.animate({ time: 200, position: { x: wx, y: wy }, ease: 'easeOutQuad' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Follow mode banner */}
|
||||||
|
{followingDisplayName && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: '8px', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
padding: '4px 14px', background: 'rgba(74, 144, 217, 0.9)', borderRadius: '8px',
|
||||||
|
color: '#fff', fontSize: '12px', fontWeight: 500, zIndex: 200,
|
||||||
|
cursor: 'pointer', pointerEvents: 'auto',
|
||||||
|
}} onClick={stopFollowing}>
|
||||||
|
Following {followingDisplayName} — click to stop
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Context menu */}
|
{/* Context menu */}
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|||||||
Reference in New Issue
Block a user