ArchDocs
Start free
Architecture as code

Forking Excalidraw for structured diagrams

How structured ERD and API cards share geometry across editing and export, and why sequence diagrams use a separate element.

AD
The ArchDocs team
October 23, 2026

Draw a table as a rectangle with text and attach an arrow to its edge. Reorder the columns and the arrow still points to the rectangle, even if you meant it to identify a foreign key.

We needed the editor to know which row an arrow targets. That led to a structured element in our Excalidraw fork, with row data and binding behavior that survive moving the card.

Store the row before drawing it

An ERD card is a structured-node with nodeKind: 'erd-entity'. Its rows have stable rowId values alongside labels and type information. A binding can store that row ID instead of guessing the row from the arrow's current position.

The shared getRowBands helper describes the visible rows. Rendering and pointer interaction use the same row geometry. The binding code can then calculate a row anchor when the entity moves. If the card is collapsed, visibleRows returns an empty array.

The column label remains editable. The row ID is what lets a binding keep its target when that label changes. If you're building a similar editor, keep display text out of the identity field.

Register behavior by kind

The common element handles the card machinery. A kind registers its own drawing and SVG functions through registerNodeKind. ERDs use the default row-stack height. API endpoints supply a measurement function because their sections wrap and disappear when empty.

This is the registry shape, shortened to the fields relevant here:

type NodeKindRenderer = {
  drawCanvas: (ctx, element, palette, iconImage?) => void
  buildSvgChildren: (element, palette, svgNs, doc) => SVGElement[]
  measure?: (element) => number
  cornerRadius?: (element) => number
  chrome?: 'card' | 'none'
}

The full implementation types each argument. Passing the document into the SVG builder also lets a test supply a DOM without opening the editor.

Sequence diagrams use a separate sequence-diagram element. They hold participants and ordered steps, with their own internal layout. A sequence message doesn't behave like a free arrow between ERD cards, so fitting it into the row-card registry would hide the differences the editor needs to handle. The ERD and sequence guide shows their different source shapes.

Measure the text that will actually render

Endpoint cards need more than a line count over the original strings. A long path can wrap in the header. A response description can wrap inside its section. Empty sections should consume no space.

In apiEndpoint.layout.ts, apiHeaderLayout and apiSectionDisplayLines calculate the lines used by measurement and rendering. Canvas and SVG consume those lines. Changing the wrap rule in only the canvas renderer would leave the SVG and measured bounds behind.

The layout tests check the last section line against the card's measured bottom edge. They also cover empty cards and collapsed cards. Those cases expose padding errors that a full card with short labels can conceal.

We size these cards from their content and omit their resize handles. That gives up free resizing, but it avoids letting a drag shrink the card beneath text whose layout still assumes the original width.

Dark canvas colors aren't SVG colors

The canvas dark theme applies an inversion filter. SVG export uses theme colors directly. Reusing the same header color in both paths would apply different transformations to it.

getCanvasPalette starts with the light palette and compensates the header colors for the canvas filter. getPalette supplies the theme's colors for SVG. The renderer receives a palette rather than choosing text colors inside each kind.

This is an awkward part of extending an existing editor: a color that looks correct in one rendering path can be wrong in another. We keep the compensation in the shared palette code so an endpoint renderer doesn't need to know how the canvas theme filter works.

Server compilation has to know the size too

The YAML compiler runs without the browser editor. It needs dimensions before it can lay out the graph, so the DSL duplicates the editor's sizing arithmetic without a DOM. The dimensions don't always match: sizing.ts keeps collapsed API cards at 34px, while apiEndpoint.layout.ts uses 30px in the editor.

That duplication has a cost. A wrapping change needs corresponding changes in the DSL. The code marks those locations as sync points. Contract conformance tests check the emitted elements' shape; they don't prove that the exported text fits inside every card.

This is part of the work behind architecture as code: the source has to compile into elements the editor can still edit. Archdocs carries a fork because row bindings and protocol fields affect that behavior. If your extension only needs a new drawing preset, a group of existing elements is a cheaper place to start. A new element commits you to editing behavior and export as well as the first successful render.

Architecture docs that stay alive

ArchDocs keeps diagrams and docs in sync with your systems instead of letting them rot. Create a free account to start documenting your architecture.