@miragon/modeler-api 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,25 @@ session has its current box at once, not the one of the last import. The box
81
81
  is the element's own shape (a connection: the box around its waypoints),
82
82
  without an external label.
83
83
 
84
+ Optional `contentBounds()` (1.3) gives the box of the CONTENT in diagram
85
+ coordinates: what a host fits the view to when it fits itself
86
+ (`fitOnImport: false`, the modeler shell's viewport), or `undefined` while
87
+ there is nothing to fit (an empty diagram). Hosts feature-detect it
88
+ (`typeof modeler.contentBounds === "function"`) instead of reaching into a
89
+ renderer's services (Wardley's `panConstraint`). The content is everything the
90
+ canvas draws of the document: the box covers every `elements.bounds` box and
91
+ whatever the notation always draws (Wardley's plot with its axes, so a map
92
+ without components still has a box and `undefined` is no emptiness test). It
93
+ is tight, with no padding of its own: a host's fit adds its margins. Like
94
+ `elements.bounds` it ignores zoom and scroll, reads the live canvas, and before
95
+ the first import returns `undefined` or a box without throwing. A browser
96
+ measures SVG text differently at each zoom (Chromium lays it out at the scaled
97
+ font size), so a renderer computes the box from its model, or measures it once
98
+ per content change and keeps it across view changes. A viewer that exposes it
99
+ follows the same rules. It sits on the modeler next to the view state rather
100
+ than on `elements`: a fit frames the whole canvas, and a notation without
101
+ addressable elements still has content to fit.
102
+
84
103
  An interaction in progress during an import (an open label editor, a popup
85
104
  with typed input) never commits during or because of it (L1): no change
86
105
  event, nothing in the imported document or on its undo history, nothing
@@ -114,7 +133,7 @@ host's aligned options.
114
133
  | Option | Meaning | Default |
115
134
  | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
