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:
@@ -116,6 +116,8 @@ function getImageUrl(minioPath) {
|
||||
return `/api/images/${minioPath}`;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE_MB || '200', 10) * 1024 * 1024;
|
||||
|
||||
module.exports = {
|
||||
minioClient,
|
||||
initBucket,
|
||||
@@ -126,4 +128,5 @@ module.exports = {
|
||||
getImageUrl,
|
||||
MINIO_BUCKET,
|
||||
MIME_TO_EXT,
|
||||
MAX_FILE_SIZE,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ const {
|
||||
createImage, getImageByMmFileId,
|
||||
} = require('../db');
|
||||
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
|
||||
const { generateLOD } = require('../services/lod-generator');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const router = Router();
|
||||
@@ -94,44 +93,23 @@ function classifyMedia(mimeType) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload image buffer with LOD tiers to MinIO.
|
||||
* Upload a media file (image or video) to MinIO as a single file.
|
||||
* GPU handles all scaling natively — no LOD tiers needed.
|
||||
*/
|
||||
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) {
|
||||
const assetKey = `boards/${boardId}/${imageId}`;
|
||||
const originalExt = MIME_TO_EXT[mimetype] || '.bin';
|
||||
async function uploadMedia(boardId, imageId, buffer, mimetype) {
|
||||
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
||||
const minioPath = `boards/${boardId}/${imageId}${ext}`;
|
||||
await putBuffer(minioPath, buffer, mimetype);
|
||||
|
||||
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') {
|
||||
const fullPath = `${assetKey}/full${originalExt}`;
|
||||
await putBuffer(fullPath, buffer, mimetype);
|
||||
let width = null, height = null;
|
||||
let width = null, height = null;
|
||||
if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) {
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
width = metadata.width || null;
|
||||
height = metadata.height || null;
|
||||
} catch {}
|
||||
return { assetKey, minioPath: fullPath, width, height };
|
||||
}
|
||||
|
||||
const lod = await generateLOD(buffer, originalExt);
|
||||
await Promise.all([
|
||||
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
|
||||
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
|
||||
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
|
||||
]);
|
||||
|
||||
const minioPath = `${assetKey}/full${lod.full.ext}`;
|
||||
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload video buffer to MinIO (no LOD).
|
||||
*/
|
||||
async function uploadVideo(boardId, imageId, buffer, mimetype) {
|
||||
const assetKey = `boards/${boardId}/${imageId}`;
|
||||
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
||||
const fullPath = `${assetKey}/full${ext}`;
|
||||
await putBuffer(fullPath, buffer, mimetype);
|
||||
return { assetKey, minioPath: fullPath, width: null, height: null };
|
||||
return { assetKey: minioPath, minioPath, width, height };
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
@@ -287,13 +265,7 @@ router.post('/:boardId/mm-pull', async (req, res) => {
|
||||
const imageId = uuidv4();
|
||||
const mediaType = classifyMedia(mimeType);
|
||||
|
||||
let assetKey, minioPath, width, height;
|
||||
|
||||
if (mediaType === 'video') {
|
||||
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimeType));
|
||||
} else {
|
||||
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimeType));
|
||||
}
|
||||
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType);
|
||||
|
||||
const publicUrl = getImageUrl(minioPath);
|
||||
|
||||
|
||||
+17
-61
@@ -7,8 +7,7 @@ const http = require('http');
|
||||
const { URL } = require('url');
|
||||
const { authMiddleware } = require('../auth');
|
||||
const { getBoard, getCollectionMember, createImage } = require('../db');
|
||||
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
|
||||
const { generateLOD } = require('../services/lod-generator');
|
||||
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -28,9 +27,9 @@ const VIDEO_MIME_TYPES = [
|
||||
|
||||
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES];
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
const MAX_FILE_SIZE_LABEL = `${MAX_FILE_SIZE / 1024 / 1024}MB`;
|
||||
|
||||
// Multer config: memory storage, 50MB limit, image types only
|
||||
// Multer config: memory storage, configurable size limit
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
@@ -90,46 +89,17 @@ function classifyMedia(mimeType) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload LOD tiers for an image to MinIO.
|
||||
* Stores: {assetKey}/thumb.webp, {assetKey}/medium.webp, {assetKey}/full{ext}
|
||||
* Returns the minioPath for the full tier (backward compat) and dimensions.
|
||||
* Upload a media file (image or video) to MinIO as a single file.
|
||||
* GPU handles all image scaling natively — no LOD tiers needed.
|
||||
*/
|
||||
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) {
|
||||
const assetKey = `boards/${boardId}/${imageId}`;
|
||||
const originalExt = MIME_TO_EXT[mimetype] || '.bin';
|
||||
|
||||
// SVG and GIF skip LOD — store single file
|
||||
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') {
|
||||
const fullPath = `${assetKey}/full${originalExt}`;
|
||||
await putBuffer(fullPath, buffer, mimetype);
|
||||
const dims = await getImageDimensions(buffer, mimetype);
|
||||
return { assetKey, minioPath: fullPath, ...dims };
|
||||
}
|
||||
|
||||
const lod = await generateLOD(buffer, originalExt);
|
||||
|
||||
// Upload all 3 tiers in parallel
|
||||
await Promise.all([
|
||||
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
|
||||
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
|
||||
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
|
||||
]);
|
||||
|
||||
// minioPath points to full tier for backward compat
|
||||
const minioPath = `${assetKey}/full${lod.full.ext}`;
|
||||
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a video file to MinIO (single file, no LOD).
|
||||
* Returns { assetKey, minioPath, width: null, height: null }.
|
||||
*/
|
||||
async function uploadVideo(boardId, imageId, buffer, mimetype) {
|
||||
const assetKey = `boards/${boardId}/${imageId}`;
|
||||
async function uploadMedia(boardId, imageId, buffer, mimetype) {
|
||||
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
||||
const fullPath = `${assetKey}/full${ext}`;
|
||||
await putBuffer(fullPath, buffer, mimetype);
|
||||
return { assetKey, minioPath: fullPath, width: null, height: null };
|
||||
const minioPath = `boards/${boardId}/${imageId}${ext}`;
|
||||
await putBuffer(minioPath, buffer, mimetype);
|
||||
|
||||
const dims = await getImageDimensions(buffer, mimetype);
|
||||
// assetKey = minioPath so frontend can build direct URLs
|
||||
return { assetKey: minioPath, minioPath, width: dims.width, height: dims.height };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,14 +119,7 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
|
||||
const { buffer, originalname, mimetype, size } = req.file;
|
||||
const mediaType = classifyMedia(mimetype);
|
||||
|
||||
let assetKey, minioPath, width, height;
|
||||
|
||||
if (mediaType === 'video') {
|
||||
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimetype));
|
||||
} else {
|
||||
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimetype));
|
||||
}
|
||||
|
||||
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimetype);
|
||||
const publicUrl = getImageUrl(minioPath);
|
||||
|
||||
// Save record
|
||||
@@ -188,7 +151,7 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ error: 'File too large (max 50MB)' });
|
||||
return res.status(413).json({ error: `File too large (max ${MAX_FILE_SIZE_LABEL})` });
|
||||
}
|
||||
console.error('[upload] error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -225,7 +188,7 @@ function downloadImage(imageUrl) {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_FILE_SIZE) {
|
||||
response.destroy();
|
||||
return reject(new Error('Downloaded file too large (max 50MB)'));
|
||||
return reject(new Error(`Downloaded file too large (max ${MAX_FILE_SIZE_LABEL})`));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
@@ -261,14 +224,7 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
|
||||
const imageId = uuidv4();
|
||||
const mediaType = classifyMedia(mimeType);
|
||||
|
||||
let assetKey, minioPath, width, height;
|
||||
|
||||
if (mediaType === 'video') {
|
||||
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimeType));
|
||||
} else {
|
||||
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimeType));
|
||||
}
|
||||
|
||||
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType);
|
||||
const publicUrl = getImageUrl(minioPath);
|
||||
|
||||
// Save record
|
||||
@@ -311,7 +267,7 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
|
||||
router.use((err, req, res, next) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ error: 'File too large (max 50MB)' });
|
||||
return res.status(413).json({ error: `File too large (max ${MAX_FILE_SIZE_LABEL})` });
|
||||
}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ const { URL } = require('url');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const sharp = require('sharp');
|
||||
const { getAllBoardChannelLinks, getImageByMmFileId, createImage, getBoard } = require('../db');
|
||||
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
|
||||
const { generateLOD } = require('./lod-generator');
|
||||
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
|
||||
|
||||
const MM_URL = process.env.MM_URL;
|
||||
const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN;
|
||||
@@ -87,7 +86,7 @@ function mmDownloadFile(fileId) {
|
||||
const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
||||
const chunks = [];
|
||||
let totalSize = 0;
|
||||
const MAX_SIZE = 50 * 1024 * 1024;
|
||||
const MAX_SIZE = MAX_FILE_SIZE;
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
totalSize += chunk.length;
|
||||
@@ -147,47 +146,23 @@ function classifyMedia(mimeType) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload image with LOD tiers (mirrors upload.js logic).
|
||||
* Upload a media file (image or video) to MinIO as a single file.
|
||||
* GPU handles all image scaling natively — no LOD tiers needed.
|
||||
*/
|
||||
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) {
|
||||
const assetKey = `boards/${boardId}/${imageId}`;
|
||||
const originalExt = MIME_TO_EXT[mimetype] || '.bin';
|
||||
|
||||
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') {
|
||||
const fullPath = `${assetKey}/full${originalExt}`;
|
||||
await putBuffer(fullPath, buffer, mimetype);
|
||||
let width = null, height = null;
|
||||
if (mimetype !== 'image/svg+xml') {
|
||||
try {
|
||||
const meta = await sharp(buffer).metadata();
|
||||
width = meta.width || null;
|
||||
height = meta.height || null;
|
||||
} catch {}
|
||||
}
|
||||
return { assetKey, minioPath: fullPath, width, height };
|
||||
}
|
||||
|
||||
const lod = await generateLOD(buffer, originalExt);
|
||||
|
||||
await Promise.all([
|
||||
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
|
||||
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
|
||||
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
|
||||
]);
|
||||
|
||||
const minioPath = `${assetKey}/full${lod.full.ext}`;
|
||||
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a video file (single file, no LOD).
|
||||
*/
|
||||
async function uploadVideo(boardId, imageId, buffer, mimetype) {
|
||||
const assetKey = `boards/${boardId}/${imageId}`;
|
||||
async function uploadMedia(boardId, imageId, buffer, mimetype) {
|
||||
const ext = MIME_TO_EXT[mimetype] || '.bin';
|
||||
const fullPath = `${assetKey}/full${ext}`;
|
||||
await putBuffer(fullPath, buffer, mimetype);
|
||||
return { assetKey, minioPath: fullPath, width: null, height: null };
|
||||
const minioPath = `boards/${boardId}/${imageId}${ext}`;
|
||||
await putBuffer(minioPath, buffer, mimetype);
|
||||
|
||||
let width = null, height = null;
|
||||
if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) {
|
||||
try {
|
||||
const meta = await sharp(buffer).metadata();
|
||||
width = meta.width || null;
|
||||
height = meta.height || null;
|
||||
} catch {}
|
||||
}
|
||||
return { assetKey: minioPath, minioPath, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,13 +214,7 @@ async function processLink(link) {
|
||||
const imageId = uuidv4();
|
||||
const mediaType = classifyMedia(mimeType);
|
||||
|
||||
let assetKey, minioPath, width, height;
|
||||
|
||||
if (mediaType === 'video') {
|
||||
({ assetKey, minioPath, width, height } = await uploadVideo(boardId, imageId, buffer, mimeType));
|
||||
} else {
|
||||
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(boardId, imageId, buffer, mimeType));
|
||||
}
|
||||
const { assetKey, minioPath, width, height } = await uploadMedia(boardId, imageId, buffer, mimeType);
|
||||
|
||||
const publicUrl = getImageUrl(minioPath);
|
||||
|
||||
|
||||
@@ -79,6 +79,26 @@ function setupBoardRoom(io, socket) {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Incremental element sync (Excalidraw-style) ----
|
||||
|
||||
socket.on('element:update', (data) => {
|
||||
if (!socket.currentBoardId) return;
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
socket.to(roomName).emit('element:update', {
|
||||
...data,
|
||||
userId: socket.userId,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('element:remove', (data) => {
|
||||
if (!socket.currentBoardId) return;
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
socket.to(roomName).emit('element:remove', {
|
||||
...data,
|
||||
userId: socket.userId,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Lightweight transform (during drag/resize/rotate) ----
|
||||
|
||||
socket.on('object:transform', (data) => {
|
||||
@@ -102,6 +122,32 @@ function setupBoardRoom(io, socket) {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Selection presence (broadcast what items a user has selected) ----
|
||||
|
||||
socket.on('selection:update', (data) => {
|
||||
if (!socket.currentBoardId) return;
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
socket.volatile.to(roomName).emit('selection:update', {
|
||||
...data,
|
||||
userId: socket.userId,
|
||||
displayName: socket.userDisplayName,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Laser pointer ----
|
||||
|
||||
socket.on('laser:move', (data) => {
|
||||
if (!socket.currentBoardId) return;
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
socket.volatile.to(roomName).emit('laser:move', { ...data, userId: socket.userId });
|
||||
});
|
||||
|
||||
socket.on('laser:stop', (data) => {
|
||||
if (!socket.currentBoardId) return;
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
socket.to(roomName).emit('laser:stop', { ...data, userId: socket.userId });
|
||||
});
|
||||
|
||||
// ---- Disconnect cleanup ----
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ const { Server } = require('socket.io');
|
||||
const { verifyToken } = require('../auth');
|
||||
const { getUserById } = require('../db');
|
||||
const { setupBoardRoom } = require('./board-room');
|
||||
const { setupViewportSync } = require('./viewport-sync');
|
||||
|
||||
/**
|
||||
* Set up Socket.IO on the given HTTP server.
|
||||
@@ -49,6 +50,7 @@ function setupSocket(httpServer) {
|
||||
|
||||
// Set up board room handlers
|
||||
setupBoardRoom(io, socket);
|
||||
setupViewportSync(io, socket);
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log(`[socket] User disconnected: ${socket.userDisplayName} (${reason})`);
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user