@miragon/modeler-api 1.1.0 → 1.2.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
@@ -31,15 +31,15 @@ modeler's build — not a downstream integration review.
31
31
  import type { CollaborativeModeler } from "@miragon/modeler-api";
32
32
  ```
33
33
 
34
- | Law | Requirement |
35
- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
- | L1 | `importText` is **silent**: no change events, undo history erased without emitting — also over existing history and foreign content; an interaction open during an import (a label editor with typed input) never commits |
37
- | L2 | `exportText` is **canonical**: deterministic, a byte fixpoint on canonical input, idempotent otherwise |
38
- | L3 | `onContentChanged` fires for **every** user-visible change — element commands, config/axis/title edits, undo/redo — and nothing else; inside a listener `exportText()` already returns the new document |
39
- | L4 | undo directly after an import is a defined **no-op** — never a stale-history mutation |
40
- | L5 | view state survives a re-import; an import leaves the view **fitted** to the content; the readback may be a normalised form of what was set |
41
- | L6 | `exportText` is total after a successful import; foreign content is **preserved** through import → export, never dropped, never a throw |
42
- | L7 | EVERY rejected import leaves no trace — previous document still rendered and exported, no events, undo history untouched — and the modeler **stays usable** |
34
+ | Law | Requirement |
35
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | L1 | `importText` is **silent**: no change events, undo history erased without emitting — also over existing history and foreign content; an interaction open during an import (a label editor with typed input) never commits |
37
+ | L2 | `exportText` is **canonical**: deterministic, a byte fixpoint on canonical input, idempotent otherwise |
38
+ | L3 | `onContentChanged` fires for **every** user-visible change — element commands, config/axis/title edits, undo/redo — and nothing else; inside a listener `exportText()` already returns the new document |
39
+ | L4 | undo directly after an import is a defined **no-op** — never a stale-history mutation |
40
+ | L5 | view state survives a re-import; an import leaves the view **fitted** to the content; the readback may be a normalised form of what was set; a host's view change (`setViewState`, a fit) never **commits** an open interaction |
41
+ | L6 | `exportText` is total after a successful import; foreign content is **preserved** through import → export, never dropped, never a throw |
42
+ | L7 | EVERY rejected import leaves no trace — previous document still rendered and exported, no events, undo history untouched — and the modeler **stays usable** |
43
43
 
44
44
  View state is never part of the document: `setViewState` fires no change
45
45
  events and `exportText` is independent of it.
@@ -71,6 +71,16 @@ per-instance counter such as `event_1`. An id derived from the content (OWM's
71
71
  `component_<slug>`) collides only when both users create the same content,
72
72
  which the merge has to reconcile anyway.
73
73
 
74
+ Optional `elements.bounds(id)` (1.2) gives the element's box in DIAGRAM
75
+ coordinates (`{ x, y, width, height }`, the canvas's own space, which zoom and
76
+ scroll leave alone), or `undefined` for an id `list()` does not report. Hosts
77
+ feature-detect it (`typeof modeler.elements?.bounds === "function"`) for
78
+ presence outlines and todo markers instead of reaching into the engine. It
79
+ reads the live canvas: an element created, moved, resized or renamed in the
80
+ session has its current box at once, not the one of the last import. The box
81
+ is the element's own shape (a connection: the box around its waypoints),
82
+ without an external label.
83
+
74
84
  An interaction in progress during an import (an open label editor, a popup
75
85
  with typed input) never commits during or because of it (L1): no change
76
86
  event, nothing in the imported document or on its undo history, nothing
@@ -78,6 +88,57 @@ applied to an element the import removed. RECOMMENDED: keep the typed input
78
88
  and reopen the interaction on the element with the same id when it is still
79
89
  there — hosts re-import remote states while the user is typing.
80
90
 
91
+ A view change the HOST makes — `setViewState`, or a fit its chrome applies to
92
+ the canvas — never commits an interaction in progress either (L5, since 1.2):
93
+ no change event, the export unchanged. Hosts restore the viewport after every
94
+ remote re-import and refit when their chrome changes; a modeler that completes
95
+ its label editor on every viewbox change (diagram-js's direct-editing pattern,
96
+ which every renderer built against contract 1.1 follows) writes the half-typed
97
+ label into the shared document for every peer. RECOMMENDED: the interaction
98
+ stays open with its typed input, so the user's commit afterwards writes what it
99
+ would have written without the view change.
100
+
101
+ ## Factory options
102
+
103
+ `CollaborativeModelerOptions` (1.2) names the options a host has to feed a
104
+ renderer's contract factory (`createCollaborativeModeler`,
105
+ `createCollaborativeViewer`), the same for every notation, so a host can pass
106
+ every notation one object. The contract defines them; each renderer adopts
107
+ them in its own release, next to its own options (diagram-js
108
+ `additionalModules`). Adopting them is part of moving a renderer to contract
109
+ 1.2: `meta.apiVersion` follows the `@miragon/modeler-api` version the renderer
110
+ pins, and no kit case checks the options (the harness's `create()` builds the
111
+ instance), so a renderer that reports 1.2.0 without them silently ignores a
112
+ host's aligned options.
113
+
114
+ | Option | Meaning | Default |
115
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
116
+ | `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` |
118
+ | `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
+
120
+ ```ts
121
+ import type { CollaborativeModelerOptions } from "@miragon/modeler-api";
122
+
123
+ const options: CollaborativeModelerOptions = {
124
+ freeArea: { insets: () => shell.fitInsets() }, // or { center: () => viewport.freeRectCenter() }
125
+ fitOnImport: false, // the shell's viewport fits after every import
126
+ undoRedoKeys: false, // VS Code owns the history
127
+ };
128
+ ```
129
+
130
+ Until a renderer ships the aligned names, a host keeps passing that
131
+ renderer's own names, or passes both: a renderer that accepts the aligned
132
+ names takes them over its own. Its own names then stay accepted as DEPRECATED
133
+ aliases for one minor release of that renderer, then go: `freeRectInsets`
134
+ (Wardley, Event Storming, Context Maps) is `freeArea: { insets }`,
135
+ `freeRectCenter` (Team Topologies, Value Chain) is `freeArea: { center }`, and
136
+ 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.
141
+
81
142
  ## The conformance kit
