Skip to content

feat(erd-editor): Make the canvas infinite (#369, #368) - #444

Merged
dineug merged 9 commits into
mainfrom
feat/infinite-canvas
Sep 6, 2026
Merged

feat(erd-editor): Make the canvas infinite (#369, #368)#444
dineug merged 9 commits into
mainfrom
feat/infinite-canvas

Conversation

@dineug

@dineug dineug commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Closes #369.
Closes #368.

The canvas has no edges any more, and the work that followed from that: what the view is stored as, how a reader finds their way back to the content, what a copy carries, and where the tools live.

The canvas

A wheel, grab or touch pan goes anywhere, and so does the zoom re-centre that rides it. The scroll ranges are derived from the union of the tables and memos and only drive the two scrollbars and the minimap, whose own thumb and handle drags stop at the end of the travel frozen when they began. A drag freezes the content rect per store so the bars and the map stand still together; scrollTo is never clamped, so a scroll undo restores exactly what it recorded. A file whose saved view is far from its content opens on the content, deferred until the host reports a screen.

The view is settings.originX / originY — the screen point scene (0, 0) lands on. The width * (1 - zoom) / 2 term the old scroll carried now lives in exactly one place, the schema parser's migrateScrollToOrigin, which computes the pair once for a document that has none. scrollLeft / scrollTop stay in the file as a frozen legacy pair: the parser and v2ToV3 read them for that migration, and nothing writes, derives or zeroes them, so a released editor keeps reading its own view from the same file.

The document box, HideSign, the toolbar's canvas-size input and the resize action are gone. width / height stay as a compatibility field the importers write and sortTable wraps at.

Finding the content again

A screen panned off every table and memo draws one pill over the middle of its bottom edge: an arrow turned at the nearest entity, the gap to it, and a press that centres it. An empty document has no nearest entity, so it hides itself, exactly as the minimap and the scrollbars do.

Selection and copying

$mod+A joins $mod+Alt+A as Select All Table, Memo, and both stand down wherever there is a caret. A press on an entity the selection already holds keeps the selection, so a group moves as one under a plain drag and $mod is left meaning "add this one as well".

A duplicate of whole tables — Alt+drag and the PayloadKind.tables clipboard path alike — now carries the graph its tables span: a relationship when both of its end tables are copied, and every index of a copied table whole, with its name, its unique flag and its columns in order. What cannot be resolved is dropped whole rather than in part. The column copy path is untouched.

Zoom and the image

$mod+O resets the zoom, naming the zoom it means rather than stepping toward it, so it holds the scene point under the middle of the screen.

The PNG export is cropped to the content by itself now, which is #368: with no document box left to draw, getExportRect is every table and memo, plus the connectors the document is set to show, plus a margin — so the image is the diagram rather than the empty canvas around it, wherever on the canvas the diagram sits. It is still not a screenshot, since the scene is built again from the document on a Stage of its own, but it is drawn at the zoom the editor is showing, HighLevelTable and all. A document too large for a canvas is scaled down and the editor says by how much.

The tools

A column of tools stands over the top left corner: the hand and the pointer, the four notations, and zen mode (Alt+Z), which takes the scrollbars, the minimap and the toolbar away and leaves the tools standing. Space toggles the hand rather than holding it. Every button names itself and the first chord bound to it, as the toolbar's Search, Undo and Redo now do.

Checks

pnpm check, pnpm build, pnpm test (402 files, 7277 tests), pnpm size (274.1 kB gzip, +4,261 B against the baseline, 8.0 kB under budget) and the Playwright suite (31 files, 377 tests) all pass locally.

🤖 Generated with Claude Code

dineug and others added 9 commits September 6, 2026 11:35
…egacy scroll pair once

settings.originX and originY are the screen point scene (0, 0) lands on.
getSceneOrigin reads them directly, so the width * (1 - zoom) / 2 term the
old scroll carried lives in exactly one place now: the schema parser's
migrateScrollToOrigin, which computes the origin pair once for a document
that has none. scrollLeft and scrollTop stay in the file as a frozen legacy
pair: the parser and v2ToV3 read them for that migration, nothing writes,
derives or zeroes them, and a released editor keeps reading its own view
from the same file. toJson serialises a copy of settings and zeroes only
the origin pair under the scroll save setting.

getZoomViewport, getAbsolutePoint, getAbsoluteZoomPoint and
getAbsolutePosition are gone; every caller goes through the canon, and
quick search and the diff tree keep landing a table at START * zoom.
coordinateAuthority gains the legacy oracle and a re-armed source scan,
and e2e/specs/scroll-origin.spec.ts proves the file meaning did not move.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The canvas has no edges any more. A wheel, grab or touch pan goes
anywhere, and so does the zoom re-centre that rides it; the scroll ranges
are derived from the union of the tables and memos and only drive the two
scrollbars and the minimap, whose own thumb and handle drags stop at the
end of the travel frozen when they began. A drag freezes the content rect
per store so the bars and the map stand still together; scrollTo is never
clamped, so a scroll undo restores exactly what it recorded; a file whose
saved view is far from its content opens on the content, deferred until
the host reports a screen; the headless replica starts with no viewport
and records the editor's scroll verbatim.

The document box, HideSign, the toolbar canvas-size input and the resize
action are gone. width/height stay in the file as a compatibility field
the importers write and sortTable wraps at. The minimap maps the content
and the current view, centred in its square with the frame painted behind
the letterbox, and is hidden on an empty document as the scrollbars are.
The PNG export draws the content's own box plus a margin, with the Stage
bounded so a far-flung document still rasterises. Automatic placement
fits its preview to the content and centres the view when applied.

Refs #369. The Ctrl+A and grab-one-move-all requests in that thread are
separate work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A screen panned off every table and memo draws one pill over the middle
of its bottom edge: an arrow turned at the nearest entity, the gap to it,
and a press that centres it. getContentCompass reads the screen through
getVisibleCanvasRect and the entities through getContentRects, the boxes
getContentRect folds, and takes the nearest by the gap between the two
rects, a box touching the screen counting as on it exactly as culling
reads one. The press reads the state again rather than the compass the
render drew, since a wheel between the two moves the screen, and it lands
as a scrollTo, so one undo puts the view back where it was.

An empty document has no nearest entity, so the compass hides itself
where the minimap has to be held back. Erd and ErdViewer both draw it and
both name .content-compass in the guard list that keeps a press on an
overlay from starting a pan.

Refs #369.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on unmodified (#369)

Select All Table, Memo answers $mod+A now as well as the older $mod+Alt+A,
and both stand down wherever there is a caret: useKeyBindingMap names the
binding in YIELDS_TO_A_CARET and returns before the preventDefault, since
tinykeys is bound on the editor root and every keydown from an input
bubbles through it. The guard is per binding name because Enter and
Escape reach handleShortcut from inside the cell editor.

The press that starts an entity drag collapses the selection onto the
pressed entity only where the selection does not already hold it, which is
the rule altDragDuplicate already read: a group of tables and memos moves
as one under a plain drag, and $mod is left meaning add this one as well.
Nothing collapses a selection on the release, so empty canvas and Escape
stay the ways to clear it.

Refs #369.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…le copy

A duplicate of whole tables — Alt+drag and the PayloadKind.tables clipboard
path alike — now carries the graph its tables span. Both gather through
toClipboardRelationships and toClipboardIndexes in table-clipboard/copy.ts, so
the rule is stated once: a relationship rides along only when both of its end
tables are in the copied set, and an index of a copied table rides whole, with
its name, its unique flag and its columns in indexColumnIds order carrying
each orderType.

toCreateEntityActions builds a source-to-new id map for tables and columns as
it emits them and points every copy at those ids alone, so nothing a duplicate
creates can reference a source entity. What it cannot resolve it drops whole:
half a relationship is a line relationshipSort refuses to route, and a
composite UNIQUE narrowed to one column is a stricter constraint than the one
that was copied, which is the reason removeColumnAction$ removes a whole index
when one of its columns goes. Atom actions in the one batch, so a single undo
takes the whole copy back.

The two payload arrays are optional at CLIPBOARD_VERSION 1 on purpose: a bump
would turn every copy this build writes into an unsupported hard stop in a
shipped editor rather than a paste that drops the graph. isSupportedStructure
keeps requiring only tables, columns and memos, and rejects a newer array only
where it is present and not an array. The column copy path is untouched —
PayloadKind.columns gathers neither array and its paste is still a column
append.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zoomReset joins zoomIn and zoomOut as the third zoom chord, bound to $mod+O
with the browser default swallowed. It names the zoom it means rather than
stepping toward it — changeZoomLevelAction$(1) — so it holds the scene point
under the middle of the screen the way the toolbar's own zoom box does, and
the zoom and the origin it moves land as one entry the undo takes back
together.

Like its two siblings it is the editor's own chord: setKeyBindingMap cannot
remap it, and the settings panel lists it as Zoom Reset under Zoom Out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wing

An export is still not a screenshot — the scene is built again from the
document on a Stage of its own, so the image holds everything however far the
view was scrolled away — but the zoom now reaches it. createDocumentPng takes
a zoomLevel and renderDocumentScene writes it into its own store before it
reads anything, because the scale the layer is placed at and the spelling a
table is drawn in are the same field: getExportScale(box, zoomLevel) is that
zoom cut to the two canvas ceilings, and ExportScene swaps in HighLevelTable
under isHighLevelTable exactly as the canvas does.

The caller names the zoom rather than the document carrying it, because toJson
writes zoomLevel 1 for an author who asked for the zoom not to be saved, and
the image is meant to look like what they are reading. An image the zoom alone
made smaller is not a reduction either: renderPng compares what it drew
against pixelRatio times that zoom, so the toast still means only that a
canvas ceiling was hit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…zen mode

A column of tools stands over the top left corner of the canvas: the hand and
the pointer, the four notations, and zen mode. Every button says its name and
the first chord bound to it through toShortcutTitle, which the top toolbar's
Search, Undo and Redo now do as well.

editor.handTool is which of the two tools a press means, and Space toggles it
rather than holding it down: the tool is a state, so a pan that starts over a
table no longer depends on a key still being held. changeHandTool ends an armed
relationship draw as the hand goes down, and picking a notation puts the hand
back up, so one press is never read as two things at once.

editor.zenMode (Alt+Z) takes the scrollbars, the minimap and the toolbar away
and leaves the floating toolbar standing, because the button that leaves the
mode is in it. ErdEditor honours it on the ERD canvas alone, or the tab that
turned it on would be the only one it could be turned off from, and the
viewport is applied from the mode as well as from the resize observer — the
canvas is the root less whatever the toolbar takes, and zen mode takes the
toolbar away without the root changing size.

Neither field is named in any list in engine/actions.ts: they are the reader's
own view rather than the document, so they are never undone, saved or sent to
a peer. The minimap frame and the new toolbar draw their shadow back from the
edge with a negative spread, since the shadow colour is an opaque black that
only ever reached the dom minimap through a css scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…drew

Both cases read a number the text a runner measures with decides. The pan past
the content's far edge was carried a fixed nine hundred units, which is past
that edge on one font and short of it on another; it is now carried the rest of
the way plus a margin, read off the drawn box.

The scrollbar thumb was measured across the press. The travel a bar is drawn
from is read off the table sizes the relationship sort settles after the load,
and the press is the first thing to read them again, so the thumb it was
compared against was one measurement behind. Both boxes are taken inside the
drag now, against the one geometry the freeze holds, which is where the thumb
following the pointer exactly is a property rather than an approximation — and
the drop is asserted to leave it where it stood.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dineug
dineug merged commit a5e441c into main Sep 6, 2026
8 checks passed
@dineug
dineug deleted the feat/infinite-canvas branch September 6, 2026 07:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Supports infinite canvas Automatically crop export PNG to content

1 participant