@miragon/modeler-api 1.3.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 +131 -51
- package/dist/{chunk-JK23ZTVG.js → chunk-N4TQ7UHQ.js} +2 -2
- package/dist/{chunk-JK23ZTVG.js.map → chunk-N4TQ7UHQ.js.map} +1 -1
- package/dist/conformance.cjs +508 -40
- package/dist/conformance.cjs.map +1 -1
- package/dist/conformance.d.cts +32 -4
- package/dist/conformance.d.ts +32 -4
- package/dist/conformance.js +508 -40
- package/dist/conformance.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -3
- package/dist/index.d.ts +27 -3
- package/dist/index.js +1 -1
- package/package.json +5 -5
package/dist/conformance.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MODELER_API_VERSION,
|
|
3
3
|
apiVersionsCompatible
|
|
4
|
-
} from "./chunk-
|
|
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) {
|
|
@@ -52,17 +56,17 @@ function contentBoxOf(m, when) {
|
|
|
52
56
|
}
|
|
53
57
|
function expectContentAroundElements(m, when) {
|
|
54
58
|
const box = contentBoxOf(m, when);
|
|
55
|
-
const
|
|
59
|
+
const listed2 = m.elements?.list() ?? [];
|
|
56
60
|
if (box === void 0) {
|
|
57
|
-
if (
|
|
61
|
+
if (listed2.length > 0) {
|
|
58
62
|
fail(
|
|
59
|
-
`contentBounds() ${when} is undefined while elements.list() reports ${
|
|
63
|
+
`contentBounds() ${when} is undefined while elements.list() reports ${listed2.length} element(s) \u2014 undefined means there is nothing to fit`
|
|
60
64
|
);
|
|
61
65
|
}
|
|
62
66
|
return void 0;
|
|
63
67
|
}
|
|
64
68
|
if (m.elements && hasBounds(m.elements)) {
|
|
65
|
-
for (const { id } of
|
|
69
|
+
for (const { id } of listed2) {
|
|
66
70
|
const element = m.elements.bounds(id);
|
|
67
71
|
if (!isFiniteBox(element)) continue;
|
|
68
72
|
if (!covers(box, element)) {
|
|
@@ -156,7 +160,9 @@ function probesOf(h) {
|
|
|
156
160
|
{ label: `${source} followed by a NUL character`, text: `${text}\0` }
|
|
157
161
|
);
|
|
158
162
|
});
|
|
159
|
-
(h.probeTexts ?? []).forEach(
|
|
163
|
+
(h.probeTexts ?? []).forEach(
|
|
164
|
+
(text, index) => probes.push({ label: `probeTexts[${index}]`, text })
|
|
165
|
+
);
|
|
160
166
|
const seen = /* @__PURE__ */ new Set();
|
|
161
167
|
return probes.filter((probe) => {
|
|
162
168
|
if (seen.has(probe.text)) return false;
|
|
@@ -169,7 +175,9 @@ async function importContentText(h, m) {
|
|
|
169
175
|
await m.importText(text);
|
|
170
176
|
if (!m.elements || m.elements.list().length > 0) return text;
|
|
171
177
|
}
|
|
172
|
-
fail(
|
|
178
|
+
fail(
|
|
179
|
+
"L5 needs a canonical text that renders at least one element \u2014 an empty document has no fitted view"
|
|
180
|
+
);
|
|
173
181
|
}
|
|
174
182
|
function conformanceCases() {
|
|
175
183
|
return [
|
|
@@ -207,13 +215,19 @@ function conformanceCases() {
|
|
|
207
215
|
for (const [index, text] of texts.entries()) {
|
|
208
216
|
const source = `canonicalTexts[${index}]`;
|
|
209
217
|
await control.importText(text);
|
|
210
|
-
const expected = exportOrFail(
|
|
218
|
+
const expected = exportOrFail(
|
|
219
|
+
control,
|
|
220
|
+
`after importing ${source} into a fresh instance`
|
|
221
|
+
);
|
|
211
222
|
await importOrFail(
|
|
212
223
|
m,
|
|
213
224
|
text,
|
|
214
225
|
`importText(${source}) rejected on an instance that ran exportText() and a user command before its first import (wedged)`
|
|
215
226
|
);
|
|
216
|
-
last = exportOrFail(
|
|
227
|
+
last = exportOrFail(
|
|
228
|
+
m,
|
|
229
|
+
`after importing ${source} into an instance used before its first import`
|
|
230
|
+
);
|
|
217
231
|
if (last !== expected) {
|
|
218
232
|
fail(
|
|
219
233
|
`importText(${source}) on an instance that ran exportText() and a user command before its first import did not render that document:
|
|
@@ -231,7 +245,9 @@ ${last}`
|
|
|
231
245
|
);
|
|
232
246
|
}
|
|
233
247
|
if (exportOrFail(m, "after a user mutation on an instance used before its first import") === last) {
|
|
234
|
-
fail(
|
|
248
|
+
fail(
|
|
249
|
+
"a user mutation did not apply on an instance used before its first import (wedged)"
|
|
250
|
+
);
|
|
235
251
|
}
|
|
236
252
|
} finally {
|
|
237
253
|
m.destroy();
|
|
@@ -250,7 +266,11 @@ ${last}`
|
|
|
250
266
|
...(h.foreignTexts ?? []).map((f) => f.text)
|
|
251
267
|
];
|
|
252
268
|
for (const text of texts) {
|
|
253
|
-
const { calls } = await changeEvents(
|
|
269
|
+
const { calls } = await changeEvents(
|
|
270
|
+
h,
|
|
271
|
+
m,
|
|
272
|
+
() => m.importText(text).then(() => void 0)
|
|
273
|
+
);
|
|
254
274
|
if (calls > 0) fail(`importText fired onContentChanged ${calls}x`);
|
|
255
275
|
}
|
|
256
276
|
} finally {
|
|
@@ -266,8 +286,13 @@ ${last}`
|
|
|
266
286
|
await m.importText(h.canonicalTexts[0] ?? "");
|
|
267
287
|
await h.mutate(m);
|
|
268
288
|
await (h.settle ?? defaultSettle)();
|
|
269
|
-
const { calls } = await changeEvents(
|
|
270
|
-
|
|
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`);
|
|
271
296
|
} finally {
|
|
272
297
|
m.destroy();
|
|
273
298
|
}
|
|
@@ -306,9 +331,15 @@ ${last}`
|
|
|
306
331
|
await (h.settle ?? defaultSettle)();
|
|
307
332
|
const opened = h.interactionState?.(m);
|
|
308
333
|
if (h.interactionState && opened === void 0) {
|
|
309
|
-
fail(
|
|
334
|
+
fail(
|
|
335
|
+
"interactionState reports no interaction after openInteraction \u2014 the case proves nothing"
|
|
336
|
+
);
|
|
310
337
|
}
|
|
311
|
-
const { calls } = await changeEvents(
|
|
338
|
+
const { calls } = await changeEvents(
|
|
339
|
+
h,
|
|
340
|
+
m,
|
|
341
|
+
() => m.importText(leg.text).then(() => void 0)
|
|
342
|
+
);
|
|
312
343
|
if (calls > 0) {
|
|
313
344
|
fail(
|
|
314
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)`
|
|
@@ -364,11 +395,13 @@ ${last}`
|
|
|
364
395
|
await m.importText(text);
|
|
365
396
|
const out = m.exportText();
|
|
366
397
|
if (out !== text)
|
|
367
|
-
fail(
|
|
398
|
+
fail(
|
|
399
|
+
`export after import differs from the canonical input:
|
|
368
400
|
--- in
|
|
369
401
|
${text}
|
|
370
402
|
--- out
|
|
371
|
-
${out}`
|
|
403
|
+
${out}`
|
|
404
|
+
);
|
|
372
405
|
if (m.exportText() !== out)
|
|
373
406
|
fail("exportText is not deterministic \u2014 two exports of one document differ");
|
|
374
407
|
}
|
|
@@ -436,7 +469,9 @@ ${out}`);
|
|
|
436
469
|
if (calls === 0) fail(`config mutation "${mutation.name}" fired no onContentChanged`);
|
|
437
470
|
const after = m.exportText();
|
|
438
471
|
if (after === before) {
|
|
439
|
-
fail(
|
|
472
|
+
fail(
|
|
473
|
+
`config mutation "${mutation.name}" did not change the export \u2014 the case proves nothing`
|
|
474
|
+
);
|
|
440
475
|
}
|
|
441
476
|
if (exportAtEmit !== after) {
|
|
442
477
|
fail(
|
|
@@ -509,7 +544,9 @@ ${out}`);
|
|
|
509
544
|
const first = h.canonicalTexts[0];
|
|
510
545
|
const second = h.canonicalTexts[1];
|
|
511
546
|
if (first === void 0 || second === void 0 || first === second) {
|
|
512
|
-
fail(
|
|
547
|
+
fail(
|
|
548
|
+
"the L4 case needs TWO DISTINCT canonicalTexts \u2014 with one, stale undo is undetectable"
|
|
549
|
+
);
|
|
513
550
|
}
|
|
514
551
|
const m = await h.create();
|
|
515
552
|
try {
|
|
@@ -536,7 +573,9 @@ ${out}`);
|
|
|
536
573
|
await h.undo(m);
|
|
537
574
|
await h.undo(m);
|
|
538
575
|
if (m.exportText() !== baseline) {
|
|
539
|
-
fail(
|
|
576
|
+
fail(
|
|
577
|
+
"undo after importText mutated the freshly imported document (stale history applied)"
|
|
578
|
+
);
|
|
540
579
|
}
|
|
541
580
|
} finally {
|
|
542
581
|
m.destroy();
|
|
@@ -579,7 +618,9 @@ ${out}`);
|
|
|
579
618
|
const applied = m.getViewState();
|
|
580
619
|
assertFinite(applied, "after setViewState");
|
|
581
620
|
if (same(applied, fitted)) {
|
|
582
|
-
fail(
|
|
621
|
+
fail(
|
|
622
|
+
"setViewState(viewState) had no effect \u2014 supply a state that differs from the fitted view"
|
|
623
|
+
);
|
|
583
624
|
}
|
|
584
625
|
await m.importText(text);
|
|
585
626
|
if (!same(m.getViewState(), fitted)) {
|
|
@@ -588,7 +629,8 @@ ${out}`);
|
|
|
588
629
|
);
|
|
589
630
|
}
|
|
590
631
|
const { calls } = await changeEvents(h, m, () => m.setViewState(applied));
|
|
591
|
-
if (calls > 0)
|
|
632
|
+
if (calls > 0)
|
|
633
|
+
fail("setViewState fired onContentChanged \u2014 view state is not document content");
|
|
592
634
|
if (m.exportText() !== doc)
|
|
593
635
|
fail("setViewState changed the export \u2014 view state leaked into the document");
|
|
594
636
|
if (!same(m.getViewState(), applied)) {
|
|
@@ -628,7 +670,9 @@ ${out}`);
|
|
|
628
670
|
await settle();
|
|
629
671
|
const opened2 = h.interactionState?.(m);
|
|
630
672
|
if (h.interactionState && opened2 === void 0) {
|
|
631
|
-
fail(
|
|
673
|
+
fail(
|
|
674
|
+
"interactionState reports no interaction after openInteraction \u2014 the case proves nothing"
|
|
675
|
+
);
|
|
632
676
|
}
|
|
633
677
|
return opened2;
|
|
634
678
|
};
|
|
@@ -643,7 +687,11 @@ ${out}`);
|
|
|
643
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)"
|
|
644
688
|
);
|
|
645
689
|
}
|
|
646
|
-
await importOrFail(
|
|
690
|
+
await importOrFail(
|
|
691
|
+
m,
|
|
692
|
+
first,
|
|
693
|
+
"re-importing canonicalTexts[0] after the commit rejected"
|
|
694
|
+
);
|
|
647
695
|
}
|
|
648
696
|
const opened = await open();
|
|
649
697
|
const view = m.getViewState();
|
|
@@ -717,7 +765,8 @@ ${out}`;
|
|
|
717
765
|
const { calls } = await changeEvents(h, m, () => {
|
|
718
766
|
if (elements.reveal(firstId) !== true) fail("reveal(known id) must return true");
|
|
719
767
|
});
|
|
720
|
-
if (calls > 0)
|
|
768
|
+
if (calls > 0)
|
|
769
|
+
fail("reveal fired onContentChanged \u2014 revealing is view behavior, not an edit");
|
|
721
770
|
if (!deliveries.some((d) => d.includes(firstId))) {
|
|
722
771
|
fail(
|
|
723
772
|
`reveal('${firstId}') delivered no selection containing the id \u2014 is it a selectable element (not a root or a label)?`
|
|
@@ -732,7 +781,8 @@ ${out}`;
|
|
|
732
781
|
}
|
|
733
782
|
}
|
|
734
783
|
}
|
|
735
|
-
if (!elements.selection().includes(firstId))
|
|
784
|
+
if (!elements.selection().includes(firstId))
|
|
785
|
+
fail("selection() does not contain the revealed id");
|
|
736
786
|
const seen = deliveries.length;
|
|
737
787
|
off();
|
|
738
788
|
const otherId = ids[1];
|
|
@@ -906,7 +956,9 @@ ${out}`;
|
|
|
906
956
|
await settle();
|
|
907
957
|
const was = before.get(movedId);
|
|
908
958
|
if (was === void 0)
|
|
909
|
-
fail(
|
|
959
|
+
fail(
|
|
960
|
+
`moveElement returned '${movedId}', an id elements.list() did not report before the move`
|
|
961
|
+
);
|
|
910
962
|
const box = boxesOf(elements, "after a move").get(movedId);
|
|
911
963
|
if (box === void 0)
|
|
912
964
|
fail(`'${movedId}' is gone from elements.list() after moveElement moved it`);
|
|
@@ -916,7 +968,9 @@ ${out}`;
|
|
|
916
968
|
);
|
|
917
969
|
}
|
|
918
970
|
} else {
|
|
919
|
-
unverified.push(
|
|
971
|
+
unverified.push(
|
|
972
|
+
"moveElement is undefined \u2014 that a box follows a move was not verified"
|
|
973
|
+
);
|
|
920
974
|
}
|
|
921
975
|
if (h.viewState !== void 0) {
|
|
922
976
|
const atRest = boxesOf(elements, "before a view change");
|
|
@@ -963,7 +1017,11 @@ ${out}`;
|
|
|
963
1017
|
await importOrFail(m, text, `importText(canonicalTexts[${index}]) rejected`);
|
|
964
1018
|
expectContentAroundElements(m, `after importing canonicalTexts[${index}]`);
|
|
965
1019
|
}
|
|
966
|
-
await importOrFail(
|
|
1020
|
+
await importOrFail(
|
|
1021
|
+
m,
|
|
1022
|
+
h.canonicalTexts[0] ?? "",
|
|
1023
|
+
"importText(canonicalTexts[0]) rejected"
|
|
1024
|
+
);
|
|
967
1025
|
await h.mutate(m);
|
|
968
1026
|
await settle();
|
|
969
1027
|
const atRest = expectContentAroundElements(m, "after a user mutation added an element");
|
|
@@ -990,7 +1048,11 @@ ${out}`;
|
|
|
990
1048
|
viewer = await h.createReadonly();
|
|
991
1049
|
if (hasContentBounds(viewer)) {
|
|
992
1050
|
contentBoxOf(viewer, "on the viewer before the first import");
|
|
993
|
-
await importOrFail(
|
|
1051
|
+
await importOrFail(
|
|
1052
|
+
viewer,
|
|
1053
|
+
h.canonicalTexts[0] ?? "",
|
|
1054
|
+
"the viewer rejected canonicalTexts[0]"
|
|
1055
|
+
);
|
|
994
1056
|
expectContentAroundElements(viewer, "on the viewer after an import");
|
|
995
1057
|
}
|
|
996
1058
|
}
|
|
@@ -1039,7 +1101,9 @@ ${out}`;
|
|
|
1039
1101
|
name: "L7: a rejected import leaves no trace",
|
|
1040
1102
|
async run(h) {
|
|
1041
1103
|
if (h.invalidTexts === void 0) {
|
|
1042
|
-
return skip(
|
|
1104
|
+
return skip(
|
|
1105
|
+
"invalidTexts is undefined \u2014 pass [] if the notation has no non-importable text"
|
|
1106
|
+
);
|
|
1043
1107
|
}
|
|
1044
1108
|
if (h.invalidTexts.length === 0) return;
|
|
1045
1109
|
const m = await h.create();
|
|
@@ -1092,7 +1156,9 @@ ${out}`;
|
|
|
1092
1156
|
name: "L7: the modeler stays usable after a rejected import",
|
|
1093
1157
|
async run(h) {
|
|
1094
1158
|
if (h.invalidTexts === void 0) {
|
|
1095
|
-
return skip(
|
|
1159
|
+
return skip(
|
|
1160
|
+
"invalidTexts is undefined \u2014 pass [] if the notation has no non-importable text"
|
|
1161
|
+
);
|
|
1096
1162
|
}
|
|
1097
1163
|
const bad = h.invalidTexts[0];
|
|
1098
1164
|
if (bad === void 0) return;
|
|
@@ -1104,12 +1170,15 @@ ${out}`;
|
|
|
1104
1170
|
() => void 0
|
|
1105
1171
|
);
|
|
1106
1172
|
const next = secondText(h);
|
|
1107
|
-
await m.importText(next).catch(
|
|
1173
|
+
await m.importText(next).catch(
|
|
1174
|
+
(e) => fail(`the next VALID import after a rejection did not render: ${String(e)}`)
|
|
1175
|
+
);
|
|
1108
1176
|
if (m.exportText() !== next)
|
|
1109
1177
|
fail("the next valid import after a rejection did not replace the document");
|
|
1110
1178
|
const before = m.exportText();
|
|
1111
1179
|
const { calls } = await changeEvents(h, m, () => h.mutate(m));
|
|
1112
|
-
if (calls === 0)
|
|
1180
|
+
if (calls === 0)
|
|
1181
|
+
fail("a user mutation after a rejected import fired no onContentChanged (wedged)");
|
|
1113
1182
|
if (exportOrFail(m, "after a mutation that followed a rejected import") === before) {
|
|
1114
1183
|
fail("a user mutation after a rejected import did not apply (wedged)");
|
|
1115
1184
|
}
|
|
@@ -1171,7 +1240,9 @@ ${out}`;
|
|
|
1171
1240
|
if (rejected) {
|
|
1172
1241
|
if (calls > 0) fail(`a rejected ${what} fired onContentChanged ${calls}x (L7)`);
|
|
1173
1242
|
if (exportOrFail(m, `after a rejected ${what}`) !== doc) {
|
|
1174
|
-
fail(
|
|
1243
|
+
fail(
|
|
1244
|
+
`a rejected ${what} changed the document \u2014 the previous state must stay intact (L7)`
|
|
1245
|
+
);
|
|
1175
1246
|
}
|
|
1176
1247
|
if (ids && !same(ids, sortedIds(m))) {
|
|
1177
1248
|
fail(
|
|
@@ -1188,7 +1259,9 @@ ${out}`;
|
|
|
1188
1259
|
}
|
|
1189
1260
|
} else {
|
|
1190
1261
|
if (calls > 0)
|
|
1191
|
-
fail(
|
|
1262
|
+
fail(
|
|
1263
|
+
`${what} resolved and fired onContentChanged ${calls}x \u2014 an import is silent (L1)`
|
|
1264
|
+
);
|
|
1192
1265
|
exportOrFail(m, `after ${what} resolved (L6)`);
|
|
1193
1266
|
}
|
|
1194
1267
|
previous = probe;
|
|
@@ -1203,7 +1276,8 @@ ${out}`;
|
|
|
1203
1276
|
{
|
|
1204
1277
|
name: "viewer (when shipped): read-only, silent, canonical, fits, reveals, keeps foreign content, unshaken by a rejected import",
|
|
1205
1278
|
async run(h) {
|
|
1206
|
-
if (!h.createReadonly)
|
|
1279
|
+
if (!h.createReadonly)
|
|
1280
|
+
return skip("createReadonly is undefined \u2014 the package ships no viewer mount");
|
|
1207
1281
|
const v = await h.createReadonly();
|
|
1208
1282
|
try {
|
|
1209
1283
|
if (v.meta.editable !== false)
|
|
@@ -1279,6 +1353,10 @@ ${out}`;
|
|
|
1279
1353
|
}
|
|
1280
1354
|
}
|
|
1281
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
|
+
},
|
|
1282
1360
|
{
|
|
1283
1361
|
name: "destroy is safe to call twice",
|
|
1284
1362
|
async run(h) {
|
|
@@ -1291,7 +1369,8 @@ ${out}`;
|
|
|
1291
1369
|
}
|
|
1292
1370
|
async function foreignRoundTrips(h, m, who) {
|
|
1293
1371
|
for (const { text, mustSurvive } of h.foreignTexts ?? []) {
|
|
1294
|
-
if (mustSurvive.length === 0)
|
|
1372
|
+
if (mustSurvive.length === 0)
|
|
1373
|
+
fail("a foreignTexts entry must name at least one fragment in mustSurvive");
|
|
1295
1374
|
await m.importText(text);
|
|
1296
1375
|
const first = exportOrFail(m, `${who}after importing a document with foreign content`);
|
|
1297
1376
|
for (const fragment of mustSurvive) {
|
|
@@ -1300,8 +1379,397 @@ async function foreignRoundTrips(h, m, who) {
|
|
|
1300
1379
|
}
|
|
1301
1380
|
await m.importText(first);
|
|
1302
1381
|
const second = exportOrFail(m, `${who}after re-importing its own export of foreign content`);
|
|
1303
|
-
if (second !== first)
|
|
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]);
|
|
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 };
|
|
1304
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
|
+
};
|
|
1305
1773
|
}
|
|
1306
1774
|
function registerConformance(harness, test, options = {}) {
|
|
1307
1775
|
const onSkip = options.onSkip ?? ((name, reason) => {
|
|
@@ -1313,8 +1781,8 @@ function registerConformance(harness, test, options = {}) {
|
|
|
1313
1781
|
for (const conformanceCase of conformanceCases()) {
|
|
1314
1782
|
test(`conformance: ${conformanceCase.name}`, async () => {
|
|
1315
1783
|
const outcome = await conformanceCase.run(harness);
|
|
1316
|
-
if (outcome?.skipped) onSkip(conformanceCase.name, outcome.skipped);
|
|
1317
1784
|
for (const advice of outcome?.advisories ?? []) onAdvisory(conformanceCase.name, advice);
|
|
1785
|
+
if (outcome?.skipped) onSkip(conformanceCase.name, outcome.skipped);
|
|
1318
1786
|
});
|
|
1319
1787
|
}
|
|
1320
1788
|
}
|