82
143
 
83
144
  ```ts
@@ -98,9 +159,11 @@ registerConformance(
98
159
  configMutations: [{ name: "axis labels", run: (m) => renameEvolutionAxis(m) }], // [] if the notation has none
99
160
  undo: (m) => triggerUndo(m),
100
161
  redo: (m) => triggerRedo(m),
162
+ moveElement: (m) => dragFirstElement(m), // optional: move one element by its own commands, return its id
101
163
  probeTexts: [CUT_OFF_SAMPLE], // optional: damaged forms your parser meets, on top of the derived probes
102
164
  openInteraction: (m) => typeIntoLabelEditor(m, "typed, not committed"), // open an editor, type, do NOT commit
103
165
  interactionState: (m) => readOpenLabelEditor(m), // { elementId, text } | undefined
166
+ commitInteraction: (m) => pressEnterInLabelEditor(m), // optional: commit what openInteraction typed
104
167
  // engines that debounce change delivery: make this outlast the debounce
105
168
  settle: () => new Promise((r) => setTimeout(r, 50)),
106
169
  },
@@ -126,7 +189,12 @@ to route it) instead of passing. An explicit `[]` (`invalidTexts`,
126
189
  `foreignTexts`, `configMutations`, `looseTexts`) is a statement that the
127
190
  notation has no such form and skips nothing. Without `openInteraction` the
128
191
  L1 interaction case reports a skip; with `openInteraction` but without
129
- `interactionState` only its keep-and-reopen half does.
192
+ `interactionState` only its keep-and-reopen half does. A modeler without
193
+ `elements.bounds` skips the bounds case; with it, a harness without
194
+ `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.
130
198
 
131
199
  **Every rejection is probed.** `invalidTexts` lists what MUST reject; the kit
132
200
  does not take `[]` on trust. The L7 probe case derives damaged forms from
@@ -155,19 +223,30 @@ requires no change event, the imported document as the export and an undo
155
223
  that changes nothing (L1, L4). `interactionState` lets it check the
156
224
  recommended keep-and-reopen on the same-document import.
157
225
 
