@miragon/modeler-api 1.2.0 → 1.4.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
@@ -1,6 +1,6 @@
1
1
  # @miragon/modeler-api
2
2
 
3
- ONE interface every Miragon modeler implements — the collaborative-modeler
3
+ ONE interface every Miragon modeler implements: the collaborative-modeler
4
4
  contract and its conformance test kit.
5
5
 
6
6
  ## Why
@@ -10,20 +10,20 @@ every remote change) exposes behavioral details that are invisible in
10
10
  standalone use. Before this contract existed, each drift had to be discovered
11
11
  per modeler, against its engine internals:
12
12
 
13
- - an `importDSL` that ran `commandStack.clear()` **emitting** a change event —
13
+ - an `importDSL` that ran `commandStack.clear()` **emitting** a change event:
14
14
  unsuppressed, opening a hand-authored file re-exported the canonical
15
15
  serialization over it;
16
- - an import that left the undo stack on stale, removed shapes — undo after a
16
+ - an import that left the undo stack on stale, removed shapes: undo after a
17
17
  co-editor's change silently no-op'd or deleted re-imported shapes by id
18
18
  coincidence;
19
19
  - a serializer that was not a byte fixpoint of its own parser;
20
- - config/axis edits that fired their own event instead of the command stack —
21
- a host observing only the stack lost them.
20
+ - config/axis edits that fired their own event instead of the command stack,
21
+ so a host observing only the stack lost them.
22
22
 
23
23
  This package turns those lessons into **laws** (see the JSDoc on
24
24
  `CollaborativeModeler`) and ships a **conformance kit** that makes every law
25
25
  an executable test the modeler repo runs in its own CI. Drift fails the
26
- modeler's build — not a downstream integration review.
26
+ modeler's build, not a downstream integration review.
27
27
 
28
28
  ## The contract (1.x)
29
29
 
@@ -33,13 +33,13 @@ import type { CollaborativeModeler } from "@miragon/modeler-api";
33
33
 
34
34
  | Law | Requirement |
35
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 |
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
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 |
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
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
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** |
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.
@@ -50,7 +50,7 @@ the first `importText` still renders and resolves as specified.
50
50
 
51
51
  `meta.editable` tells a viewer mount from an editor: a package that ships a
52
52
  `NavigatedViewer`/`Viewer` exposes it under the same contract with
53
- `editable: false` — it imports, exports, reveals and reports view state like
53
+ `editable: false`: it imports, exports, reveals and reports view state like
54
54
  the editor and never fires `onContentChanged`, so hosts mount it on read-only
55
55
  surfaces without per-package knowledge. A viewer must import without a
56
56
  command stack of its own, or register one: the kit's viewer case fails a
@@ -59,14 +59,14 @@ command stack of its own, or register one: the kit's viewer case fails a
59
59
  Optional `elements` surface (stable ids, `reveal`, a synchronous `selection()`
60
60
  and selection events) powers host features like anchored todos, `?element=`
61
61
  deep links and the selection half of live presence. Ids are ELEMENT ids
62
- (a label shape resolves to its target) and survive edits to other elements —
62
+ (a label shape resolves to its target) and survive edits to other elements:
63
63
  positional ids that renumber when a line is inserted above break every anchor
64
64
  below. The id a session reports is the element's id in the DOCUMENT: after
65
65
  `exportText()` → `importText()`, in this instance or any other (a peer, the
66
- viewer, the next session), the element carries the same id — an element
66
+ viewer, the next session), the element carries the same id. An element
67
67
  created in the session gets an id the serializer writes back, never one that
68
68
  lives only in memory. RECOMMENDED: when two peers create different elements
69
- at the same time, their ids do not collide — random or peer-scoped ids, not a
69
+ at the same time, their ids do not collide: random or peer-scoped ids, not a
70
70
  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.
@@ -81,15 +81,34 @@ 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
87
106
  applied to an element the import removed. RECOMMENDED: keep the typed input
88
107
  and reopen the interaction on the element with the same id when it is still
89
- there — hosts re-import remote states while the user is typing.
108
+ there, since hosts re-import remote states while the user is typing.
90
109
 
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):
110
+ A view change the HOST makes (`setViewState`, or a fit its chrome applies to
111
+ the canvas) never commits an interaction in progress either (L5, since 1.2):
93
112
  no change event, the export unchanged. Hosts restore the viewport after every
