feat(refboard): canvas polish — transform box sync, dot grid, snap tuning, perf fixes

- Fix transform box not updating after alignment/arrangement/normalize/flip operations
  (shortcuts, context menu, and selection toolbar all fixed with DRY _opUpdate helper)
- Replace 100ms polling loop with event-driven viewport updates (moved + wheel-scroll)
- Add adaptive dot grid background that responds to zoom/pan
- Reduce snap guide threshold from 8px to 4px for subtler snapping
- Remove PresenceOverlay (remote selection highlighting) — too heavy for minimal benefit
- Offset multiple dropped images so they don't overlap
- Add new canvas modules: SnapGuides, clipboard, grouping, FrameSprite, DrawingSprite,
  LaserPointer, context-menu-items, SelectionToolbar, Minimap
- Extract Editor hooks into dedicated files (useBoardLoader, useCanvasSetup,
  useShortcutHandler, useLayerPanel, useSaveManager, useFollowMode)
- Sync improvements: real-time transform broadcast, board rooms, viewport sync
This commit is contained in:
Hiren Kangad
2026-03-10 03:58:53 +05:30
parent cfd7adf73d
commit e9509ef7b0
41 changed files with 4171 additions and 705 deletions
+17 -61
View File
@@ -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 });
}