116
135
  | `freeArea` | the part of the canvas no host chrome covers — `{ insets }` (px from the container's edges, e.g. `shell.fitInsets()`) or `{ center }` (diagram coordinates, e.g. `viewport.freeRectCenter()`), each a function, never both; keyboard creation lands in its centre | the whole container |
117
- | `fitOnImport` | fit the content after every import (the fitted view of L5); `false` when the host's chrome fits it itself | `true` |
136
+ | `fitOnImport` | fit the content after every import (the fitted view of L5), into the free rectangle or with the renderer's own padding (see below); `false` when the host's chrome fits it itself | `true` |
118
137
  | `undoRedoKeys` | bind Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z and Cmd/Ctrl+Y to the canvas history; `false` when the host owns the document's history (VS Code) | `true` |
119
138
 
120
139
  ```ts
@@ -134,10 +153,21 @@ aliases for one minor release of that renderer, then go: `freeRectInsets`
134
153
  (Wardley, Event Storming, Context Maps) is `freeArea: { insets }`,
135
154
  `freeRectCenter` (Team Topologies, Value Chain) is `freeArea: { center }`, and
136
155
  Wardley's `keyboard: { undoRedo }` is `undoRedoKeys`. A renderer given only
137
- `center` that needs the rectangle itself (a pan constraint) treats the whole
138
- container as free. A viewer factory takes the same type and ignores
139
- `undoRedoKeys`. The kit's `create()` keeps `fitOnImport` at its default: L5
140
- checks the modeler's own fit.
156
+ `center` that needs the rectangle itself (a pan constraint) works there as it
157
+ does without the option: no host chrome, with whatever margins of its own it
158
+ uses then. A viewer factory takes the same type and ignores `undoRedoKeys`.
159
+ The kit's `create()` keeps `fitOnImport` at its default: L5 checks the
160
+ modeler's own fit.
161
+
162
+ The post-import fit (`fitOnImport`, L5's fitted view) MAY use the free area:
163
+ given `insets` a renderer may fit into the free rectangle (Wardley, Context
164
+ Maps) or keep its own fit (Event Storming, Team Topologies, Value Chain), and
165
+ given only `center`, which names no rectangle, it MAY fit as it does without
166
+ the option, keeping the padding it uses then (Context Maps keeps the room its
167
+ palette needs, and a test pins it). All five published renderers conform to
168
+ this reading; no kit case checks it, since `create()` takes no options. A host
169
+ whose first view must leave its chrome free passes `fitOnImport: false` and
170
+ fits itself, to `contentBounds()` where the renderer has it.
141
171
 
142
172
  ## The conformance kit
143
173
 
@@ -192,9 +222,11 @@ L1 interaction case reports a skip; with `openInteraction` but without
192
222
  `interactionState` only its keep-and-reopen half does. A modeler without
193
223
  `elements.bounds` skips the bounds case; with it, a harness without
194
224
  `moveElement` skips only its move half, one without `viewState` only its
195
- diagram-coordinates half. Without `openInteraction` or `viewState` the L5
196
- interaction case reports a skip; without `commitInteraction` only its commit
197
- halves do.
225
+ diagram-coordinates half. A modeler without `contentBounds` skips the
226
+ content-bounds case; with it, a harness without `viewState` skips only its
227
+ view half, and a modeler without `elements.bounds` only its covering half.
228
+ Without `openInteraction` or `viewState` the L5 interaction case reports a
229
+ skip; without `commitInteraction` only its commit halves do.
198
230
 
199
231
  **Every rejection is probed.** `invalidTexts` lists what MUST reject; the kit
200
232
  does not take `[]` on trust. The L7 probe case derives damaged forms from
@@ -261,6 +293,17 @@ once) and after `moveElement` (the moved element's box changed position).
261
293
  `setViewState(viewState)` must leave every box as it was: they are diagram
262
294
  coordinates. A viewer that exposes `bounds` is held to the same listing rules.
263
295
 
296
+ **The content box is verified around every element.** With `contentBounds`
297
+ present, it must not throw and must give `undefined` or a finite box with a
298
+ non-negative size before the first import; after importing each canonical text
299
+ and after `mutate` it must give a finite box as soon as an element is listed,
300
+ and that box must cover every element's finite `elements.bounds` box (the new
301
+ element included, so a box measured at the last import fails), give or take
302
+ what float32 rounding takes off a box measured on the live SVG (`getBBox`): a
303
+ thousandth of a unit, or 2^-20 of the edge where that is more.
304
+ `setViewState(viewState)` must leave it as it was. A viewer that exposes `contentBounds` is held to the
305
+ same rules after an import.
306
+
264
307
  **Element identity is verified through names.** After an unrelated edit, and
265
308
  after an export → import into the same instance, a fresh instance and the
266
309
  viewer, every id the session reported must still exist and, where the
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- var MODELER_API_VERSION = "1.2.0";
2
+ var MODELER_API_VERSION = "1.3.0";
3
3
  function apiVersionsCompatible(a, b) {
4
4
  const [aMajor = "", aMinor = ""] = a.split(".");
5
5
  const [bMajor = "", bMinor = ""] = b.split(".");
@@ -11,4 +11,4 @@ export {
11
11
  MODELER_API_VERSION,
12
12
  apiVersionsCompatible
13
13
  };
14
- //# sourceMappingURL=chunk-E6KJB7QH.js.map
14
+ //# sourceMappingURL=chunk-JK23ZTVG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @miragon/modeler-api — ONE interface every Miragon modeler implements.\n *\n * The contract encodes, as REQUIREMENTS, the behavioral drift that had to be\n * discovered per modeler against engine internals before it existed:\n * an `importDSL` that emitted change events (rewriting hand-authored files on\n * open), an import that left the undo stack on stale removed shapes, a\n * serializer that was not a byte fixpoint. A host embedding a conforming\n * modeler needs no per-engine suppression tricks — the laws below hold.\n *\n * The conformance test kit (`@miragon/modeler-api/conformance`) turns each\n * law into an executable case the modeler repo runs in its own CI.\n */\n\n/** the semver of THIS contract — a modeler declares the version it implements\n * in `meta.apiVersion`; hosts check compatibility with `apiVersionsCompatible`\n * (same major; while 0.x, the same minor too). The constant IS the\n * package version: release-please rewrites the literal below on every\n * release (release-please-config.json `extra-files`), and the kit's own\n * test pins it to package.json exactly. Modelers pin the npm package; the\n * constant is what travels in `meta`. */\nexport const MODELER_API_VERSION = \"1.3.0\"; // x-release-please-version\n\n/**\n * apiVersion compatibility: same major — and, while the contract is 0.x,\n * the same minor too (pre-1.0 minors may break; bump-minor-pre-major).\n * Hosts call it at bind time with `meta.apiVersion` and their own\n * MODELER_API_VERSION; it lives in the package root so that check never\n * pulls the conformance kit into a host bundle (`/conformance` re-exports it)\n */\nexport function apiVersionsCompatible(a: string, b: string): boolean {\n const [aMajor = \"\", aMinor = \"\"] = a.split(\".\");\n const [bMajor = \"\", bMinor = \"\"] = b.split(\".\");\n if (aMajor !== bMajor) return false;\n return aMajor !== \"0\" || aMinor === bMinor;\n}\n\nexport interface ModelerMeta {\n /** stable notation id, e.g. \"wardley\", \"team-topology\", \"event-storming\" */\n readonly notation: string;\n /** the MODELER_API_VERSION this modeler implements — compatible when the\n * major matches and, while the contract is 0.x, the minor too (pre-1.0\n * minors may break: bump-minor-pre-major) */\n readonly apiVersion: string;\n /**\n * true on an editing mount (the package's Modeler), false on a VIEWER\n * (its NavigatedViewer/Viewer): a viewer never fires `onContentChanged`,\n * accepts no user commands, and still imports, exports, reveals and\n * reports view state exactly like the editor — hosts mount it on\n * read-only surfaces (an MCP-App widget in read-only mode) without\n * per-package knowledge or a command-stack shim.\n */\n readonly editable: boolean;\n}\n\nexport interface ImportResult {\n /** non-fatal notes from the import (unknown lines kept, migrations run) */\n warnings: string[];\n}\n\nexport interface ElementInfo {\n /**\n * stable element id — identical across re-imports of the same text AND\n * untouched by edits to OTHER elements: hosts anchor todos, presence and\n * `?element=` deep links to it, so an id that renumbers when a line is\n * inserted above (positional edge ids) breaks every anchor below it. An\n * element without an id in the text gets one the serializer WRITES BACK.\n *\n * The id a session reports IS the element's id in the document: after\n * `exportText()` → `importText()` — in this instance or in any other one\n * (a peer, the viewer, the next session) — the element carries the same\n * id. An id that lives only in memory anchors nothing: the todo or deep\n * link a host stored against it points nowhere once the text travels.\n *\n * RECOMMENDED: when two peers create different elements at the same time,\n * their ids do not collide. An id allocated regardless of the content —\n * a per-instance counter (`event_1` on both peers) — does: once both edits\n * merge into one document, two elements share one id; random or\n * peer-scoped ids do not. An id derived from the content (OWM's\n * `component_<slug>`) collides only when both users create the same\n * content, which the merge has to reconcile anyway.\n */\n id: string;\n /** human label, when the notation has one */\n name?: string;\n}\n\n/** a box in diagram coordinates: an element's, as `ElementsSurface.bounds`\n * reports it, or the content's, as `CollaborativeModeler.contentBounds` does */\nexport interface ElementBounds {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n/**\n * Optional element surface — what a host needs for anchored todos, `?element=`\n * deep links and the selection half of live presence. Absent = the notation\n * has no addressable element identity. Ids are ELEMENT ids throughout: a\n * shape that only labels another element (diagram-js external labels) is\n * reported and revealed as its target, never under its own label id.\n */\nexport interface ElementsSurface {\n /** every addressable element currently on the canvas */\n list(): ElementInfo[];\n /** select + scroll the element into view; false when the id is unknown.\n * Revealing is view behavior: it delivers a selection, never a change event */\n reveal(id: string): boolean;\n /** the current selection as stable element ids — synchronous, so a host\n * can read it at the moment of a click (a deep link carrying the\n * selection) without caching every delivery */\n selection(): string[];\n /** selection changes as STABLE element ids; returns the unsubscribe */\n onSelection(cb: (ids: string[]) => void): () => void;\n /**\n * the element's box in DIAGRAM coordinates — the canvas's own coordinate\n * space, which zoom and scroll leave alone (a host maps it to the screen\n * with the current view, or draws in a layer that moves with the canvas) —\n * or `undefined` for an id `list()` does not report. Optional (1.2): hosts\n * feature-detect it for presence outlines and todo markers instead of\n * reaching into the engine.\n *\n * It reads the LIVE canvas: an element created, moved, resized or renamed\n * in this session has its current box at once, not the one of the last\n * import — a host outlining a peer's selection would otherwise draw it\n * where the element was, or not at all, until the next import. The box is\n * the element's own shape (a connection: the box around its waypoints); an\n * external label is not part of it.\n */\n bounds?(id: string): ElementBounds | undefined;\n}\n\n/**\n * The collaborative-modeler contract. The LAWS (verified by the conformance\n * kit) are part of the interface:\n *\n * L1 SILENT IMPORT — `importText` never fires `onContentChanged` and erases\n * the undo history without emitting — also when history exists. Hosts\n * re-import remote states on every sync; an import echo would re-export\n * the canonical serialization over half-typed peer text.\n * An interaction in progress when an import runs (an open label editor,\n * a popup with typed input) never commits during or because of the\n * import: no change event comes from it, its input lands neither in the\n * imported document nor on its fresh undo history, and nothing is\n * applied to an element the import removed. RECOMMENDED: keep the typed\n * input and reopen the interaction on the element with the same id when\n * that element is still there after the import — a remote re-import\n * should not cost the user what they were typing.\n * L2 CANONICAL FIXPOINT — `exportText` is deterministic, and for any text t:\n * with c = exportText() after importText(t), importText(c) followed by\n * exportText() returns c byte-for-byte. Canonicalization is idempotent;\n * opening a file never rewrites it.\n * L3 ONE CHANGE EVENT — every user-visible atomic change (element commands\n * AND config/axis/title/style edits that live in the document, AND\n * undo/redo) fires `onContentChanged` at least once, and nothing else\n * does: not `importText`, not undo-history housekeeping, not view or\n * selection changes. When a listener runs, `exportText()` ALREADY\n * returns the post-change serialization — hosts export inside the\n * listener; an emit-before-apply would lose the last edit.\n * L4 DEFINED UNDO ACROSS IMPORTS — undo directly after an `importText` is a\n * no-op: it must never mutate or resurrect shapes from the pre-import\n * document (id coincidence included).\n * L5 VIEW-STATE ROUND-TRIP — `getViewState`/`setViewState` survive a\n * re-import: snapshot before, restore after, viewport unchanged. An\n * import leaves the view FITTED to the content (the defined post-import\n * view, the same for the same document and host — a host that wants to\n * keep the user's viewport across a remote re-import snapshots before\n * and restores after, and one that fits the view itself passes\n * `fitOnImport: false` and fits to `contentBounds()`, 1.3, where the\n * modeler has it); before the first import `getViewState()` returns\n * `undefined` or a state `setViewState` accepts, and never throws. The\n * readback may be a NORMALISED form of what was set (a diagram-js\n * viewbox comes back rounded, with scale/inner/outer) — what must hold\n * is that setting a readback yields that readback again.\n * A view change the HOST makes — `setViewState`, or a fit its chrome\n * applies to the canvas — never commits an interaction in progress (an\n * open label editor, a popup with typed input): no change event, the\n * export unchanged. Hosts restore the viewport after every remote\n * re-import and refit when their chrome changes; a commit there writes\n * the half-typed input into the shared document for every peer.\n * RECOMMENDED: the interaction stays open with its typed input, so the\n * user's own commit afterwards writes what it would have written without\n * the view change.\n * L6 EXPORT IS TOTAL AFTER IMPORT — after a successful import (and any\n * sequence of user commands and undos), `exportText` succeeds. Foreign or\n * auxiliary content the modeler does not understand is PRESERVED through\n * import → export (it comes back in the export, canonicalised like the\n * rest), never dropped and never a reason to throw.\n * L7 REJECTED IMPORTS LEAVE NO TRACE — `importText` on non-importable text\n * REJECTS (resolve-with-warnings means \"imported, with notes\"; rejection\n * means \"nothing imported\"). This holds for EVERY rejection, whatever\n * the input (a truncated or half-typed text included): afterwards the\n * previously imported document is still rendered with its elements,\n * `exportText` still returns it, no change event has fired and the undo\n * history is untouched.\n * The modeler stays USABLE: the next valid import renders, the next user\n * command applies. Hosts re-import half-typed peer text on every sync —\n * \"blank (or wedged) until healed\" is not an acceptable failure mode.\n *\n * View state is NEVER part of the document: `setViewState` fires no change\n * events and `exportText` is independent of it.\n */\nexport interface CollaborativeModeler {\n readonly meta: ModelerMeta;\n\n /** mount the canvas into a host element (once, before the first import).\n * The host must be attached to the document with a non-zero size — a\n * detached or 0×0 element gives diagram-js engines NaN viewboxes.\n * Calls made between mounting and the first import (`exportText()`, a\n * user command) return whatever the modeler defines, or throw (only\n * `getViewState` never does, L5), but they never wedge the instance:\n * the first `importText` still behaves exactly as specified */\n attachTo(host: Element): void;\n\n /** parse + render `text` — SILENT per L1, the view fitted to the content\n * per L5; resolves when rendered, REJECTS when nothing was imported (L7).\n * This holds for the first import too, whatever was called before it — a\n * host may export or route a command before its first document arrives */\n importText(text: string): Promise<ImportResult>;\n\n /** the canonical serialization of the current document (L2, L6) */\n exportText(): string;\n\n /** subscribe to user-visible model changes (L3); returns the unsubscribe */\n onContentChanged(cb: () => void): () => void;\n\n /** opaque viewport snapshot (zoom/scroll) — JSON-serializable (L5) */\n getViewState(): unknown;\n setViewState(state: unknown): void;\n\n /**\n * the box of the CONTENT in diagram coordinates — what a host fits the\n * view to when it fits itself (`fitOnImport: false`, the modeler shell's\n * viewport) — or `undefined` while there is nothing to fit (an empty\n * diagram). Optional (1.3): hosts feature-detect it (`typeof\n * modeler.contentBounds === \"function\"`) instead of reaching into a\n * renderer's services; a renderer's own fit stays its business (L5).\n *\n * The content is everything the canvas draws of the document: the box\n * covers every box `elements.bounds` reports, and whatever the notation\n * always draws (Wardley's plot with its axes — a map without components\n * still has a box, so `undefined` is no emptiness test). It is tight: no\n * padding and no room for chrome of its own, since a host's fit adds its\n * margins (the shell's fit insets) on top. Like `elements.bounds` it is in\n * the canvas's own coordinate space, which zoom and scroll leave alone,\n * and it reads the LIVE canvas: an element created or moved in this\n * session counts at once. A browser measures SVG text differently at each\n * zoom (Chromium lays it out at the scaled font size), so a renderer\n * computes the box from its model, or measures it once per content change\n * and keeps it across view changes. Before the first import it returns\n * `undefined` or a box, and never throws. A viewer that exposes it follows\n * the same rules. It sits next to the view state rather than on `elements`: a fit\n * frames the whole canvas, and a notation without addressable elements\n * still has content to fit.\n */\n contentBounds?(): ElementBounds | undefined;\n\n readonly elements?: ElementsSurface;\n\n /** tear down DOM + listeners; further calls on the instance are undefined\n * behavior, but a second destroy() must not throw */\n destroy(): void;\n}\n\n/** px the host's chrome covers along the edges of the modeler's container */\nexport interface FreeAreaInsets {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\n/** a point in diagram coordinates */\nexport interface DiagramPoint {\n x: number;\n y: number;\n}\n\n/**\n * The FREE AREA: the part of the canvas no host chrome covers (Miragon\n * guide §5.9). Keyboard creation places a new element in its centre (§5.5)\n * and a renderer that constrains panning keeps the content reachable around\n * it, so both agree with a host that fits into the same rectangle. A host\n * passes ONE of two forms, each a function the renderer reads whenever it\n * needs it (the chrome changes when an inspector or a legend opens):\n *\n * - `insets`: what the chrome covers, in px from the container's edges\n * (the modeler shell's `shell.fitInsets()`); the renderer computes the\n * centre itself, the way a fit does;\n * - `center`: the centre itself, in diagram coordinates, `undefined` while\n * there is none (a 0×0 container) — the shell's `viewport.freeRectCenter()`.\n *\n * A renderer accepts both. One that needs the rectangle itself (a pan\n * constraint) and is given only `center` works there as it does without\n * the option: it assumes no host chrome, with whatever margins of its own\n * it uses then.\n *\n * The post-import fit (`fitOnImport`, L5's fitted view) MAY use the free\n * area: given `insets` a renderer may fit into the free rectangle or keep\n * its own fit, and given only `center`, which names no rectangle, it MAY fit\n * as it does without the option, keeping the padding it uses then (Context\n * Maps keeps the room its palette needs) — the reading all five published\n * renderers conform to, which no kit case checks; a host whose first view\n * must leave its chrome free passes `fitOnImport: false` and fits itself.\n */\nexport type FreeArea =\n | { insets: () => FreeAreaInsets; center?: never }\n | { center: () => DiagramPoint | undefined; insets?: never };\n\n/**\n * The options a renderer's contract factory takes under THESE names once it\n * implements contract 1.2, so a host feeds every notation the same object\n * instead of one spelling per renderer. A renderer extends the type with its\n * own options (diagram-js `additionalModules`); the contract fixes names,\n * meaning and defaults. A viewer factory takes the same type and ignores\n * what has no meaning without editing (`undoRedoKeys`).\n *\n * Each renderer adopts them in its own release, as part of its move to 1.2:\n * `meta.apiVersion` follows the pinned package and no kit case checks the\n * options, so the version alone does not tell a host they are honoured.\n * Until a renderer ships them, a host passes the renderer's own names, or\n * both. Those names then stay accepted as DEPRECATED aliases for one minor\n * release of that renderer, then go: `freeRectInsets` (Wardley, Event\n * Storming, Context Maps) is `freeArea: { insets }`, `freeRectCenter` (Team\n * Topologies, Value Chain) is `freeArea: { center }`, and Wardley's\n * `keyboard: { undoRedo }` is `undoRedoKeys`. Given both, the aligned name\n * wins.\n */\nexport interface CollaborativeModelerOptions {\n /** where the host's chrome leaves the canvas free; default: the whole container */\n freeArea?: FreeArea | undefined;\n /**\n * fit the content into the view after every import — default true, the\n * fitted view L5 defines. A host whose chrome fits the content itself\n * (the modeler shell's viewport fits into the free area) passes false:\n * the post-import view is then the host's to set, and L5's fit is the\n * host's fit. The conformance kit runs with the default. Whether the\n * renderer's own fit uses `freeArea` is its choice (see `FreeArea`): with\n * `insets` it may fit into the free rectangle, with only `center` it may\n * keep the padding it uses without the option\n */\n fitOnImport?: boolean | undefined;\n /**\n * bind Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z and Cmd/Ctrl+Y to the canvas history —\n * default true. A host that owns the document's history (VS Code) passes\n * false, and the keys reach it unprevented; text fields keep their own\n * undo either way\n */\n undoRedoKeys?: boolean | undefined;\n}\n"],"mappings":";AAqBO,IAAM,sBAAsB;AAS5B,SAAS,sBAAsB,GAAW,GAAoB;AACnE,QAAM,CAAC,SAAS,IAAI,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG;AAC9C,QAAM,CAAC,SAAS,IAAI,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG;AAC9C,MAAI,WAAW,OAAQ,QAAO;AAC9B,SAAO,WAAW,OAAO,WAAW;AACtC;","names":[]}
@@ -28,7 +28,7 @@ __export(conformance_exports, {
28
28
  module.exports = __toCommonJS(conformance_exports);
29
29
 
30
30
  // src/index.ts
31
- var MODELER_API_VERSION = "1.2.0";
31
+ var MODELER_API_VERSION = "1.3.0";
32
32
  function apiVersionsCompatible(a, b) {
33
33
  const [aMajor = "", aMinor = ""] = a.split(".");
34
34
  const [bMajor = "", bMinor = ""] = b.split(".");
@@ -61,6 +61,52 @@ var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
61
61
  var hasBounds = (elements) => typeof elements.bounds === "function";
62
62
  var isFiniteBox = (box) => box !== void 0 && [box.x, box.y, box.width, box.height].every((n) => typeof n === "number" && Number.isFinite(n)) && box.width >= 0 && box.height >= 0;
63
63
  var describeBox = (box) => box === void 0 ? "undefined" : `{ x: ${box.x}, y: ${box.y}, width: ${box.width}, height: ${box.height} }`;
64
+ var hasContentBounds = (m) => typeof m.contentBounds === "function";
65
+ var edgeTolerance = (edge) => Math.max(1e-3, Math.abs(edge) * 2 ** -20);
66
+ var covers = (outer, inner) => {
67
+ const right = outer.x + outer.width;
68
+ const bottom = outer.y + outer.height;
69
+ return inner.x >= outer.x - edgeTolerance(outer.x) && inner.y >= outer.y - edgeTolerance(outer.y) && inner.x + inner.width <= right + edgeTolerance(right) && inner.y + inner.height <= bottom + edgeTolerance(bottom);
70
+ };
71
+ function contentBoxOf(m, when) {
72
+ let box;
73
+ try {
74
+ box = m.contentBounds();
75
+ } catch (e) {
76
+ fail(`contentBounds() threw ${when}: ${String(e)}`);
77
+ }
78
+ if (box === void 0) return void 0;
79
+ if (!isFiniteBox(box)) {
80
+ fail(
81
+ `contentBounds() ${when} is ${describeBox(box)} \u2014 the content box must be finite with a non-negative size, or undefined when there is nothing to fit`
82
+ );
83
+ }
84
+ return { x: box.x, y: box.y, width: box.width, height: box.height };
85
+ }
86
+ function expectContentAroundElements(m, when) {
87
+ const box = contentBoxOf(m, when);
88
+ const listed = m.elements?.list() ?? [];
89
+ if (box === void 0) {
90
+ if (listed.length > 0) {
91
+ fail(
92
+ `contentBounds() ${when} is undefined while elements.list() reports ${listed.length} element(s) \u2014 undefined means there is nothing to fit`
93
+ );
94
+ }
95
+ return void 0;
96
+ }
97
+ if (m.elements && hasBounds(m.elements)) {
98
+ for (const { id } of listed) {
99
+ const element = m.elements.bounds(id);
100
+ if (!isFiniteBox(element)) continue;
101
+ if (!covers(box, element)) {
102
+ fail(
103
+ `contentBounds() ${when} is ${describeBox(box)}, which does not cover elements.bounds('${id}') = ${describeBox(element)} \u2014 the content box holds every element (and reads the live canvas)`
104
+ );
105
+ }
106
+ }
107
+ }
108
+ return box;
109
+ }
64
110
  function boxesOf(elements, when) {
65
111
  const boxes = /* @__PURE__ */ new Map();
66
112
  for (const { id } of elements.list()) {
@@ -936,6 +982,58 @@ ${out}`;
936
982
  }
937
983
  }
938
984
  },
985
+ {
986
+ name: "contentBounds (when present): a finite box in diagram coordinates around every listed element, live after a new element, unchanged by zoom and scroll",
987
+ async run(h) {
988
+ const settle = h.settle ?? defaultSettle;
989
+ const m = await h.create();
990
+ let viewer;
991
+ try {
992
+ if (!hasContentBounds(m))
993
+ return skip("the modeler exposes no contentBounds (optional, 1.3) \u2014 nothing to verify");
994
+ contentBoxOf(m, "before the first import");
995
+ for (const [index, text] of h.canonicalTexts.entries()) {
996
+ await importOrFail(m, text, `importText(canonicalTexts[${index}]) rejected`);
997
+ expectContentAroundElements(m, `after importing canonicalTexts[${index}]`);
998
+ }
999
+ await importOrFail(m, h.canonicalTexts[0] ?? "", "importText(canonicalTexts[0]) rejected");
1000
+ await h.mutate(m);
1001
+ await settle();
1002
+ const atRest = expectContentAroundElements(m, "after a user mutation added an element");
1003
+ const unverified = [];
1004
+ if (h.viewState !== void 0) {
1005
+ m.setViewState(h.viewState);
1006
+ const viewed = contentBoxOf(m, "after setViewState");
1007
+ if (!same(viewed, atRest)) {
1008
+ fail(
1009
+ `contentBounds() changed with the view (${describeBox(atRest)} \u2192 ${describeBox(viewed)}) \u2014 the content box is in diagram coordinates, which zoom and scroll leave alone`
1010
+ );
1011
+ }
1012
+ } else {
1013
+ unverified.push(
1014
+ "viewState is undefined \u2014 that the content box is independent of the view was not verified"
1015
+ );
1016
+ }
1017
+ if (!m.elements || !hasBounds(m.elements)) {
1018
+ unverified.push(
1019
+ "the modeler exposes no elements.bounds \u2014 that the content box covers every element was not verified"
1020
+ );
1021
+ }
1022
+ if (h.createReadonly) {
1023
+ viewer = await h.createReadonly();
1024
+ if (hasContentBounds(viewer)) {
1025
+ contentBoxOf(viewer, "on the viewer before the first import");
1026
+ await importOrFail(viewer, h.canonicalTexts[0] ?? "", "the viewer rejected canonicalTexts[0]");
1027
+ expectContentAroundElements(viewer, "on the viewer after an import");
1028
+ }
1029
+ }
1030
+ if (unverified.length > 0) return skip(unverified.join("; "));
1031
+ } finally {
1032
+ m.destroy();
1033
+ viewer?.destroy();
1034
+ }
1035
+ }
1036
+ },
939
1037
  {
940
1038
  name: "L6: export is total after imports, user mutations and undo",
941
1039
  async run(h) {