94
113
  remote re-import and refit when their chrome changes; a modeler that completes
95
114
  its label editor on every viewbox change (diagram-js's direct-editing pattern,
@@ -98,6 +117,31 @@ label into the shared document for every peer. RECOMMENDED: the interaction
98
117
  stays open with its typed input, so the user's commit afterwards writes what it
99
118
  would have written without the view change.
100
119
 
120
+ **Keys stay on the canvas** (1.4, RECOMMENDED). A modeler acts on a key only
121
+ while its canvas holds the focus: the key's target is the focusable element the
122
+ modeler placed in the host to take its keys (diagram-js: the canvas `<svg>`,
123
+ `tabindex="0"`) or lies inside it. Every other key is the page's:
124
+
125
+ - a key aimed at the host's chrome or at a host editor (a button, a text input,
126
+ a select, a contenteditable, an EditContext or Monaco editor), whether it sits
127
+ beside the modeler's container, inside it, or next to the canvas in a host's
128
+ overlay;
129
+ - a key whose target is `<body>`: nothing is focused after a load, after a
130
+ click on page text, and while the host hides the canvas (`display: none`) or
131
+ makes it `inert`.
132
+
133
+ The modeler leaves such a key alone: no command, no change of the document or
134
+ the selection, no `preventDefault()` and no `stopPropagation()`, in the capture
135
+ phase too. A keydown or keyup without a string `key` (browser autofill
136
+ dispatches both) is ignored without an error. The rule holds for the viewer
137
+ mount as for the editable one. Hosts embed modelers next to their own editors
138
+ and toolbars: a key the canvas takes from there deletes the selection while the
139
+ user types, undoes the last edit from a toolbar button or swallows Cmd/Ctrl+C
140
+ in a text field. The modeler's own chrome (its palette, context pad, label
141
+ editor) handles its keys as it sees fit, and a host that wants keyboard editing
142
+ hands the canvas the focus (a click on it, Tab, or `focus()` on the canvas
143
+ element).
144
+
101
145
  ## Factory options
102
146
 
103
147
  `CollaborativeModelerOptions` (1.2) names the options a host has to feed a
@@ -111,11 +155,11 @@ pins, and no kit case checks the options (the harness's `create()` builds the
111
155
  instance), so a renderer that reports 1.2.0 without them silently ignores a
112
156
  host's aligned options.
113
157
 
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` |
158
+ | Option | Meaning | Default |
159
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
160
+ | `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 |
161
+ | `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` |
162
+ | `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
163
 
120
164
  ```ts
121
165
  import type { CollaborativeModelerOptions } from "@miragon/modeler-api";
@@ -134,28 +178,39 @@ aliases for one minor release of that renderer, then go: `freeRectInsets`
134
178
  (Wardley, Event Storming, Context Maps) is `freeArea: { insets }`,
135
179
  `freeRectCenter` (Team Topologies, Value Chain) is `freeArea: { center }`, and
136
180
  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.