226
+ **Interactions open during a host's view change.** On a fresh import of
227
+ `canonicalTexts[0]` the kit opens the interaction, commits it with
228
+ `commitInteraction` and requires the export to change and to contain the typed
229
+ input (`interactionState().text`) — proof the case can see a commit. It then
230
+ re-imports, opens the interaction again and calls `setViewState(viewState)`:
231
+ no change event, the export unchanged (L5), and the view really changed. The
232
+ RECOMMENDED keep-open is an advisory: `interactionState` must still report the
233
+ same element and input, and `commitInteraction` must then write the same
234
+ document as without the view change — so `openInteraction` types the same
235
+ input on every call.
236
+
158
237
  **Recommendations are advisories.** Where the contract only RECOMMENDS
159
- (collision-free ids across peers, keep-and-reopen of an open interaction), the
160
- kit reports what it finds as an ADVISORY through `onAdvisory`
161
- (`console.warn` by default) and never fails: a recommendation becomes a law,
162
- with a failing case, only in a major release. The collision check imports the
163
- same canonical text into two fresh instances, runs `mutate` once on each and
164
- advises when both allocate the same id — unless both new elements carry the
165
- same name and the id spells it out (its letters and digits contain the
166
- name's, as `component_new_component` does for "New Component"). Such an id is
167
- derived from the content and collides for identical content by design, so
168
- the check reports a skip instead: let `mutate`'s elements differ in content
169
- (a counter the harness shares across instances, as the reference harness
170
- does).
238
+ (collision-free ids across peers, keep-and-reopen of an open interaction across
239
+ an import, keep-open across a host's view change), the kit reports what it
240
+ finds as an ADVISORY through `onAdvisory` (`console.warn` by default) and never
241
+ fails: a recommendation becomes a law, with a failing case, only in a major
242
+ release. The collision check imports the same canonical text into two fresh
243
+ instances, runs `mutate` once on each and advises when both allocate the same
244
+ id — unless both new elements carry the same name and the id spells it out
245
+ (its letters and digits contain the name's, as `component_new_component` does
246
+ for "New Component"). Such an id is derived from the content and collides for
247
+ identical content by design, so the check reports a skip instead: let
248
+ `mutate`'s elements differ in content (a counter the harness shares across
249
+ instances, as the reference harness does).
171
250
 
172
251
  **View state is compared by readback.** The kit never compares
173
252
  `getViewState()` with your `viewState` literal: it sets it, reads the
@@ -175,6 +254,13 @@ modeler's own (possibly normalised) state back, and requires that readback
175
254
  to survive a re-import — a diagram-js viewbox that comes back rounded with
176
255
  `scale`/`inner`/`outer` is fine.
177
256
 
257
+ **Element boxes are verified live.** With `elements.bounds` present, every
258
+ listed id must give a finite box with a non-negative size and an unknown id
259
+ `undefined` — after an import, after `mutate` (the new element has its box at
260
+ once) and after `moveElement` (the moved element's box changed position).
261
+ `setViewState(viewState)` must leave every box as it was: they are diagram
262
+ coordinates. A viewer that exposes `bounds` is held to the same listing rules.
263
+
178
264
  **Element identity is verified through names.** After an unrelated edit, and
179
265
  after an export → import into the same instance, a fresh instance and the
180
266
  viewer, every id the session reported must still exist and, where the
@@ -198,6 +284,16 @@ rewrites the constant on every release (`extra-files`) and a test pins it to
198
284
  `meta.apiVersion`; hosts match on the major (`apiVersionsCompatible`).
199
285
  Releases are tagged `modeler-api-vX.Y.Z`.
200
286
 
287
+ Hosts bind from the package root, which carries no kit code (since 1.2;
288
+ `@miragon/modeler-api/conformance` re-exports `apiVersionsCompatible`, so 1.1
289
+ imports keep working):
290
+
291
+ ```ts
292
+ import { apiVersionsCompatible, MODELER_API_VERSION } from "@miragon/modeler-api";
293
+
294
+ if (!apiVersionsCompatible(modeler.meta.apiVersion, MODELER_API_VERSION)) refuseToMount(modeler);
295
+ ```
296
+
201
297
  The contract is first published as 1.0.0, never 0.x: under 0.x,
202
298
  `apiVersionsCompatible` matches on the minor too, which would turn every
203
299
  additive surface into a lockstep release across all modelers and their
@@ -226,6 +322,10 @@ Recommendations today, proposed as hard laws for the next major:
226
322
  during an import is reopened, with its typed input, on the element with the
227
323
  same id when that element is still there (today: L1 RECOMMENDED, advisory in
228
324
  the L1 interaction case).
325
+ - **Keep an interaction open across a host's view change** — after
326
+ `setViewState` (or a host's fit) the interaction is still open with its typed
327
+ input, and the user's commit writes what it would have written without the
328
+ view change (today: L5 RECOMMENDED, advisory in the L5 interaction case).
229
329
 
230
330
  ## License
231
331
 
@@ -0,0 +1,14 @@
1
+ // src/index.ts
2
+ var MODELER_API_VERSION = "1.2.0";
3
+ function apiVersionsCompatible(a, b) {
4
+ const [aMajor = "", aMinor = ""] = a.split(".");
5
+ const [bMajor = "", bMinor = ""] = b.split(".");
6
+ if (aMajor !== bMajor) return false;
7
+ return aMajor !== "0" || aMinor === bMinor;
8
+ }
9
+
10
+ export {
11
+ MODELER_API_VERSION,
12
+ apiVersionsCompatible
13
+ };
14
+ //# sourceMappingURL=chunk-E6KJB7QH.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.2.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/** an element's box in diagram coordinates, as `ElementsSurface.bounds` reports it */\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); 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 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` treats the whole container as\n * free, as it does without the option.\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\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,13 @@ __export(conformance_exports, {
28
28
  module.exports = __toCommonJS(conformance_exports);
29
29
 
30
30
  // src/index.ts
31
- var MODELER_API_VERSION = "1.1.0";
31
+ var MODELER_API_VERSION = "1.2.0";
32
+ function apiVersionsCompatible(a, b) {
33
+ const [aMajor = "", aMinor = ""] = a.split(".");
34
+ const [bMajor = "", bMinor = ""] = b.split(".");
35
+ if (aMajor !== bMajor) return false;
36
+ return aMajor !== "0" || aMinor === bMinor;
37
+ }
32
38
 
33
39
  // src/conformance.ts
34
40
  function attachedHost(width = 800, height = 600) {
@@ -40,12 +46,6 @@ function attachedHost(width = 800, height = 600) {
40
46
  document.body.append(wrapper);
41
47
  return host;
42
48
  }
43
- function apiVersionsCompatible(a, b) {
44
- const [aMajor = "", aMinor = ""] = a.split(".");
45
- const [bMajor = "", bMinor = ""] = b.split(".");
46
- if (aMajor !== bMajor) return false;
47
- return aMajor !== "0" || aMinor === bMinor;
48
- }
49
49
  function fail(message) {
50
50
  throw new Error(`modeler-api conformance: ${message}`);
51
51
  }
@@ -58,6 +58,22 @@ function exportOrFail(modeler, when) {
58
58
  }
59
59
  }
60
60
  var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
61
+ var hasBounds = (elements) => typeof elements.bounds === "function";
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
+ var describeBox = (box) => box === void 0 ? "undefined" : `{ x: ${box.x}, y: ${box.y}, width: ${box.width}, height: ${box.height} }`;
64
+ function boxesOf(elements, when) {
65
+ const boxes = /* @__PURE__ */ new Map();
66
+ for (const { id } of elements.list()) {
67
+ const box = elements.bounds(id);
68
+ if (!isFiniteBox(box)) {
69
+ fail(
70
+ `elements.bounds('${id}') ${when} is ${describeBox(box)} \u2014 every listed id must give a finite box with a non-negative size`
71
+ );
72
+ }
73
+ boxes.set(id, { x: box.x, y: box.y, width: box.width, height: box.height });
74
+ }
75
+ return boxes;
76
+ }
61
77
  function assertFinite(state, when) {
62
78
  const json = JSON.stringify(
63
79
  state,
@@ -572,6 +588,97 @@ ${out}`);
572
588
  }
573
589
  }
574
590
  },
591
+ {
592
+ name: "L5: a host's view change never commits an open interaction",
593
+ async run(h) {
594
+ if (!h.openInteraction) {
595
+ return skip(
596
+ "openInteraction is undefined \u2014 a host's view change over an open interaction (a label editor with typed input) was not verified"
597
+ );
598
+ }
599
+ if (h.viewState === void 0) {
600
+ return skip(
601
+ "viewState is undefined \u2014 a host's view change over an open interaction was not verified (supply a panned/zoomed state)"
602
+ );
603
+ }
604
+ const openInteraction = h.openInteraction;
605
+ const commitInteraction = h.commitInteraction;
606
+ const viewState = h.viewState;
607
+ const settle = h.settle ?? defaultSettle;
608
+ const first = h.canonicalTexts[0] ?? "";
609
+ const m = await h.create();
610
+ try {
611
+ await m.importText(first);
612
+ const doc = exportOrFail(m, "after importing canonicalTexts[0]");
613
+ const open = async () => {
614
+ await openInteraction(m);
615
+ await settle();
616
+ const opened2 = h.interactionState?.(m);
617
+ if (h.interactionState && opened2 === void 0) {
618
+ fail("interactionState reports no interaction after openInteraction \u2014 the case proves nothing");
619
+ }
620
+ return opened2;
621
+ };
622
+ let committed;
623
+ if (commitInteraction) {
624
+ const typed = (await open())?.text;
625
+ await commitInteraction(m);
626
+ await settle();
627
+ committed = exportOrFail(m, "after committing the interaction");
628
+ if (committed === doc || typed && !committed.includes(typed)) {
629
+ fail(
630
+ "committing the interaction openInteraction opened did not write its typed input into the export \u2014 the case proves nothing (commitInteraction must commit the way a user does, openInteraction type input the serializer writes verbatim)"
631
+ );
632
+ }
633
+ await importOrFail(m, first, "re-importing canonicalTexts[0] after the commit rejected");
634
+ }
635
+ const opened = await open();
636
+ const view = m.getViewState();
637
+ const { calls } = await changeEvents(h, m, () => m.setViewState(viewState));
638
+ if (calls > 0) {
639
+ fail(
640
+ `setViewState over an open interaction fired onContentChanged ${calls}x \u2014 the host's view change committed it (L5; if every setViewState emits, see the L5 round-trip case first)`
641
+ );
642
+ }
643
+ if (exportOrFail(m, "after setViewState over an open interaction") !== doc) {
644
+ fail(
645
+ "setViewState over an open interaction changed the export \u2014 the host's view change committed it (L5)"
646
+ );
647
+ }
648
+ if (same(m.getViewState(), view)) {
649
+ fail(
650
+ "setViewState(viewState) had no effect over an open interaction \u2014 the case proves nothing (supply a state that differs from the fitted view)"
651
+ );
652
+ }
653
+ const after = h.interactionState?.(m);
654
+ let advice;
655
+ if (opened !== void 0 && (after?.elementId !== opened.elementId || after?.text !== opened.text)) {
656
+ const input = opened.text === void 0 ? "" : ` with the input ${JSON.stringify(opened.text)}`;
657
+ const what = after === void 0 ? "closed it" : `left ${JSON.stringify(after)} open`;
658
+ advice = `an interaction on '${opened.elementId}'${input} did not survive a host's view change (setViewState ${what}) \u2014 RECOMMENDED: keep it open with its typed input, so the user's commit afterwards writes what it would have written without the view change`;
659
+ } else if (commitInteraction && committed !== void 0) {
660
+ await commitInteraction(m);
661
+ await settle();
662
+ const out = exportOrFail(m, "after committing the interaction after a view change");
663
+ if (out !== committed) {
664
+ advice = `committing an interaction after a host's view change wrote another document than committing it without one \u2014 RECOMMENDED: keep it open with its typed input across a view change (if openInteraction types different input per call, this advice is the harness's):
665
+ --- committed without a view change
666
+ ${committed}
667
+ --- committed after setViewState
668
+ ${out}`;
669
+ }
670
+ }
671
+ return {
672
+ ...commitInteraction ? {} : {
673
+ skipped: "commitInteraction is undefined \u2014 that the typed input changes the document (without it the case may prove nothing) and that it still commits after the view change were not checked (pass commitInteraction to check both)"
674
+ },
675
+ ...advice === void 0 ? {} : { advisories: [advice] }
676
+ };
677
+ } finally {
678
+ m.destroy();
679
+ }
680
+ }
681
+ },
575
682
  {
576
683
  name: "elements (when present): stable ids across re-imports and unrelated edits, reveal and selection semantics",
577
684
  async run(h) {
@@ -751,6 +858,84 @@ ${out}`);
751
858
  }
752
859
  }
753
860
  },
861
+ {
862
+ name: "elements.bounds (when present): a finite box in diagram coordinates for every listed id, undefined for an unknown one, live after a new element and a move",
863
+ async run(h) {
864
+ const text = h.canonicalTexts[0] ?? "";
865
+ const settle = h.settle ?? defaultSettle;
866
+ const m = await h.create();
867
+ let viewer;
868
+ try {
869
+ await m.importText(text);
870
+ const elements = m.elements;
871
+ if (!elements)
872
+ return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
873
+ if (!hasBounds(elements))
874
+ return skip("the modeler exposes no elements.bounds (optional) \u2014 nothing to verify");
875
+ boxesOf(elements, "after an import");
876
+ if (elements.bounds("__modeler_api_no_such_id__") !== void 0)
877
+ fail("elements.bounds(unknown id) must return undefined");
878
+ const imported = new Set(elements.list().map((e) => e.id));
879
+ await h.mutate(m);
880
+ await settle();
881
+ for (const { id } of elements.list().filter((e) => !imported.has(e.id))) {
882
+ const box = elements.bounds(id);
883
+ if (!isFiniteBox(box)) {
884
+ fail(
885
+ `elements.bounds('${id}') of an element created in the session is ${describeBox(box)} \u2014 bounds must read the live canvas, not the last import`
886
+ );
887
+ }
888
+ }
889
+ const unverified = [];
890
+ if (h.moveElement) {
891
+ const before = boxesOf(elements, "before a move");
892
+ const movedId = await h.moveElement(m);
893
+ await settle();
894
+ const was = before.get(movedId);
895
+ if (was === void 0)
896
+ fail(`moveElement returned '${movedId}', an id elements.list() did not report before the move`);
897
+ const box = boxesOf(elements, "after a move").get(movedId);
898
+ if (box === void 0)
899
+ fail(`'${movedId}' is gone from elements.list() after moveElement moved it`);
900
+ if (box.x === was.x && box.y === was.y) {
901
+ fail(
902
+ `elements.bounds('${movedId}') did not follow a move made through the modeler's own commands (still ${describeBox(was)}) \u2014 bounds must read the live canvas, not the last import (or moveElement did not move the element)`
903
+ );
904
+ }
905
+ } else {
906
+ unverified.push("moveElement is undefined \u2014 that a box follows a move was not verified");
907
+ }
908
+ if (h.viewState !== void 0) {
909
+ const atRest = boxesOf(elements, "before a view change");
910
+ m.setViewState(h.viewState);
911
+ for (const [id, box] of boxesOf(elements, "after setViewState")) {
912
+ if (!same(box, atRest.get(id))) {
913
+ fail(
914
+ `elements.bounds('${id}') changed with the view (${describeBox(atRest.get(id))} \u2192 ${describeBox(box)}) \u2014 boxes are in diagram coordinates, which zoom and scroll leave alone`
915
+ );
916
+ }
917
+ }
918
+ } else {
919
+ unverified.push(
920
+ "viewState is undefined \u2014 that boxes are independent of the view was not verified"
921
+ );
922
+ }
923
+ if (h.createReadonly) {
924
+ viewer = await h.createReadonly();
925
+ await importOrFail(viewer, text, "the viewer rejected canonicalTexts[0]");
926
+ if (viewer.elements && hasBounds(viewer.elements)) {
927
+ boxesOf(viewer.elements, "on the viewer after an import");
928
+ if (viewer.elements.bounds("__modeler_api_no_such_id__") !== void 0)
929
+ fail("the viewer's elements.bounds(unknown id) must return undefined");
930
+ }
931
+ }
932
+ if (unverified.length > 0) return skip(unverified.join("; "));
933
+ } finally {
934
+ m.destroy();
935
+ viewer?.destroy();
936
+ }
937
+ }
938
+ },
754
939
  {
755
940
  name: "L6: export is total after imports, user mutations and undo",
756
941
  async run(h) {