diff --git a/backend/routes/boards.js b/backend/routes/boards.js index 90517da..60cc43a 100644 --- a/backend/routes/boards.js +++ b/backend/routes/boards.js @@ -98,6 +98,26 @@ router.get('/:boardId', (req, res) => { if (!result) return; const { board, collection } = result; + + // Auto-convert Fabric v1 → v2 on first load + if (board.canvas_state) { + try { + const parsed = typeof board.canvas_state === 'string' + ? JSON.parse(board.canvas_state) + : board.canvas_state; + if (!parsed.v || parsed.v < 2) { + const { convertFabricToV2 } = require('../services/scene-converter'); + const v2 = convertFabricToV2(parsed); + const v2json = JSON.stringify(v2); + saveBoardCanvas(board.id, v2json, null); + board.canvas_state = v2json; + } + } catch (e) { + // Don't block board load on conversion failure + console.error('[boards] Scene conversion failed:', e); + } + } + const images = getBoardImages(board.id); return res.json({ diff --git a/backend/services/scene-converter.js b/backend/services/scene-converter.js new file mode 100644 index 0000000..5da70c3 --- /dev/null +++ b/backend/services/scene-converter.js @@ -0,0 +1,85 @@ +const { v4: uuidv4 } = require('uuid'); + +/** + * Convert Fabric.js v1 canvas JSON to v2 scene format. + * Flattens groups, normalises property names, and extracts asset keys from image URLs. + */ +function convertFabricToV2(fabricJson) { + const objects = []; + let z = 0; + + for (const obj of (fabricJson.objects || [])) { + const base = { + id: obj.id || uuidv4(), + x: obj.left ?? 0, + y: obj.top ?? 0, + w: obj.width ?? 0, + h: obj.height ?? 0, + sx: obj.scaleX ?? 1, + sy: obj.scaleY ?? 1, + angle: obj.angle ?? 0, + z: z++, + opacity: obj.opacity ?? 1, + locked: obj.selectable === false, + name: obj.name || '', + visible: obj.visible !== false, + }; + + if (obj.type === 'image') { + // Extract asset key from src URL + const src = obj.src || ''; + const match = src.match(/\/api\/images\/(boards\/[^/]+\/[^/]+)/); + const asset = match ? match[1] : src; + objects.push({ + ...base, + type: 'image', + asset, + filters: (obj.filters || []).map(f => (f.type || '').toLowerCase()).filter(Boolean), + }); + } else if (obj.type === 'i-text' || obj.type === 'text' || obj.type === 'textbox') { + objects.push({ + ...base, + type: 'text', + text: obj.text || '', + fontSize: obj.fontSize ?? 24, + fill: obj.fill ?? '#ffffff', + fontFamily: obj.fontFamily ?? 'sans-serif', + }); + } else if (obj.type === 'group') { + const childIds = []; + for (const child of (obj.objects || [])) { + const childId = child.id || uuidv4(); + childIds.push(childId); + // Add flattened child with adjusted coordinates + objects.push({ + id: childId, + type: child.type === 'image' ? 'image' : 'text', + x: (child.left ?? 0) + base.x, + y: (child.top ?? 0) + base.y, + w: child.width ?? 0, + h: child.height ?? 0, + sx: child.scaleX ?? 1, + sy: child.scaleY ?? 1, + angle: child.angle ?? 0, + z: z++, + opacity: child.opacity ?? 1, + locked: false, + name: child.name || '', + visible: true, + ...(child.type === 'image' ? { asset: child.src || '', filters: [] } : {}), + ...(child.type === 'i-text' ? { text: child.text || '', fontSize: 24, fill: '#fff', fontFamily: 'sans-serif' } : {}), + }); + } + objects.push({ ...base, type: 'group', children: childIds }); + } + // Skip paths (drawing tools deferred) + } + + return { + v: 2, + bg: fabricJson.background || '#1e1e1e', + objects, + }; +} + +module.exports = { convertFabricToV2 };