181
+ `center` that needs the rectangle itself (a pan constraint) works there as it
182
+ does without the option: no host chrome, with whatever margins of its own it
183
+ uses then. A viewer factory takes the same type and ignores `undoRedoKeys`.
184
+ The kit's `create()` keeps `fitOnImport` at its default: L5 checks the
185
+ modeler's own fit.
186
+
187
+ The post-import fit (`fitOnImport`, L5's fitted view) MAY use the free area:
188
+ given `insets` a renderer may fit into the free rectangle (Wardley, Context
189
+ Maps) or keep its own fit (Event Storming, Team Topologies, Value Chain), and
190
+ given only `center`, which names no rectangle, it MAY fit as it does without
191
+ the option, keeping the padding it uses then (Context Maps keeps the room its
192
+ palette needs, and a test pins it). All five published renderers conform to
193
+ this reading; no kit case checks it, since `create()` takes no options. A host
194
+ whose first view must leave its chrome free passes `fitOnImport: false` and
195
+ fits itself, to `contentBounds()` where the renderer has it.
141
196
 
142
197
  ## The conformance kit
143
198
 
144
199
  ```ts
145
- // e.g. test/conformance.browser.test.ts in a modeler repo (any (name, fn) runner)
200
+ // e.g. test/conformance.browser.test.ts in a modeler repo (any (name, fn) runner that runs the cases one after another)
146
201
  import { attachedHost, registerConformance } from "@miragon/modeler-api/conformance";
147
202
  import { test } from "vitest";
148
203
 
149
204
  registerConformance(
150
205
  {
151
- create: async () => mountMyModeler(attachedHost()), // IN the document, sized — see below
206
+ create: async () => mountMyModeler(attachedHost()), // IN the document, sized (see below)
152
207
  createReadonly: async () => mountMyViewer(attachedHost()), // the viewer mount, if the package ships one
153
- canonicalTexts: [CANONICAL_SAMPLE_A, CANONICAL_SAMPLE_B], // two DISTINCT, REVIEWED ones — the undo law needs them
208
+ canonicalTexts: [CANONICAL_SAMPLE_A, CANONICAL_SAMPLE_B], // two DISTINCT, REVIEWED ones: the undo law needs them
154
209
  looseTexts: [HAND_AUTHORED_SAMPLE],
155
210
  invalidTexts: [HALF_TYPED_SAMPLE], // [] if NOTHING is non-importable (a lenient line DSL)
156
211
  foreignTexts: [{ text: SAMPLE_WITH_VENDOR_EXTENSION, mustSurvive: ["x-vendor: keep-me"] }],
157
212
  viewState: { x: 120, y: 80, width: 640, height: 480 }, // a panned/zoomed viewport, not the fitted one (the kit compares readbacks, never this literal)
158
- mutate: (m) => addOneElement(m), // undoable; adds — never renames or deletes
213
+ mutate: (m) => addOneElement(m), // undoable; adds, never renames or deletes
159
214
  configMutations: [{ name: "axis labels", run: (m) => renameEvolutionAxis(m) }], // [] if the notation has none
160
215
  undo: (m) => triggerUndo(m),
161
216
  redo: (m) => triggerRedo(m),
@@ -164,6 +219,7 @@ registerConformance(
164
219
  openInteraction: (m) => typeIntoLabelEditor(m, "typed, not committed"), // open an editor, type, do NOT commit
165
220
  interactionState: (m) => readOpenLabelEditor(m), // { elementId, text } | undefined
166
221
  commitInteraction: (m) => pressEnterInLabelEditor(m), // optional: commit what openInteraction typed
222
+ canvasElement: (m) => canvasOf(m), // optional: the element that takes the keys, if not diagram-js's focusable <svg>
167
223
  // engines that debounce change delivery: make this outlast the debounce
168
224
  settle: () => new Promise((r) => setTimeout(r, 50)),
169
225
  },
@@ -175,8 +231,19 @@ registerConformance(
175
231
  **Run it in a real browser.** diagram-js engines compute viewboxes from the
176
232
  host's client size and use `getBBox`, which jsdom lacks and a detached or 0×0
177
233
  element turns into NaN: mount into `attachedHost()` (a sized element appended
178
- to `document.body`) under vitest browser mode / Playwright — the modeler
179
- repos' `test:browser` project — not under the unit project.
234
+ to `document.body`) under vitest browser mode / Playwright (the modeler
235
+ repos' `test:browser` project), not under the unit project. The keys case
236
+ finds the canvas in the `attachedHost()` that `create()` (and
237
+ `createReadonly()`) mounted into: the focusable `<svg>` diagram-js places
238
+ there, or the element `canvasElement` names for a modeler that takes its keys
239
+ elsewhere.
240
+
241
+ **Run the cases one after another.** `registerConformance` registers one test
242
+ per case; run them in order (the default of vitest, jest and node:test within a
243
+ file), never with `test.concurrent`. The keys case moves the focus and
244
+ dispatches keys in the one document every mounted instance listens to: a case
245
+ running beside it would see those keys or lose its focus, and the keys case
246
+ could not tell which `attachedHost()` holds its modeler.
180
247
 
181
248
  **Canonical texts are yours to vouch for.** L2 proves `exportText` is a
182
249
  fixpoint of the texts you provide. Obtain them by exporting a hand-authored
@@ -192,14 +259,24 @@ L1 interaction case reports a skip; with `openInteraction` but without
192
259
  `interactionState` only its keep-and-reopen half does. A modeler without
193
260
  `elements.bounds` skips the bounds case; with it, a harness without
194
261
  `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.
262
+ diagram-coordinates half. A modeler without `contentBounds` skips the
263
+ content-bounds case; with it, a harness without `viewState` skips only its
264
+ view half, and a modeler without `elements.bounds` only its covering half.
265
+ Without `openInteraction` or `viewState` the L5 interaction case reports a
266
+ skip; without `commitInteraction` only its commit halves do. The keys case
267
+ reports a skip without a DOM and when it cannot find the canvas: `create()` or
268
+ `createReadonly()` mounted into no `attachedHost()` holding a focusable `<svg>`
269
+ (or into two), or `canvasElement` names no element or one outside every
270
+ `attachedHost()`. After its other checks it also reports one when it cannot see
271
+ a key act on the editable modeler: a modeler without `elements`, a canvas that
272
+ does not take the focus, or Delete and Backspace that leave the revealed
273
+ element in place. A case reports its advisories before its skip, so an
274
+ `onSkip` that throws still shows what the keys case found.
198
275
 
199
276
  **Every rejection is probed.** `invalidTexts` lists what MUST reject; the kit
200
277
  does not take `[]` on trust. The L7 probe case derives damaged forms from
201
- every canonical text — its first half, its first third, the text without its
202
- last character, the text followed by a NUL character — plus your optional
278
+ every canonical text (its first half, its first third, the text without its
279
+ last character, the text followed by a NUL character) plus your optional
203
280
  `probeTexts`, and imports each over a document with undo history. A probe may
204
281
  import or reject: one that imports must be silent (L1) with `exportText`
205
282
  total (L6); one that rejects must leave the document, its element ids, the
@@ -208,40 +285,67 @@ valid import must render and the next user command must apply. The case runs
208
285
  for every harness and never skips.
209
286
 
210
287
  **Calls before the first import.** A fresh instance gets `exportText()` and
211
- one `mutate` before any document (throws tolerated — what they return is
212
- unspecified — and a `mutate` that never settles too: the kit waits 2 s for
213
- it, so a harness that waits for its command's change event still runs); then
288
+ one `mutate` before any document. Both may throw or return anything, and the
289
+ `mutate` may never settle: the kit waits 2 s for it, so a harness that waits
290
+ for its command's change event still runs. Then
214
291
  importing the first two canonical texts must render each exactly like a
215
292
  fresh instance does, and the next `mutate` must emit and apply.
216
293
 
217
294
  **Interactions open during an import.** `openInteraction` opens an editor on
218
295
  an element of `canonicalTexts[0]` and types without committing. The kit then
219
- imports over it twice — a different document (`canonicalTexts[1]`, the edited
296
+ imports over it twice: a different document (`canonicalTexts[1]`, the edited
220
297
  element is usually gone; a harness with one canonical text reports that leg
221
- as skipped) and the same document again (the element is still there) — and
222
- requires no change event, the imported document as the export and an undo
298
+ as skipped) and the same document again (the element is still there). Each
299
+ import requires no change event, the imported document as the export and an undo
223
300
  that changes nothing (L1, L4). `interactionState` lets it check the
224
301
  recommended keep-and-reopen on the same-document import.
225
302
 
226
303
  **Interactions open during a host's view change.** On a fresh import of
227
304
  `canonicalTexts[0]` the kit opens the interaction, commits it with
228
305
  `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
306
+ input (`interactionState().text`), proof the case can see a commit. It then
230
307
  re-imports, opens the interaction again and calls `setViewState(viewState)`:
231
308
  no change event, the export unchanged (L5), and the view really changed. The
232
309
  RECOMMENDED keep-open is an advisory: `interactionState` must still report the
233
310
  same element and input, and `commitInteraction` must then write the same
234
- document as without the view change — so `openInteraction` types the same
311
+ document as without the view change, so `openInteraction` types the same
235
312
  input on every call.
236
313
 
314
+ **Keys stay on the canvas.** The keys case imports `canonicalTexts[0]`, runs
315
+ `mutate` (undo history for Cmd/Ctrl+Z) and reveals the first listed element (a
316
+ selection for Delete and the arrows). It then puts focusable host controls
317
+ beside the modeler, inside the host element and next to the canvas (a button, a
318
+ text input, a textarea, a select, a contenteditable, a focusable element in a
319
+ `.monaco-editor` and, where the browser has EditContext, an EditContext editor)
320
+ and aims the keys the Miragon canvases and diagram-js's keyboard bind at each
321
+ of them, the focus on that control, and at `<body>`: with nothing focused,
322
+ right after the canvas lost the focus, and while the host element's wrapper is
323
+ `display: none` or `inert`. The keys are Backspace, Delete, the arrows (plain
324
+ and with Shift), Enter, Escape, Space, Tab, the single-key tools
325
+ (`a c e h l n p s u`), Shift+1, and `a c v x d z`, Shift+Z, `y`, `+`, `=`, `-`,
326
+ `0` and the arrows (plain and with Shift) once with Ctrl and once with Meta.
327
+ Each key must leave the export, the change events, the selection,
328
+ `defaultPrevented` and its propagation to the window as they were; a keydown and
329
+ a keyup without a key, on the focused canvas, on `<body>` and on a host button
330
+ and input, must throw nothing. What it finds is one advisory; after the first
331
+ key that changed something on a control it moves to the next, on a fresh
332
+ document. Last, Delete (or Backspace) on the focused canvas must change the
333
+ document, the revealed element removed: proof that the case's keys reach the
334
+ modeler at all. With `createReadonly` it then mounts the viewer, imports and
335
+ reveals the same way and aims the same keys at the same targets; the viewer
336
+ edits nothing, so no key has to act on it. The case checks the canvas at rest:
337
+ no tool, popup or editor of the modeler's own is open (a draw tool, a context
338
+ pad, a label editor), since the kit cannot open one; the renderers' own tests
339
+ cover those modes.
340
+
237
341
  **Recommendations are advisories.** Where the contract only RECOMMENDS
238
342
  (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
343
+ an import, keep-open across a host's view change, keys that stay on the canvas),
344
+ the kit reports what it finds as an ADVISORY through `onAdvisory` (`console.warn` by default) and never
241
345
  fails: a recommendation becomes a law, with a failing case, only in a major
242
346
  release. The collision check imports the same canonical text into two fresh
243
347
  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
348
+ id, unless both new elements carry the same name and the id spells it out
245
349
  (its letters and digits contain the name's, as `component_new_component` does
246
350
  for "New Component"). Such an id is derived from the content and collides for
247
351
  identical content by design, so the check reports a skip instead: let
@@ -251,34 +355,49 @@ instances, as the reference harness does).
251
355
  **View state is compared by readback.** The kit never compares
252
356
  `getViewState()` with your `viewState` literal: it sets it, reads the
253
357
  modeler's own (possibly normalised) state back, and requires that readback
254
- to survive a re-import — a diagram-js viewbox that comes back rounded with
358
+ to survive a re-import: a diagram-js viewbox that comes back rounded with
255
359
  `scale`/`inner`/`outer` is fine.
256
360
 
257
361
  **Element boxes are verified live.** With `elements.bounds` present, every
258
362
  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
363
+ `undefined`: after an import, after `mutate` (the new element has its box at
260
364
  once) and after `moveElement` (the moved element's box changed position).
261
365
  `setViewState(viewState)` must leave every box as it was: they are diagram
262
366
  coordinates. A viewer that exposes `bounds` is held to the same listing rules.
263
367
 
368
+ **The content box is verified around every element.** With `contentBounds`
369
+ present, it must not throw and must give `undefined` or a finite box with a
370
+ non-negative size before the first import; after importing each canonical text
371
+ and after `mutate` it must give a finite box as soon as an element is listed,
372
+ and that box must cover every element's finite `elements.bounds` box (the new
373
+ element included, so a box measured at the last import fails), give or take
374
+ what float32 rounding takes off a box measured on the live SVG (`getBBox`): a
375
+ thousandth of a unit, or 2^-20 of the edge where that is more.
376
+ `setViewState(viewState)` must leave it as it was. A viewer that exposes `contentBounds` is held to the
377
+ same rules after an import.
378
+
264
379
  **Element identity is verified through names.** After an unrelated edit, and
265
380
  after an export → import into the same instance, a fresh instance and the
266
381
  viewer, every id the session reported must still exist and, where the
267
382
  notation has names, still name the same element; a notation whose elements
268
383
  carry no names is verified for id existence only.
269
384
 
270
- The kit throws plain `Error`s — no assertion-library or runner dependency.
385
+ The kit throws plain `Error`s, with no assertion-library or runner dependency.
271
386
  Its own CI proves it bites: a conforming reference modeler
272
- ([`test/reference-modeler.ts`](test/reference-modeler.ts) — also the model
387
+ ([`test/reference-modeler.ts`](test/reference-modeler.ts), also the model
273
388
  for a harness) passes every case and advises nothing, one deliberately broken
274
389
  variant per historical drift AND per kit case fails exactly the case set that
275
390
  encodes the violated law (not every individual assertion has a saboteur;
276
391
  every case does), and one variant per recommendation fails nothing and
277
- advises exactly once.
392
+ advises exactly once. That suite runs without a DOM, which proves the other
393
+ cases need none; the keys case needs one, so a second suite
394
+ ([`test/keys.conformance.test.ts`](test/keys.conformance.test.ts), jsdom) mounts
395
+ the reference into `attachedHost()`, runs every case on it, and holds the keys
396
+ saboteurs.
278
397
 
279
398
  ## Versioning
280
399
 
281
- One identity: `MODELER_API_VERSION` IS the npm package version — release-please
400
+ One identity: `MODELER_API_VERSION` IS the npm package version: release-please
282
401
  rewrites the constant on every release (`extra-files`) and a test pins it to
283
402
  `package.json` exactly. Modelers pin the package and put the constant into
284
403
  `meta.apiVersion`; hosts match on the major (`apiVersionsCompatible`).
@@ -313,19 +432,23 @@ that upgrade by its own choice, and the skip names what to add or change.
313
432
 
314
433
  Recommendations today, proposed as hard laws for the next major:
315
434
 
316
- - **Collision-free element ids across peers** — two instances that import the
435
+ - **Collision-free element ids across peers:** two instances that import the
317
436
  same document and each create a different element never allocate the same
318
437
  id for them; ids derived from the content (OWM's `component_<slug>`) meet
319
438
  this by construction and collide only for identical content (today:
320
439
  `ElementInfo.id` RECOMMENDED, advisory in the elements collision case).
321
- - **Keep and reopen an interaction across an import** — an interaction open
440
+ - **Keep and reopen an interaction across an import:** an interaction open
322
441
  during an import is reopened, with its typed input, on the element with the
323
442
  same id when that element is still there (today: L1 RECOMMENDED, advisory in
324
443
  the L1 interaction case).
325
- - **Keep an interaction open across a host's view change** — after
444
+ - **Keep an interaction open across a host's view change:** after
326
445
  `setViewState` (or a host's fit) the interaction is still open with its typed
327
446
  input, and the user's commit writes what it would have written without the
328
447
  view change (today: L5 RECOMMENDED, advisory in the L5 interaction case).
448
+ - **Keys stay on the canvas:** a key acts only while the canvas holds the
449
+ focus; a key aimed at the host's controls and editors or at `<body>` is left
450
+ alone, and a keydown or keyup without a key throws nothing (today:
451
+ RECOMMENDED since 1.4, advisory in the keys case).
329
452
 
330
453
  ## License
331
454
 
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- var MODELER_API_VERSION = "1.2.0";
2
+ var MODELER_API_VERSION = "1.4.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-N4TQ7UHQ.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.4.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 *\n * KEYS STAY ON THE CANVAS (1.4, RECOMMENDED) — a modeler acts on a key only\n * while its CANVAS holds the focus: the key's target is the focusable\n * element the modeler placed in the host to take its keys (diagram-js: the\n * canvas `<svg>`, `tabindex=\"0\"`) or lies inside it. Every other key is the\n * page's: one aimed at the host's chrome or at a host editor (a button, a\n * text input, a select, a contenteditable, an EditContext or Monaco\n * editor), beside the modeler's container, inside it or next to the canvas\n * in a host's overlay, and one whose target is `<body>` (nothing focused:\n * after a load, after a click on page text, while the host hides the canvas\n * or makes it inert). The modeler leaves such a key alone: no command, no\n * change of the document or the selection, no `preventDefault()` and no\n * `stopPropagation()`, in the capture phase too. A keydown or keyup\n * without a string `key` (browser autofill dispatches both) is ignored\n * without an error. The rule holds for the viewer mount too. Hosts embed\n * modelers next to their own editors and toolbars: a key the canvas takes\n * from there deletes the selection while the user types, undoes the last\n * edit from a toolbar button or swallows Cmd/Ctrl+C in a text field. The\n * modeler's own chrome (its palette, context pad, label editor) handles its\n * keys as it sees fit; a host that wants keyboard editing hands the canvas\n * the focus (a click on it, Tab, or `focus()` on the canvas element).\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. RECOMMENDED\n * (1.4): the canvas placed here takes keys only while it holds the focus\n * (KEYS STAY ON THE CANVAS above) */\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":[]}