Add backend scene converter for auto-migrating Fabric v1 to v2 format

When a board is loaded via GET /api/boards/:id, if the canvas_state
lacks a v2 marker, it is converted in-place and persisted back to DB
so subsequent loads skip conversion.
This commit is contained in:
Hiren Kangad
2026-03-09 23:25:39 +05:30
parent edcac21983
commit c8743c9a56
2 changed files with 105 additions and 0 deletions
+20
View File
@@ -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({
+85
View File
@@ -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 };