Borrowing the browser’s layout engine for a canvas
A template is drawn for a fixed amount of content: three games, one headline, a logo. Then someone types in seven games, or the agent returns more than the design has slots for, and everything has to move.
Fabric.js will not help you with that. It gives you objects with a left, a
top and a width, and it will happily let two of them sit on top of each
other, or run off the edge of the canvas. There is no layout engine — nothing
that knows a textbox is allowed to grow to here but no further, and nothing
that reflows its neighbours when it does.
Writing one is a genuinely hard problem. Then it occurred to me that I was sitting inside a program that had already solved it.
Ask the browser and copy the answer
The idea is small enough to say in one sentence: build the layout in hidden DOM, let the browser lay it out, then read the positions back and apply them to the canvas.
For every group of objects I create one proxy div sized to that group's
bounding box, and drop them into a container the size of the canvas:
const container = document.createElement('div')
container.style.cssText = [
'position:fixed', 'left:-99999px', 'top:-99999px',
`width:${canvasW}px`, `height:${canvasH}px`,
'display:flex', 'flex-wrap:wrap',
`justify-content:${preset.justifyContent}`,
].join(';')The browser reflows it. Then each proxy's offsetLeft and offsetTop are the
answer, and the difference between that and where the group currently sits is
the distance every object in it has to move:
const deltaX = div.offsetLeft - group.bounds.minX
inst.left += deltaXApplying the change as a delta rather than an absolute position is what makes
this survive contact with Fabric. An object's left may be relative to a parent
group, and its originX may be centre or right. Adding the same canvas-space
distance to whatever left already is translates it correctly in every one of
those cases. Setting an absolute position would need me to handle each.
Two layouts, and knowing when to give up on the first
Flex alone is not enough. It preserves the proportions the designer chose, which is what you want — but with enough content it simply runs off the bottom.
So there are two passes. Flex first; if the result overflows the canvas height, throw it away and lay the whole thing out again as a grid. The number of columns is chosen to keep roughly the canvas's own aspect ratio:
const C = Math.round(Math.sqrt(N * canvasWidth / dataHeight))Headers — anything whose id carries no number, like headline or school_name
— go in the top fifth. The numbered groups, event_1 through event_30, fill
the rest as grid cells, and inside each cell the font shrinks until the text fits
its share of the row.
Grouping is done off the object ids, because the ids already encode the
structure: event_1_info_1 and event_1_info_2 belong to event_1 and must
move as one thing. A logo with a matching number joins its group and takes the
left half of the cell.
The part I did not expect
The container is deliberately left in the page afterwards, with an id on it.
So when a layout comes out wrong, I open DevTools, find #gipper_reflow_debug
in the Elements panel, and change justify-content or grid-template-columns
the way I would on any other page. The container re-lays out live. Running the
reflow again copies whatever I settled on back onto the canvas.
A canvas is famously a black box — one element, no inspector, nothing to select. This one has a layout you can debug in the Elements panel, because the layout was never really happening on the canvas at all.