@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.
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  MODELER_API_VERSION,
3
3
  apiVersionsCompatible
4
- } from "./chunk-E6KJB7QH.js";
4
+ } from "./chunk-N4TQ7UHQ.js";
5
5
 
6
6
  // src/conformance.ts
7
+ var nextHostSerial = 0;
8
+ var attachedHosts = [];
7
9
  function attachedHost(width = 800, height = 600) {
8
10
  const wrapper = document.createElement("div");
9
11
  wrapper.style.cssText = `width:${width}px;height:${height}px;position:absolute;left:0;top:0;overflow:hidden`;
@@ -11,6 +13,8 @@ function attachedHost(width = 800, height = 600) {
11
13
  host.style.cssText = "width:100%;height:100%";
12
14
  wrapper.append(host);
13
15
  document.body.append(wrapper);
16
+ attachedHosts = attachedHosts.filter((entry) => entry.wrapper.isConnected);
17
+ attachedHosts.push({ serial: nextHostSerial++, host, wrapper });
14
18
  return host;
15
19
  }
16
20
  function fail(message) {
@@ -28,6 +32,52 @@ var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
28
32
  var hasBounds = (elements) => typeof elements.bounds === "function";
29
33
  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;
30
34
  var describeBox = (box) => box === void 0 ? "undefined" : `{ x: ${box.x}, y: ${box.y}, width: ${box.width}, height: ${box.height} }`;
35
+ var hasContentBounds = (m) => typeof m.contentBounds === "function";
36
+ var edgeTolerance = (edge) => Math.max(1e-3, Math.abs(edge) * 2 ** -20);
37
+ var covers = (outer, inner) => {
38
+ const right = outer.x + outer.width;
39
+ const bottom = outer.y + outer.height;
40
+ return inner.x >= outer.x - edgeTolerance(outer.x) && inner.y >= outer.y - edgeTolerance(outer.y) && inner.x + inner.width <= right + edgeTolerance(right) && inner.y + inner.height <= bottom + edgeTolerance(bottom);
41
+ };
42
+ function contentBoxOf(m, when) {
43
+ let box;
44
+ try {
45
+ box = m.contentBounds();
46
+ } catch (e) {
47
+ fail(`contentBounds() threw ${when}: ${String(e)}`);
48
+ }
49
+ if (box === void 0) return void 0;
50
+ if (!isFiniteBox(box)) {
51
+ fail(
52
+ `contentBounds() ${when} is ${describeBox(box)} \u2014 the content box must be finite with a non-negative size, or undefined when there is nothing to fit`
53
+ );
54
+ }
55
+ return { x: box.x, y: box.y, width: box.width, height: box.height };
56
+ }
57
+ function expectContentAroundElements(m, when) {
58
+ const box = contentBoxOf(m, when);
59
+ const listed2 = m.elements?.list() ?? [];
60
+ if (box === void 0) {
61
+ if (listed2.length > 0) {
62
+ fail(
63
+ `contentBounds() ${when} is undefined while elements.list() reports ${listed2.length} element(s) \u2014 undefined means there is nothing to fit`
64
+ );
65
+ }
66
+ return void 0;
67
+ }
68
+ if (m.elements && hasBounds(m.elements)) {
69
+ for (const { id } of listed2) {
70
+ const element = m.elements.bounds(id);
71
+ if (!isFiniteBox(element)) continue;
72
+ if (!covers(box, element)) {
73
+ fail(
74
+ `contentBounds() ${when} is ${describeBox(box)}, which does not cover elements.bounds('${id}') = ${describeBox(element)} \u2014 the content box holds every element (and reads the live canvas)`
75
+ );
76
+ }
77
+ }
78
+ }
79
+ return box;
80
+ }
31
81
  function boxesOf(elements, when) {
32
82
  const boxes = /* @__PURE__ */ new Map();
33
83
  for (const { id } of elements.list()) {
@@ -110,7 +160,9 @@ function probesOf(h) {
110
160
  { label: `${source} followed by a NUL character`, text: `${text}\0` }
111
161
  );
112
162
  });
113
- (h.probeTexts ?? []).forEach((text, index) => probes.push({ label: `probeTexts[${index}]`, text }));
163
+ (h.probeTexts ?? []).forEach(
164
+ (text, index) => probes.push({ label: `probeTexts[${index}]`, text })
165
+ );
114
166
  const seen = /* @__PURE__ */ new Set();
115
167
  return probes.filter((probe) => {
116
168
  if (seen.has(probe.text)) return false;
@@ -123,7 +175,9 @@ async function importContentText(h, m) {
123
175
  await m.importText(text);
124
176
  if (!m.elements || m.elements.list().length > 0) return text;
125
177
  }
126
- fail("L5 needs a canonical text that renders at least one element \u2014 an empty document has no fitted view");
178
+ fail(
179
+ "L5 needs a canonical text that renders at least one element \u2014 an empty document has no fitted view"
180
+ );
127
181
  }
128
182
  function conformanceCases() {
129
183
  return [
@@ -161,13 +215,19 @@ function conformanceCases() {
161
215
  for (const [index, text] of texts.entries()) {
162
216
  const source = `canonicalTexts[${index}]`;
163
217
  await control.importText(text);
164
- const expected = exportOrFail(control, `after importing ${source} into a fresh instance`);
218
+ const expected = exportOrFail(
219
+ control,
220
+ `after importing ${source} into a fresh instance`
221
+ );
165
222
  await importOrFail(
166
223
  m,
167
224
  text,
168
225
  `importText(${source}) rejected on an instance that ran exportText() and a user command before its first import (wedged)`
169
226
  );
170
- last = exportOrFail(m, `after importing ${source} into an instance used before its first import`);
227
+ last = exportOrFail(
228
+ m,
229
+ `after importing ${source} into an instance used before its first import`
230
+ );
171
231
  if (last !== expected) {
172
232
  fail(
173
233
  `importText(${source}) on an instance that ran exportText() and a user command before its first import did not render that document:
@@ -185,7 +245,9 @@ ${last}`
185
245
  );
186
246
  }
187
247
  if (exportOrFail(m, "after a user mutation on an instance used before its first import") === last) {
188
- fail("a user mutation did not apply on an instance used before its first import (wedged)");
248
+ fail(
249
+ "a user mutation did not apply on an instance used before its first import (wedged)"
250
+ );
189
251
  }
190
252
  } finally {
191
253
  m.destroy();
@@ -204,7 +266,11 @@ ${last}`
204
266
  ...(h.foreignTexts ?? []).map((f) => f.text)
205
267
  ];
206
268
  for (const text of texts) {
207
- const { calls } = await changeEvents(h, m, () => m.importText(text).then(() => void 0));
269
+ const { calls } = await changeEvents(
270
+ h,
271
+ m,
272
+ () => m.importText(text).then(() => void 0)
273
+ );
208
274
  if (calls > 0) fail(`importText fired onContentChanged ${calls}x`);
209
275
  }
210
276
  } finally {
@@ -220,8 +286,13 @@ ${last}`
220
286
  await m.importText(h.canonicalTexts[0] ?? "");
221
287
  await h.mutate(m);
222
288
  await (h.settle ?? defaultSettle)();
223
- const { calls } = await changeEvents(h, m, () => m.importText(secondText(h)).then(() => void 0));
224
- if (calls > 0) fail(`importText over a non-empty undo history fired onContentChanged ${calls}x`);
289
+ const { calls } = await changeEvents(
290
+ h,
291
+ m,
292
+ () => m.importText(secondText(h)).then(() => void 0)
293
+ );
294
+ if (calls > 0)
295
+ fail(`importText over a non-empty undo history fired onContentChanged ${calls}x`);
225
296
  } finally {
226
297
  m.destroy();
227
298
  }
@@ -260,9 +331,15 @@ ${last}`
260
331
  await (h.settle ?? defaultSettle)();
261
332
  const opened = h.interactionState?.(m);
262
333
  if (h.interactionState && opened === void 0) {
263
- fail("interactionState reports no interaction after openInteraction \u2014 the case proves nothing");
334
+ fail(
335
+ "interactionState reports no interaction after openInteraction \u2014 the case proves nothing"
336
+ );
264
337
  }
265
- const { calls } = await changeEvents(h, m, () => m.importText(leg.text).then(() => void 0));
338
+ const { calls } = await changeEvents(
339
+ h,
340
+ m,
341
+ () => m.importText(leg.text).then(() => void 0)
342
+ );
266
343
  if (calls > 0) {
267
344
  fail(
268
345
  `importing ${leg.what} over an open interaction fired onContentChanged ${calls}x \u2014 the interaction committed during or because of the import (if every import emits, see the L1 cases first)`
@@ -318,11 +395,13 @@ ${last}`
318
395
  await m.importText(text);
319
396
  const out = m.exportText();
320
397
  if (out !== text)
321
- fail(`export after import differs from the canonical input:
398
+ fail(
399
+ `export after import differs from the canonical input:
322
400
  --- in
323
401
  ${text}
324
402
  --- out
325
- ${out}`);
403
+ ${out}`
404
+ );
326
405
  if (m.exportText() !== out)
327
406
  fail("exportText is not deterministic \u2014 two exports of one document differ");
328
407
  }
@@ -390,7 +469,9 @@ ${out}`);
390
469
  if (calls === 0) fail(`config mutation "${mutation.name}" fired no onContentChanged`);
391
470
  const after = m.exportText();
392
471
  if (after === before) {
393
- fail(`config mutation "${mutation.name}" did not change the export \u2014 the case proves nothing`);
472
+ fail(
473
+ `config mutation "${mutation.name}" did not change the export \u2014 the case proves nothing`
474
+ );
394
475
  }
395
476
  if (exportAtEmit !== after) {
396
477
  fail(
@@ -463,7 +544,9 @@ ${out}`);
463
544
  const first = h.canonicalTexts[0];
464
545
  const second = h.canonicalTexts[1];
465
546
  if (first === void 0 || second === void 0 || first === second) {
466
- fail("the L4 case needs TWO DISTINCT canonicalTexts \u2014 with one, stale undo is undetectable");
547
+ fail(
548
+ "the L4 case needs TWO DISTINCT canonicalTexts \u2014 with one, stale undo is undetectable"
549
+ );
467
550
  }
468
551
  const m = await h.create();
469
552
  try {
@@ -490,7 +573,9 @@ ${out}`);
490
573
  await h.undo(m);
491
574
  await h.undo(m);
492
575
  if (m.exportText() !== baseline) {
493
- fail("undo after importText mutated the freshly imported document (stale history applied)");
576
+ fail(
577
+ "undo after importText mutated the freshly imported document (stale history applied)"
578
+ );
494
579
  }
495
580
  } finally {
496
581
  m.destroy();
@@ -533,7 +618,9 @@ ${out}`);
533
618
  const applied = m.getViewState();
534
619
  assertFinite(applied, "after setViewState");
535
620
  if (same(applied, fitted)) {
536
- fail("setViewState(viewState) had no effect \u2014 supply a state that differs from the fitted view");
621
+ fail(
622
+ "setViewState(viewState) had no effect \u2014 supply a state that differs from the fitted view"
623
+ );
537
624
  }
538
625
  await m.importText(text);
539
626
  if (!same(m.getViewState(), fitted)) {
@@ -542,7 +629,8 @@ ${out}`);
542
629
  );
543
630
  }
544
631
  const { calls } = await changeEvents(h, m, () => m.setViewState(applied));
545
- if (calls > 0) fail("setViewState fired onContentChanged \u2014 view state is not document content");
632
+ if (calls > 0)
633
+ fail("setViewState fired onContentChanged \u2014 view state is not document content");
546
634
  if (m.exportText() !== doc)
547
635
  fail("setViewState changed the export \u2014 view state leaked into the document");
548
636
  if (!same(m.getViewState(), applied)) {
@@ -582,7 +670,9 @@ ${out}`);
582
670
  await settle();
583
671
  const opened2 = h.interactionState?.(m);
584
672
  if (h.interactionState && opened2 === void 0) {
585
- fail("interactionState reports no interaction after openInteraction \u2014 the case proves nothing");
673
+ fail(
674
+ "interactionState reports no interaction after openInteraction \u2014 the case proves nothing"
675
+ );
586
676
  }
587
677
  return opened2;
588
678
  };
@@ -597,7 +687,11 @@ ${out}`);
597
687
  "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)"
598
688
  );
599
689
  }
600
- await importOrFail(m, first, "re-importing canonicalTexts[0] after the commit rejected");
690
+ await importOrFail(
691
+ m,
692
+ first,
693
+ "re-importing canonicalTexts[0] after the commit rejected"
694
+ );
601
695
  }
602
696
  const opened = await open();
603
697
  const view = m.getViewState();
@@ -671,7 +765,8 @@ ${out}`;
671
765
  const { calls } = await changeEvents(h, m, () => {
672
766
  if (elements.reveal(firstId) !== true) fail("reveal(known id) must return true");
673
767
  });
674
- if (calls > 0) fail("reveal fired onContentChanged \u2014 revealing is view behavior, not an edit");
768
+ if (calls > 0)
769
+ fail("reveal fired onContentChanged \u2014 revealing is view behavior, not an edit");
675
770
  if (!deliveries.some((d) => d.includes(firstId))) {
676
771
  fail(
677
772
  `reveal('${firstId}') delivered no selection containing the id \u2014 is it a selectable element (not a root or a label)?`
@@ -686,7 +781,8 @@ ${out}`;
686
781
  }
687
782
  }
688
783
  }
689
- if (!elements.selection().includes(firstId)) fail("selection() does not contain the revealed id");
784
+ if (!elements.selection().includes(firstId))
785
+ fail("selection() does not contain the revealed id");
690
786
  const seen = deliveries.length;
691
787
  off();
692
788
  const otherId = ids[1];
@@ -860,7 +956,9 @@ ${out}`;
860
956
  await settle();
861
957
  const was = before.get(movedId);
862
958
  if (was === void 0)
863
- fail(`moveElement returned '${movedId}', an id elements.list() did not report before the move`);
959
+ fail(
960
+ `moveElement returned '${movedId}', an id elements.list() did not report before the move`
961
+ );
864
962
  const box = boxesOf(elements, "after a move").get(movedId);
865
963
  if (box === void 0)
866
964
  fail(`'${movedId}' is gone from elements.list() after moveElement moved it`);
@@ -870,7 +968,9 @@ ${out}`;
870
968
  );
871
969
  }
872
970
  } else {
873
- unverified.push("moveElement is undefined \u2014 that a box follows a move was not verified");
971
+ unverified.push(
972
+ "moveElement is undefined \u2014 that a box follows a move was not verified"
973
+ );
874
974
  }
875
975
  if (h.viewState !== void 0) {
876
976
  const atRest = boxesOf(elements, "before a view change");
@@ -903,6 +1003,66 @@ ${out}`;
903
1003
  }
904
1004
  }
905
1005
  },
1006
+ {
1007
+ name: "contentBounds (when present): a finite box in diagram coordinates around every listed element, live after a new element, unchanged by zoom and scroll",
1008
+ async run(h) {
1009
+ const settle = h.settle ?? defaultSettle;
1010
+ const m = await h.create();
1011
+ let viewer;
1012
+ try {
1013
+ if (!hasContentBounds(m))
1014
+ return skip("the modeler exposes no contentBounds (optional, 1.3) \u2014 nothing to verify");
1015
+ contentBoxOf(m, "before the first import");
1016
+ for (const [index, text] of h.canonicalTexts.entries()) {
1017
+ await importOrFail(m, text, `importText(canonicalTexts[${index}]) rejected`);
1018
+ expectContentAroundElements(m, `after importing canonicalTexts[${index}]`);
1019
+ }
1020
+ await importOrFail(
1021
+ m,
1022
+ h.canonicalTexts[0] ?? "",
1023
+ "importText(canonicalTexts[0]) rejected"
1024
+ );
1025
+ await h.mutate(m);
1026
+ await settle();
1027
+ const atRest = expectContentAroundElements(m, "after a user mutation added an element");
1028
+ const unverified = [];
1029
+ if (h.viewState !== void 0) {
1030
+ m.setViewState(h.viewState);
1031
+ const viewed = contentBoxOf(m, "after setViewState");
1032
+ if (!same(viewed, atRest)) {
1033
+ fail(
1034
+ `contentBounds() changed with the view (${describeBox(atRest)} \u2192 ${describeBox(viewed)}) \u2014 the content box is in diagram coordinates, which zoom and scroll leave alone`
1035
+ );
1036
+ }
1037
+ } else {
1038
+ unverified.push(
1039
+ "viewState is undefined \u2014 that the content box is independent of the view was not verified"
1040
+ );
1041
+ }
1042
+ if (!m.elements || !hasBounds(m.elements)) {
1043
+ unverified.push(
1044
+ "the modeler exposes no elements.bounds \u2014 that the content box covers every element was not verified"
1045
+ );
1046
+ }
1047
+ if (h.createReadonly) {
1048
+ viewer = await h.createReadonly();
1049
+ if (hasContentBounds(viewer)) {
1050
+ contentBoxOf(viewer, "on the viewer before the first import");
1051
+ await importOrFail(
1052
+ viewer,
1053
+ h.canonicalTexts[0] ?? "",
1054
+ "the viewer rejected canonicalTexts[0]"
1055
+ );
1056
+ expectContentAroundElements(viewer, "on the viewer after an import");
1057
+ }
1058
+ }
1059
+ if (unverified.length > 0) return skip(unverified.join("; "));
1060
+ } finally {
1061
+ m.destroy();
1062
+ viewer?.destroy();
1063
+ }
1064
+ }
1065
+ },
906
1066
  {
907
1067
  name: "L6: export is total after imports, user mutations and undo",
908
1068
  async run(h) {
@@ -941,7 +1101,9 @@ ${out}`;
941
1101
  name: "L7: a rejected import leaves no trace",
942
1102
  async run(h) {
943
1103
  if (h.invalidTexts === void 0) {
944
- return skip("invalidTexts is undefined \u2014 pass [] if the notation has no non-importable text");
1104
+ return skip(
1105
+ "invalidTexts is undefined \u2014 pass [] if the notation has no non-importable text"
1106
+ );
945
1107
  }
946
1108
  if (h.invalidTexts.length === 0) return;
947
1109
  const m = await h.create();
@@ -994,7 +1156,9 @@ ${out}`;
994
1156
  name: "L7: the modeler stays usable after a rejected import",
995
1157
  async run(h) {
996
1158
  if (h.invalidTexts === void 0) {
997
- return skip("invalidTexts is undefined \u2014 pass [] if the notation has no non-importable text");
1159
+ return skip(
1160
+ "invalidTexts is undefined \u2014 pass [] if the notation has no non-importable text"
1161
+ );
998
1162
  }
999
1163
  const bad = h.invalidTexts[0];
1000
1164
  if (bad === void 0) return;
@@ -1006,12 +1170,15 @@ ${out}`;
1006
1170
  () => void 0
1007
1171
  );
1008
1172
  const next = secondText(h);
1009
- await m.importText(next).catch((e) => fail(`the next VALID import after a rejection did not render: ${String(e)}`));
1173
+ await m.importText(next).catch(
1174
+ (e) => fail(`the next VALID import after a rejection did not render: ${String(e)}`)
1175
+ );
1010
1176
  if (m.exportText() !== next)
1011
1177
  fail("the next valid import after a rejection did not replace the document");
1012
1178
  const before = m.exportText();
1013
1179
  const { calls } = await changeEvents(h, m, () => h.mutate(m));
1014
- if (calls === 0) fail("a user mutation after a rejected import fired no onContentChanged (wedged)");
1180
+ if (calls === 0)
1181
+ fail("a user mutation after a rejected import fired no onContentChanged (wedged)");
1015
1182
  if (exportOrFail(m, "after a mutation that followed a rejected import") === before) {
1016
1183
  fail("a user mutation after a rejected import did not apply (wedged)");
1017
1184
  }
@@ -1073,7 +1240,9 @@ ${out}`;
1073
1240
  if (rejected) {
1074
1241
  if (calls > 0) fail(`a rejected ${what} fired onContentChanged ${calls}x (L7)`);
1075
1242
  if (exportOrFail(m, `after a rejected ${what}`) !== doc) {
1076
- fail(`a rejected ${what} changed the document \u2014 the previous state must stay intact (L7)`);
1243
+ fail(
1244
+ `a rejected ${what} changed the document \u2014 the previous state must stay intact (L7)`
1245
+ );
1077
1246
  }
1078
1247
  if (ids && !same(ids, sortedIds(m))) {
1079
1248
  fail(
@@ -1090,7 +1259,9 @@ ${out}`;
1090
1259
  }
1091
1260
  } else {
1092
1261
  if (calls > 0)
1093
- fail(`${what} resolved and fired onContentChanged ${calls}x \u2014 an import is silent (L1)`);
1262
+ fail(
1263
+ `${what} resolved and fired onContentChanged ${calls}x \u2014 an import is silent (L1)`
1264
+ );
1094
1265
  exportOrFail(m, `after ${what} resolved (L6)`);
1095
1266
  }
1096
1267
  previous = probe;
@@ -1105,7 +1276,8 @@ ${out}`;
1105
1276
  {
1106
1277
  name: "viewer (when shipped): read-only, silent, canonical, fits, reveals, keeps foreign content, unshaken by a rejected import",
1107
1278
  async run(h) {
1108
- if (!h.createReadonly) return skip("createReadonly is undefined \u2014 the package ships no viewer mount");
1279
+ if (!h.createReadonly)
1280
+ return skip("createReadonly is undefined \u2014 the package ships no viewer mount");
1109
1281
  const v = await h.createReadonly();
1110
1282
  try {
1111
1283
  if (v.meta.editable !== false)
@@ -1181,6 +1353,10 @@ ${out}`;
1181
1353
  }
1182
1354
  }
1183
1355
  },
1356
+ {
1357
+ name: "keys (recommended): a key aimed at the host's controls and editors or at <body> is left alone, a keydown or keyup without a key throws nothing, a key on the focused canvas acts",
1358
+ run: keysStayOnTheCanvas
1359
+ },
1184
1360
  {
1185
1361
  name: "destroy is safe to call twice",
1186
1362
  async run(h) {
@@ -1193,7 +1369,8 @@ ${out}`;
1193
1369
  }
1194
1370
  async function foreignRoundTrips(h, m, who) {
1195
1371
  for (const { text, mustSurvive } of h.foreignTexts ?? []) {
1196
- if (mustSurvive.length === 0) fail("a foreignTexts entry must name at least one fragment in mustSurvive");
1372
+ if (mustSurvive.length === 0)
1373
+ fail("a foreignTexts entry must name at least one fragment in mustSurvive");
1197
1374
  await m.importText(text);
1198
1375
  const first = exportOrFail(m, `${who}after importing a document with foreign content`);
1199
1376
  for (const fragment of mustSurvive) {
@@ -1202,8 +1379,397 @@ async function foreignRoundTrips(h, m, who) {
1202
1379
  }
1203
1380
  await m.importText(first);
1204
1381
  const second = exportOrFail(m, `${who}after re-importing its own export of foreign content`);
1205
- if (second !== first) fail(`${who}foreign content did not survive a second import \u2192 export round-trip`);
1382
+ if (second !== first)
1383
+ fail(`${who}foreign content did not survive a second import \u2192 export round-trip`);
1384
+ }
1385
+ }
1386
+ var CANVAS_SELECTOR = "svg[tabindex]";
1387
+ var NAMED_CODES = {
1388
+ " ": "Space",
1389
+ "+": "Equal",
1390
+ "=": "Equal",
1391
+ "-": "Minus",
1392
+ "!": "Digit1"
1393
+ };
1394
+ var codeOf = (key) => {
1395
+ if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
1396
+ if (/^\d$/.test(key)) return `Digit${key}`;
1397
+ return NAMED_CODES[key] ?? key;
1398
+ };
1399
+ var ARROWS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
1400
+ var KEY_STROKES = [
1401
+ ...[
1402
+ "Backspace",
1403
+ "Delete",
1404
+ ...ARROWS,
1405
+ "Enter",
1406
+ "Escape",
1407
+ " ",
1408
+ "Tab",
1409
+ "a",
1410
+ "c",
1411
+ "e",
1412
+ "h",
1413
+ "l",
1414
+ "n",
1415
+ "p",
1416
+ "s",
1417
+ "u"
1418
+ ].map((key) => ({ key, code: codeOf(key) })),
1419
+ ...ARROWS.map((key) => ({ key, code: key, shiftKey: true })),
1420
+ { key: "!", code: codeOf("!"), shiftKey: true },
1421
+ ...[
1422
+ { key: "a" },
1423
+ { key: "c" },
1424
+ { key: "v" },
1425
+ { key: "x" },
1426
+ { key: "d" },
1427
+ { key: "z" },
1428
+ { key: "Z", shiftKey: true },
1429
+ { key: "y" },
1430
+ { key: "+" },
1431
+ { key: "=" },
1432
+ { key: "-" },
1433
+ { key: "0" },
1434
+ ...ARROWS.flatMap((key) => [{ key }, { key, shiftKey: true }])
1435
+ ].flatMap((stroke) => [
1436
+ { ...stroke, code: codeOf(stroke.key), ctrlKey: true },
1437
+ { ...stroke, code: codeOf(stroke.key), metaKey: true }
1438
+ ])
1439
+ ];
1440
+ var describeStroke = (stroke) => [
1441
+ stroke.ctrlKey ? "Ctrl" : "",
1442
+ stroke.metaKey ? "Meta" : "",
1443
+ stroke.shiftKey ? "Shift" : "",
1444
+ stroke.key === " " ? "Space" : stroke.key === "!" ? "1" : stroke.key
1445
+ ].filter((part) => part !== "").join("+");
1446
+ var focusOn = (element) => {
1447
+ element.focus({ preventScroll: true });
1448
+ };
1449
+ var blurActive = (doc) => {
1450
+ const active = doc.activeElement;
1451
+ if (active !== null && active !== doc.body) active.blur();
1452
+ };
1453
+ var stayPut = () => void 0;
1454
+ function hostControls(parent, where) {
1455
+ const doc = parent.ownerDocument;
1456
+ const button = doc.createElement("button");
1457
+ button.type = "button";
1458
+ button.textContent = "Host button";
1459
+ const input = doc.createElement("input");
1460
+ input.value = "host text";
1461
+ const textarea = doc.createElement("textarea");
1462
+ textarea.value = "host text";
1463
+ const select = doc.createElement("select");
1464
+ for (const label of ["one", "two"]) {
1465
+ const option = doc.createElement("option");
1466
+ option.textContent = label;
1467
+ select.append(option);
1468
+ }
1469
+ const editable = doc.createElement("div");
1470
+ editable.setAttribute("contenteditable", "true");
1471
+ editable.textContent = "host text";
1472
+ const monaco = doc.createElement("div");
1473
+ monaco.className = "monaco-editor";
1474
+ const monacoInput = doc.createElement("div");
1475
+ monacoInput.className = "native-edit-context";
1476
+ monacoInput.setAttribute("role", "textbox");
1477
+ monacoInput.tabIndex = 0;
1478
+ monaco.append(monacoInput);
1479
+ const box = doc.createElement("div");
1480
+ box.append(button, input, textarea, select, editable, monaco);
1481
+ const controls = [
1482
+ ["a host <button>", button],
1483
+ ["a host text <input>", input],
1484
+ ["a host <textarea>", textarea],
1485
+ ["a host <select>", select],
1486
+ ["a host contenteditable", editable],
1487
+ ["a focusable element in a host .monaco-editor", monacoInput]
1488
+ ];
1489
+ const EditContextClass = globalThis.EditContext;
1490
+ if (typeof EditContextClass === "function") {
1491
+ const editor = doc.createElement("div");
1492
+ editor.tabIndex = 0;
1493
+ Object.assign(editor, { editContext: new EditContextClass() });
1494
+ box.append(editor);
1495
+ controls.push(["a host EditContext editor", editor]);
1206
1496
  }
1497
+ parent.append(box);
1498
+ const targets = controls.map(([what, element]) => ({
1499
+ what: `${what} ${where}`,
1500
+ element,
1501
+ enter: () => {
1502
+ focusOn(element);
1503
+ return stayPut;
1504
+ }
1505
+ }));
1506
+ return { targets, box };
1507
+ }
1508
+ function bodyTargets(doc, canvas, wrapper) {
1509
+ const body = doc.body;
1510
+ const hideWith = (hide) => () => {
1511
+ focusOn(canvas);
1512
+ const show = hide();
1513
+ blurActive(doc);
1514
+ return show;
1515
+ };
1516
+ return [
1517
+ {
1518
+ what: "<body> with nothing focused",
1519
+ element: body,
1520
+ enter: () => {
1521
+ blurActive(doc);
1522
+ return stayPut;
1523
+ }
1524
+ },
1525
+ {
1526
+ what: "<body> right after the canvas lost the focus",
1527
+ element: body,
1528
+ enter: () => {
1529
+ focusOn(canvas);
1530
+ blurActive(doc);
1531
+ return stayPut;
1532
+ }
1533
+ },
1534
+ {
1535
+ what: "<body> while the host hides the modeler (display: none)",
1536
+ element: body,
1537
+ enter: hideWith(() => {
1538
+ const display = wrapper.style.display;
1539
+ wrapper.style.display = "none";
1540
+ return () => {
1541
+ wrapper.style.display = display;
1542
+ };
1543
+ })
1544
+ },
1545
+ {
1546
+ what: "<body> while the host makes the modeler inert",
1547
+ element: body,
1548
+ enter: hideWith(() => {
1549
+ wrapper.setAttribute("inert", "");
1550
+ return () => wrapper.removeAttribute("inert");
1551
+ })
1552
+ }
1553
+ ];
1554
+ }
1555
+ var listed = (parts) => parts.length < 2 ? parts[0] ?? "" : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
1556
+ var SHOWN_KEY_FINDINGS = 12;
1557
+ var selectionOf = (m) => m.elements ? JSON.stringify([...m.elements.selection()].sort()) : "";
1558
+ function mountedCanvas(h, m, instance, firstSerial) {
1559
+ const live = attachedHosts.filter((entry) => entry.wrapper.isConnected);
1560
+ if (h.canvasElement) {
1561
+ const named = h.canvasElement(m);
1562
+ if (!named)
1563
+ return `canvasElement() names no canvas for the ${instance}; keys were not verified`;
1564
+ const mount2 = live.find((entry) => entry.host.contains(named));
1565
+ return mount2 === void 0 ? `the canvas canvasElement() names for the ${instance} lies in no attachedHost() \u2014 the kit puts host controls into that host and hides it; keys were not verified` : { canvas: named, host: mount2.host, wrapper: mount2.wrapper };
1566
+ }
1567
+ const made = instance === "modeler" ? "create()" : "createReadonly()";
1568
+ const mounts = live.filter(
1569
+ (entry) => entry.serial >= firstSerial && entry.host.querySelector(CANVAS_SELECTOR) !== null
1570
+ );
1571
+ const [mount] = mounts;
1572
+ const canvas = mount?.host.querySelector(CANVAS_SELECTOR);
1573
+ if (mount && canvas && mounts.length === 1) {
1574
+ return { canvas, host: mount.host, wrapper: mount.wrapper };
1575
+ }
1576
+ return mount === void 0 ? `${made} mounted the ${instance} into no attachedHost() that holds a focusable <svg> (one with a tabindex, diagram-js's canvas) \u2014 mount into one, or name the canvas with canvasElement; keys were not verified` : `${made} made ${mounts.length} attachedHost() elements that hold a focusable <svg> \u2014 the kit cannot tell which one is the ${instance}'s canvas (run the cases one after another, or name the canvas with canvasElement); keys were not verified`;
1577
+ }
1578
+ async function aimKeysAt(h, instance, create, findings) {
1579
+ const settle = h.settle ?? defaultSettle;
1580
+ const text = h.canonicalTexts[0] ?? "";
1581
+ const editable = instance === "modeler";
1582
+ const found = (finding) => {
1583
+ findings.push(editable ? finding : `the viewer: ${finding}`);
1584
+ };
1585
+ const firstSerial = nextHostSerial;
1586
+ const m = await create();
1587
+ const cleanups = [];
1588
+ try {
1589
+ await importOrFail(
1590
+ m,
1591
+ text,
1592
+ editable ? "importText(canonicalTexts[0]) rejected" : "the viewer rejected canonicalTexts[0]"
1593
+ );
1594
+ const mount = mountedCanvas(h, m, instance, firstSerial);
1595
+ if (typeof mount === "string") return mount;
1596
+ const { canvas, host, wrapper } = mount;
1597
+ const doc = host.ownerDocument;
1598
+ const view = doc.defaultView ?? window;
1599
+ const propagated = /* @__PURE__ */ new WeakSet();
1600
+ const reachedWindow = (event) => {
1601
+ propagated.add(event);
1602
+ };
1603
+ view.addEventListener("keydown", reachedWindow);
1604
+ view.addEventListener("keyup", reachedWindow);
1605
+ cleanups.push(() => {
1606
+ view.removeEventListener("keydown", reachedWindow);
1607
+ view.removeEventListener("keyup", reachedWindow);
1608
+ });
1609
+ let changes = 0;
1610
+ cleanups.push(
1611
+ m.onContentChanged(() => {
1612
+ changes += 1;
1613
+ })
1614
+ );
1615
+ const thrownBy = (dispatch) => {
1616
+ const thrown = [];
1617
+ const onError = (event) => {
1618
+ thrown.push(String(event.error ?? event.message));
1619
+ event.preventDefault();
1620
+ };
1621
+ view.addEventListener("error", onError);
1622
+ try {
1623
+ dispatch();
1624
+ } finally {
1625
+ view.removeEventListener("error", onError);
1626
+ }
1627
+ return thrown;
1628
+ };
1629
+ const press = (target, stroke) => {
1630
+ const init = { ...stroke, bubbles: true, cancelable: true, composed: true };
1631
+ const down = new view.KeyboardEvent("keydown", init);
1632
+ const up = new view.KeyboardEvent("keyup", init);
1633
+ const thrown = thrownBy(() => {
1634
+ target.dispatchEvent(down);
1635
+ target.dispatchEvent(up);
1636
+ });
1637
+ return {
1638
+ prevented: down.defaultPrevented || up.defaultPrevented,
1639
+ stopped: !propagated.has(down) || !propagated.has(up),
1640
+ thrown
1641
+ };
1642
+ };
1643
+ let revealed;
1644
+ const setUp = async () => {
1645
+ await importOrFail(m, text, "re-importing canonicalTexts[0] rejected");
1646
+ if (editable) await h.mutate(m);
1647
+ await settle();
1648
+ revealed = m.elements?.list()[0]?.id;
1649
+ if (revealed !== void 0) m.elements?.reveal(revealed);
1650
+ await settle();
1651
+ };
1652
+ await setUp();
1653
+ const placements = [
1654
+ [doc.body, "beside the modeler"],
1655
+ [host, "inside the modeler's container"]
1656
+ ];
1657
+ const canvasHolder = canvas.parentElement;
1658
+ if (canvasHolder !== null && canvasHolder !== host) {
1659
+ placements.push([canvasHolder, "next to the canvas"]);
1660
+ }
1661
+ const controls = placements.map(([parent, where]) => hostControls(parent, where));
1662
+ for (const { box } of controls) cleanups.push(() => box.remove());
1663
+ const outside = controls[0]?.targets ?? [];
1664
+ const targets = [
1665
+ ...controls.flatMap((placed) => placed.targets),
1666
+ ...bodyTargets(doc, canvas, wrapper)
1667
+ ];
1668
+ for (const target of targets) {
1669
+ const leave = target.enter();
1670
+ let dirty = false;
1671
+ try {
1672
+ const last = { doc: exportOrFail(m, `with the focus on ${target.what}`), changes };
1673
+ for (const stroke of KEY_STROKES) {
1674
+ const key = `${describeStroke(stroke)} on ${target.what}`;
1675
+ const selection = selectionOf(m);
1676
+ const { prevented, stopped, thrown } = press(target.element, stroke);
1677
+ const changed = [
1678
+ ...changes !== last.changes ? ["fired onContentChanged"] : [],
1679
+ ...exportOrFail(m, `after ${key}`) !== last.doc ? ["changed the document"] : [],
1680
+ ...selectionOf(m) !== selection ? ["changed the selection"] : []
1681
+ ];
1682
+ const effects = [
1683
+ ...changed,
1684
+ ...prevented ? ["prevented it"] : [],
1685
+ ...stopped ? ["stopped its propagation"] : [],
1686
+ ...thrown.map((error) => `threw ${error}`)
1687
+ ];
1688
+ if (effects.length > 0) found(`${key} ${listed(effects)}`);
1689
+ dirty = changed.length > 0;
1690
+ if (dirty) break;
1691
+ }
1692
+ if (!dirty) {
1693
+ await settle();
1694
+ if (exportOrFail(m, `once the keys on ${target.what} settled`) !== last.doc) {
1695
+ found(`a key on ${target.what} changed the document once it settled`);
1696
+ dirty = true;
1697
+ } else if (changes !== last.changes) {
1698
+ found(`a key on ${target.what} fired onContentChanged once it settled`);
1699
+ dirty = true;
1700
+ }
1701
+ }
1702
+ } finally {
1703
+ leave();
1704
+ }
1705
+ if (dirty) await setUp();
1706
+ }
1707
+ const keyless = [
1708
+ ["the focused canvas", canvas, () => focusOn(canvas)],
1709
+ ["<body> with nothing focused", doc.body, () => blurActive(doc)],
1710
+ ...outside.slice(0, 2).map((target) => [
1711
+ target.what,
1712
+ target.element,
1713
+ () => target.enter()
1714
+ ])
1715
+ ];
1716
+ for (const [what, element, enter] of keyless) {
1717
+ enter();
1718
+ for (const type of ["keydown", "keyup"]) {
1719
+ const thrown = thrownBy(
1720
+ () => element.dispatchEvent(new view.Event(type, { bubbles: true, cancelable: true }))
1721
+ );
1722
+ for (const error of thrown) found(`a ${type} without a key on ${what} threw ${error}`);
1723
+ }
1724
+ }
1725
+ if (!editable) return void 0;
1726
+ if (!m.elements) {
1727
+ return "the modeler exposes no elements surface (optional) \u2014 that Delete on the focused canvas removes a revealed element was not verified, so the case cannot tell a key the modeler leaves alone from one it never sees";
1728
+ }
1729
+ await setUp();
1730
+ focusOn(canvas);
1731
+ if (revealed === void 0) {
1732
+ return "elements.list() reports no element after importing canonicalTexts[0] and one mutate \u2014 that Delete on the focused canvas removes a revealed element was not verified, so the case cannot tell a key the modeler leaves alone from one it never sees";
1733
+ }
1734
+ if (doc.activeElement !== canvas) {
1735
+ return "the canvas did not take the focus \u2014 that a key on the focused canvas acts was not verified, so the case cannot tell a key the modeler leaves alone from one it never sees";
1736
+ }
1737
+ const before = exportOrFail(m, `with '${revealed}' revealed`);
1738
+ for (const key of ["Delete", "Backspace"]) {
1739
+ press(canvas, { key, code: key });
1740
+ await settle();
1741
+ if (exportOrFail(m, `after ${key} on the focused canvas`) !== before) return void 0;
1742
+ }
1743
+ return `Delete and Backspace on the focused canvas, with '${revealed}' revealed (selected), left the document as it was \u2014 the case cannot tell a key the modeler leaves alone from one it never sees`;
1744
+ } finally {
1745
+ for (const cleanup of cleanups) cleanup();
1746
+ m.destroy();
1747
+ }
1748
+ }
1749
+ async function keysStayOnTheCanvas(h) {
1750
+ if (typeof document === "undefined") {
1751
+ return skip(
1752
+ "no DOM \u2014 keys need a document to be aimed at (run the kit in a browser, the modeler mounted into attachedHost())"
1753
+ );
1754
+ }
1755
+ const findings = [];
1756
+ const unverified = [];
1757
+ const modelerGap = await aimKeysAt(h, "modeler", () => h.create(), findings);
1758
+ if (modelerGap !== void 0) unverified.push(modelerGap);
1759
+ if (h.createReadonly) {
1760
+ const createViewer = () => h.createReadonly?.() ?? fail("createReadonly went away during the keys case");
1761
+ const viewerGap = await aimKeysAt(h, "viewer", createViewer, findings);
1762
+ if (viewerGap !== void 0) unverified.push(viewerGap);
1763
+ }
1764
+ const more = findings.length > SHOWN_KEY_FINDINGS ? `; and ${findings.length - SHOWN_KEY_FINDINGS} more` : "";
1765
+ return {
1766
+ ...unverified.length === 0 ? {} : { skipped: unverified.join("; ") },
1767
+ ...findings.length === 0 ? {} : {
1768
+ advisories: [
1769
+ `keys left the canvas: ${findings.slice(0, SHOWN_KEY_FINDINGS).join("; ")}${more} \u2014 RECOMMENDED: act on a key only while the canvas holds the focus (its target is the canvas or lies inside it), leave every other key alone (no command, no change of the document or the selection, no preventDefault, no stopPropagation) and ignore a keydown or keyup without a key`
1770
+ ]
1771
+ }
1772
+ };
1207
1773
  }
1208
1774
  function registerConformance(harness, test, options = {}) {
1209
1775
  const onSkip = options.onSkip ?? ((name, reason) => {
@@ -1215,8 +1781,8 @@ function registerConformance(harness, test, options = {}) {
1215
1781
  for (const conformanceCase of conformanceCases()) {
1216
1782
  test(`conformance: ${conformanceCase.name}`, async () => {
1217
1783
  const outcome = await conformanceCase.run(harness);
1218
- if (outcome?.skipped) onSkip(conformanceCase.name, outcome.skipped);
1219
1784
  for (const advice of outcome?.advisories ?? []) onAdvisory(conformanceCase.name, advice);
1785
+ if (outcome?.skipped) onSkip(conformanceCase.name, outcome.skipped);
1220
1786
  });
1221
1787
  }
1222
1788
  }