A UML class diagram editor that runs in a web browser. Load, view, edit, and save class diagrams, with a semantic model (classes, associations) kept separate from the diagram that shows it (positions, sizes, bend points).
UMLOne was generated by AI Sloperator with Claude Code. No license is given.
Live: https://www.aisloperator.com/umlone/ — nothing to install.
Or clone the repository and double-click index.html. That is the whole procedure: it
runs the same either way, because there is nothing to serve.
There is no build step, no package manager, no dev server, and nothing to download. The
editor is plain JavaScript and SVG loaded with classic <script> tags, which is
deliberate: ES modules are blocked by CORS over file://, so using them would have meant
running a local server just to open the editor — and the hosted copy is the same files,
not a build of them.
To try it out, press Open and pick samples/example.uml.json, then press Fit.
Open tests.html, or the local copy. It
runs the whole suite in that browser and prints a pass/fail list. Everything runs against
the real DOM, which is the only place text measurement and SVG geometry mean anything.
- Open, or drag a
.uml.jsonfile onto the canvas. - Save writes the document to your Downloads folder.
- SVG writes the diagram you are looking at as a standalone image.
- Print puts that same image on paper, with the diagram's name above it.
The export is one diagram, not the document — what is on screen, cropped to the drawing
with a small margin, with nothing that only exists to be clicked and no dependency on
styles.css. Every colour, font, and stroke width is written onto the elements
themselves, because a file that needs a stylesheet beside it is not an exported file. It
is not a save: an SVG cannot be opened back into the editor, so the document is still
unsaved afterwards.
Printing uses the same builder, so what the printer produces is what the file would have
contained. Ctrl/Cmd+P and the browser's own print menu print the diagram too, not a
screenshot of the editor: the print stylesheet hides the chrome, and the page is filled on
beforeprint rather than only by the button, since a hidden editor plus an empty print
area is a blank sheet.
A browser cannot write back to the file you opened, so there is no in-place save: Save
downloads a fresh copy every time, and the status line says Downloaded rather than
Saved so it is clear where the file went. The title bar shows • while there are
unsaved changes, and closing the tab with unsaved work prompts first.
Files are validated on load. Structural problems (a dangling relationship endpoint, a duplicate id, a version from a newer editor) refuse the load and say why. Recoverable problems (an unparseable multiplicity, an owner that is not a package, a diagram node for an element that no longer exists) are repaired, and the status line tells you how many repairs happened, with details in the browser console.
| Action | How |
|---|---|
| Select | Click; Shift-click to add; drag on empty space to marquee |
| Move | Drag a box. Dragging a package moves its contents with it |
| Resize | Drag a handle on a selected box |
| Create element | Pick a palette tool, then click the canvas |
| Create relationship | Pick a relationship tool, click the source, then the target |
| Add a bend to a line | Double-click the line |
| Remove a bend | Double-click the bend handle |
| Edit text on the diagram | Double-click a name, attribute, operation, literal, or note |
| Precise placement | Hold Alt while dragging to bypass the 5px grid |
| Nudge | Arrow keys; Shift+Arrow for a larger step |
| Pan | Middle-drag, or Ctrl/Cmd-drag, or scroll |
| Zoom | Ctrl/Cmd + scroll, or the toolbar buttons |
| Back to 100% | The 1:1 button, or Ctrl/Cmd+0 |
| Show the whole diagram | The Fit button. It shrinks to fit and stops at 100% — it never magnifies |
| Arrange the diagram | The Layout button. One undo step |
| Print this diagram | Ctrl/Cmd+P, or the toolbar button |
| Undo / redo | Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z |
| Select all | Ctrl/Cmd+A |
| Delete from the model | Delete |
| Remove from this diagram only | Shift+Delete |
| Cancel the current tool | Escape |
Delete and Shift+Delete differ, and the difference matters. Delete removes the element from the model, so it disappears from every diagram along with its relationships. Shift+Delete removes only the view — the element stays in the model and on other diagrams, and you can put it back from the inspector's Not on this diagram list.
Double-click any line of text in a box to edit it where it sits — a single click selects the box and starts a drag, as it does anywhere else on it. Placing a new element opens the field immediately, so creating a class and naming it is one gesture.
| Key | Effect |
|---|---|
| Enter, on a member row with text | Commit and open a new row below |
| Enter, on a row that is empty | Finish — the blank row is dropped |
| Enter, on a name or note | Commit and finish |
| Shift+Enter | Commit and finish without adding a row |
| Escape | Discard the edit |
| ↑ / ↓, Tab / Shift+Tab | Commit and move between rows |
| Backspace in an empty row | Delete the row and move up |
So a whole attribute list is typed straight down: type, Enter, type, Enter, and one last Enter on the empty row to stop. That final Enter tidies up after itself, because committing an empty row deletes it — which is also how a member is removed, along with Backspace in an already-empty row.
Arrow keys cross compartment boundaries, so ↓ walks from the class name through every attribute and on into the operations without touching the mouse.
Double-clicking the gap just above or below a compartment's members inserts one there rather than editing an existing row — before the first, after the last, or the first one in an empty compartment. Those gaps are only a few pixels tall, so they tint on hover, and the one below the last member borrows a little of the empty space beneath it. That borrow is capped at a single row: a strip covering all the slack in a tall box would light up on hover and read as a member that is already there.
A box grows when a new row would not fit. Since building a list means inserting a blank row on every Enter, and the last of those is thrown away when the list ends, the box hands that height back when editing stops — down to what the contents need, but never below the height the box had when editing began. A size set by dragging stays yours.
Members are edited as the exact string the diagram draws:
- /total: Money [0..*] = 0
+ move(dx: int, out ok: bool): Point
which is read back by model.parseAttribute / parseOperation. The parsers never fail —
text that matches nothing becomes the member's name, because losing what someone typed is
worse than storing something odd. Two properties they guarantee:
- Round-trip.
format(parse(format(x)))equalsformat(x), so opening a field and pressing Enter changes nothing. - Static and abstract survive. Those render as underline and italic, which the one edited line cannot express, so they are carried over from the member being replaced rather than read from the text. The same goes for the member's id, so editing a row modifies it rather than replacing it.
Notes are the exception to the Enter rule: their text is genuinely multi-line, so Shift+Enter inserts a line break there and the arrow keys move the caret.
SVG <text> cannot be typed into. Rather than a <foreignObject>, the editor positions a
real HTML <input> over the line being edited, using the same rectangle the renderer used
to draw it (render.lineBoxes). Caret, selection, double-click-to-select-word, clipboard,
and IME are then simply the browser's, none of which would be free inside SVG. The line
underneath is skipped while its field is open, so the text never shows through.
Double-clicks are recognised in interact.js at pointerdown rather than left to the
browser's dblclick event. Drawing is a full rebuild — every render calls
ns.clear(svg) — and selecting a node redraws, as does releasing the pointer, so across a
double-click the element under the cursor is destroyed and replaced four times. dblclick
only fires when both clicks share a target, and Firefox holds to that strictly, so the
event never arrived at all. Reading the target during pointerdown, before anything can
redraw, does not depend on the browser's click bookkeeping surviving a rebuild.
Layout rearranges the diagram you are looking at. It is one undo step, and pressing it twice in a row does nothing the second time.
The arrangement is layered, in the Sugiyama shape. Class diagrams have one strong convention — a supertype is drawn above its subtypes — and generalization and realization are the only relationships that carry it, so those two decide the rows. Everything else (associations, aggregation, composition, dependencies, note links) only pulls related boxes towards each other within a row. Treating every relationship as a hierarchy would tip associations on their side and draw inheritance where there is none.
- A box goes below its deepest supertype, so a chain never doubles back.
- Boxes that inheritance says nothing about sit beside whatever they are related to; a group joined only by associations spreads over rows from its busiest member, rather than forming one long line.
- Order within a row comes from barycentre sweeps — the standard heuristic for reducing crossings, since the exact problem is NP-hard.
- Packages nest. A package's contents are arranged on their own, the package is sized to hold the result, and it then takes part in the layout around it as a single box. An edge that crosses a package boundary pulls on the package.
- Parts of a diagram that are not connected to each other are packed side by side, biggest first, wrapping into rows.
- Bends are dropped: they were placed against the old positions.
What it is not: optimal. There is no orthogonal routing, no overlap avoidance for edges, and no attempt to preserve the shape you already had — it is a fresh arrangement, which is why it is a button rather than something that happens as you edit.
One file is one document: a single semantic model plus any number of diagrams over it. The format is bespoke JSON rather than XMI. XMI is the actual standard, but it is verbose and version-fragmented, tools disagree on it in practice, and its layout half (Diagram Interchange) is the least-supported part of the ecosystem — while layout is a first-class requirement here. A converter can be added later without changing how the editor works internally.
Files are written in a canonical form: fixed key order, two-space indent, unknown fields dropped. Loading a file and immediately downloading it produces identical bytes, so these files diff and merge sensibly in version control.
{
"format": "umlone",
"version": 1,
"model": {
"elements": [
{ "id": "c1", "kind": "class", "name": "Order",
"isAbstract": false, "stereotypes": ["entity"],
"attributes": [
{ "id": "a1", "name": "total", "type": "Money",
"visibility": "private", "multiplicity": "", "defaultValue": "",
"isStatic": false, "isDerived": true }
],
"operations": [
{ "id": "o1", "name": "recalculate", "returnType": "void",
"visibility": "public", "isStatic": false, "isAbstract": false,
"parameters": [
{ "name": "policy", "type": "TaxPolicy", "direction": "in" }
] }
],
"owner": "p1" }
],
"relationships": [
{ "id": "r1", "kind": "composition", "name": "",
"ends": [
{ "ref": "c1", "role": "order", "multiplicity": "1",
"visibility": "public", "navigable": false },
{ "ref": "c2", "role": "lines", "multiplicity": "1..*",
"visibility": "private", "navigable": true }
] }
]
},
"diagrams": [
{ "id": "d1", "name": "Domain",
"nodes": [
{ "id": "n1", "ref": "c1", "x": 80, "y": 110, "w": 220, "h": 130,
"show": { "attributes": true, "operations": true } }
],
"edges": [
{ "id": "g1", "ref": "r1", "waypoints": [[300, 190]], "labels": {} }
] }
]
}- Element kinds:
class,interface,enumeration,package,comment. Which compartments an element carries follows from its kind — acommenthasbodyinstead ofname, anenumerationhasliterals, aninterfacehas no attributes. - Relationship kinds:
association,aggregation,composition,generalization,realization,dependency,annotation. endsis always[source, target]. What that ordering means is per-kind: forgeneralizationthe source is the subtype; forrealizationthe source is the implementation; forcompositionandaggregationthe source is the whole; forannotationthe source is the note. Encoding every relationship the same way keeps rendering and hit-testing generic.- Role, multiplicity, visibility, and navigability are only meaningful on
association,aggregation, andcomposition. They are preserved but not shown on the others, so switching a relationship's kind and switching back loses nothing. - Multiplicity grammar:
1,0,*,0..1,1..*,2..7, or empty. Inverted ranges such as5..2are rejected. - Package containment lives in the model as
owner, not in the geometry. A box that merely overlaps a package on screen does not belong to it. - An element appears at most once per diagram. That invariant is what lets an edge resolve its endpoints from element ids alone.
- Diagram nodes and edges hold ids and geometry only — never copies of semantic data. Rename a class and every diagram showing it updates.
- An edge is only drawable on a diagram where both of its endpoint elements have nodes.
Script order in index.html is the dependency graph: each file uses only globals defined
above it, and every file is an IIFE hanging one property off the UMLOne global.
| File | Responsibility |
|---|---|
src/ns.js |
The namespace; ids, cloning, events, DOM helpers |
src/geometry.js |
Rect and polyline math; clipping a line to a box border |
src/measure.js |
SVG text measurement, cached |
src/model.js |
The semantic half: elements, relationships, formatting, parsing |
src/diagram.js |
The diagram half: nodes, edges, routing, integrity |
src/validate.js |
Validation, normalization, canonical key order |
src/io.js |
Serialize, deserialize, file read, download, drag-and-drop |
src/render.js |
Layout, line rectangles, and SVG drawing |
src/layout.js |
Automatic arrangement: layering, ordering, packing |
src/export.js |
Standalone SVG for export and for printing |
src/commands.js |
The document store, undo stack, composite operations |
src/inlineedit.js |
In-place text editing on the diagram |
src/interact.js |
Pointer and keyboard handling |
src/panel.js |
The property inspector |
src/app.js |
Wiring |
Three design decisions worth knowing before changing anything:
- A document is plain JSON data, never a graph of class instances. That is what makes snapshot undo and lossless round-tripping cheap.
- Every mutation goes through
store.apply, which snapshots first. Undo is therefore correct by construction rather than by remembering to add it each time. Continuous gestures useapplyContinuousso one drag is one undo step. render.lineBoxesis the only answer to "where is this line?" The drawn text, the invisible click targets, and the in-place editor all read from it, so what you see, what you click, and where the text box appears cannot drift apart.
- Rendering is a full redraw per change. Fine at class-diagram sizes; it would need incremental updates for thousands of elements.
- Edges route as straight polylines through user-placed bends. There is no automatic orthogonal routing or overlap avoidance.
- No export to XMI or PlantUML. The JSON format is designed so an exporter can be added without changing the internals.
- Automatic layout is a button, not a mode. Nothing rearranges itself while you edit, and the arrangement does not try to preserve the shape you already had.