vitest-prosemirror 0.4.0 → 0.5.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,37 +1,65 @@
1
1
  import 'vitest';
2
+ import { Command } from 'prosemirror-state';
3
+ import { DirectEditorProps } from 'prosemirror-view';
2
4
  import { EditorState } from 'prosemirror-state';
3
5
  import { Node as Node_2 } from 'prosemirror-model';
4
- import { Plugin as Plugin_2 } from 'prosemirror-state';
6
+ import { ParseOptions } from 'prosemirror-model';
7
+ import { Schema } from 'prosemirror-model';
5
8
  import { Selection as Selection_2 } from 'prosemirror-state';
6
9
 
10
+ declare interface Clipboard_2 {
11
+ html: string;
12
+ text: string;
13
+ }
14
+ export { Clipboard_2 as Clipboard }
15
+
7
16
  export declare interface CustomMatchers<R = unknown> {
8
17
  toEqualProseMirrorNode(expected: Node_2): R;
18
+ toHaveSelection(expected: TesterSelection): R;
9
19
  }
10
20
 
11
- export declare interface Options {
21
+ export declare interface Options extends Omit<DirectEditorProps, "dispatchTransaction" | "state"> {
12
22
  autoCleanup: boolean;
13
- plugins: Array<Plugin_2>;
14
23
  }
15
24
 
25
+ export declare function parseHTML(html: string, schema: Schema, options?: ParseOptions): Node_2;
26
+
27
+ export { ParseOptions }
28
+
29
+ export declare interface PasteContent {
30
+ files?: Array<File>;
31
+ html?: string;
32
+ plainText?: boolean;
33
+ text?: string;
34
+ }
35
+
36
+ export declare type PasteInput = PasteContent | Node_2 | string;
37
+
16
38
  export declare class ProseMirrorTester {
17
39
  get doc(): Node_2;
18
40
  get html(): string;
19
41
  get state(): EditorState;
20
42
  get text(): string;
21
43
  private destroyed;
22
- private readonly element;
44
+ private readonly mountPoint;
23
45
  private readonly view;
24
46
  constructor(documentRoot: Node_2, options?: Partial<Options>);
47
+ click(target: Element | string): boolean;
48
+ command(command: Command): boolean;
49
+ copy(): Clipboard_2;
25
50
  destroy(): void;
51
+ element(selector: string): HTMLElement;
52
+ elements(selector: string): Array<HTMLElement>;
26
53
  insertText(text: string): void;
54
+ paste(content: PasteInput): void;
27
55
  selectText(selection: TesterSelection): void;
28
56
  private assertAlive;
29
57
  }
30
58
 
31
59
  export declare type TesterSelection = "all" | "end" | "start" | {
32
- anchor: number;
33
- head: number;
34
- } | Selection_2 | number;
60
+ anchor: number | string;
61
+ head: number | string;
62
+ } | (string & {}) | Selection_2 | number;
35
63
 
36
64
  export { }
37
65
 
@@ -1,175 +1,33 @@
1
1
  import { afterEach, expect } from "vitest";
2
2
  import { AllSelection, EditorState, Selection, TextSelection } from "prosemirror-state";
3
3
  import { EditorView } from "prosemirror-view";
4
+ import { DOMParser, Fragment, Node, Slice } from "prosemirror-model";
4
5
  import stringifyObject from "stringify-object";
5
- //#region src/mockRangeRects.ts
6
- var mockRangeRects = () => {
7
- const zeroRect = () => new DOMRect(0, 0, 0, 0);
8
- const emptyRectList = () => [];
9
- const rangePrototype = Range.prototype;
10
- if (typeof rangePrototype.getBoundingClientRect !== "function") Range.prototype.getBoundingClientRect = zeroRect;
11
- if (typeof rangePrototype.getClientRects !== "function") Range.prototype.getClientRects = emptyRectList;
12
- };
13
- //#endregion
14
- //#region src/MutationObserverMock.ts
15
- var MutationObserverMock = class MutationObserverMock {
16
- static activeObservers = /* @__PURE__ */ new Map();
17
- callback;
18
- target;
19
- constructor(callback) {
20
- this.callback = callback;
21
- this.target = void 0;
22
- }
23
- static createMutation(target, mutationRecords) {
24
- const observer = MutationObserverMock.activeObservers.get(target);
25
- if (observer === void 0) return;
26
- observer.callback(mutationRecords.map((record) => ({
27
- addedNodes: [],
28
- attributeName: null,
29
- attributeNamespace: null,
30
- nextSibling: null,
31
- oldValue: null,
32
- previousSibling: null,
33
- removedNodes: [],
34
- ...record
35
- })), observer);
36
- }
37
- disconnect() {
38
- if (this.target !== void 0) MutationObserverMock.activeObservers.delete(this.target);
39
- this.target = void 0;
40
- }
41
- observe(target) {
42
- this.target = target;
43
- MutationObserverMock.activeObservers.set(target, this);
44
- }
45
- takeRecords() {
46
- return [];
47
- }
6
+ //#region src/isProseMirrorNode.ts
7
+ var isProseMirrorNode = (val) => {
8
+ if (typeof val !== "object" || val === null) return false;
9
+ const node = val;
10
+ const type = node["type"];
11
+ if (typeof type !== "object" || type === null) return false;
12
+ const typeRecord = type;
13
+ return typeof typeRecord["name"] === "string" && typeof typeRecord["schema"] === "object" && "marks" in node && "attrs" in node && "content" in node;
48
14
  };
49
15
  //#endregion
50
- //#region src/selection.ts
51
- var resolveSelection = (doc, selection) => {
52
- if (selection === "all") return new AllSelection(doc);
53
- if (typeof selection === "object" && "$anchor" in selection && "$head" in selection) return selection;
54
- if (typeof selection === "object" && "anchor" in selection && "head" in selection) return TextSelection.between(doc.resolve(selection.anchor), doc.resolve(selection.head));
55
- if (typeof selection === "object" && "from" in selection && "to" in selection) throw new Error("selectText no longer accepts { from, to } — use { anchor, head } instead. The values map directly: { from: a, to: b } becomes { anchor: a, head: b }.");
56
- let pos = 0;
57
- if (selection === "end") pos = doc.nodeSize - 2;
58
- else if (selection !== "start") pos = selection;
59
- return TextSelection.near(doc.resolve(pos));
16
+ //#region src/isEditorState.ts
17
+ var isEditorState = (val) => {
18
+ if (typeof val !== "object" || val === null) return false;
19
+ const state = val;
20
+ return isProseMirrorNode(state["doc"]) && typeof state["selection"] === "object" && state["selection"] !== null && "storedMarks" in state;
60
21
  };
61
22
  //#endregion
62
- //#region src/typing/dom.ts
63
- var characterDataAt = (node, offset) => {
64
- if (node instanceof CharacterData) return {
65
- offset,
66
- target: node
67
- };
68
- const after = node.childNodes[offset];
69
- if (after instanceof CharacterData) return {
70
- offset: 0,
71
- target: after
72
- };
73
- return null;
74
- };
75
- function deleteText(view, direction) {
76
- const { selection } = view.state;
77
- const from = selection.empty && direction === -1 ? selection.from - 1 : selection.from;
78
- const to = selection.empty && direction === 1 ? selection.to + 1 : selection.to;
79
- const fromDOM = view.domAtPos(from, -1);
80
- const target = characterDataAt(fromDOM.node, fromDOM.offset)?.target;
81
- if (target === void 0) throw new Error("Cannot simulate deleting a range that is not inside a single text node");
82
- const nodeStart = view.posAtDOM(target, 0);
83
- const fromOffset = from - nodeStart;
84
- const toOffset = to - nodeStart;
85
- if (fromOffset < 0 || toOffset > target.data.length) throw new Error("Cannot simulate deleting a range that is not inside a single text node");
86
- const oldValue = target.data;
87
- target.data = target.data.slice(0, fromOffset) + target.data.slice(toOffset);
88
- MutationObserverMock.createMutation(view.dom, [{
89
- oldValue,
90
- target,
91
- type: "characterData"
92
- }]);
93
- }
94
- function moveCaret(view, direction) {
95
- const { selection } = view.state;
96
- let pos = direction === -1 ? selection.from : selection.to;
97
- if (selection.empty) pos += direction;
98
- view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(pos), direction)));
99
- }
100
- function typeCharacter(view, character) {
101
- const { node, offset: domOffset } = view.domAtPos(view.state.selection.from, -1);
102
- const point = characterDataAt(node, domOffset);
103
- if (point !== null) {
104
- const { offset, target } = point;
105
- const oldValue = target.data;
106
- target.data = target.data.slice(0, offset) + character + target.data.slice(offset);
107
- MutationObserverMock.createMutation(view.dom, [{
108
- oldValue,
109
- target,
110
- type: "characterData"
111
- }]);
112
- return;
113
- }
114
- const textNode = new Text(character);
115
- const nextSibling = node.childNodes.item(domOffset);
116
- node.insertBefore(textNode, nextSibling);
117
- MutationObserverMock.createMutation(view.dom, [{
118
- addedNodes: [textNode],
119
- nextSibling,
120
- previousSibling: textNode.previousSibling,
121
- target: node,
122
- type: "childList"
123
- }]);
124
- }
125
- //#endregion
126
- //#region src/typing/keyboardInput.ts
127
- function tokenizeKeyboardInput(input) {
128
- const output = [];
129
- let currentGroupOpener = null;
130
- let group = "";
131
- for (const char of input) if (currentGroupOpener !== null) {
132
- if (["]", "}"].includes(char)) {
133
- if (group.endsWith("\\") && char === matchingBrace(currentGroupOpener)) group = group.slice(0, -2) + char;
134
- else if (char === matchingBrace(currentGroupOpener)) {
135
- if (group.length === 4 && group.startsWith("Key")) output.push(group.slice(3).toLowerCase());
136
- else output.push(group);
137
- currentGroupOpener = null;
138
- group = "";
139
- } else group += char;
140
- } else if (group === "" && currentGroupOpener === char) {
141
- output.push(char);
142
- currentGroupOpener = null;
143
- group = "";
144
- } else group += char;
145
- } else if (["[", "{"].includes(char)) currentGroupOpener = char;
146
- else output.push(char);
147
- output.forEach(assertSupported);
148
- return output;
149
- }
150
- function assertSupported(character) {
151
- if (/^\/.+/u.exec(character) || /.+>[\d]*\/?$/u.exec(character)) throw new Error("Unsupported keyboard input");
152
- }
153
- function matchingBrace(opener) {
154
- return opener === "{" ? "}" : "]";
155
- }
156
- //#endregion
157
- //#region src/typing/keyChord.ts
158
- function parseKeyChord(token) {
159
- const parts = token.split(/-(?!$)/u);
160
- const key = parts[parts.length - 1] === "Space" ? " " : parts[parts.length - 1];
161
- const modifiers = {};
162
- for (const modifier of parts.slice(0, -1)) if (/^(cmd|meta|m)$/iu.test(modifier)) modifiers.metaKey = true;
163
- else if (/^a(lt)?$/iu.test(modifier)) modifiers.altKey = true;
164
- else if (/^(c|ctrl|control)$/iu.test(modifier)) modifiers.ctrlKey = true;
165
- else if (/^s(hift)?$/iu.test(modifier)) modifiers.shiftKey = true;
166
- else if (/^mod$/iu.test(modifier)) modifiers.ctrlKey = true;
167
- else throw new Error(`Unrecognized modifier name: ${modifier}`);
23
+ //#region src/clipboard/copy.ts
24
+ var copy = (view) => {
25
+ const { dom, text } = view.serializeForClipboard(view.state.selection.content());
168
26
  return {
169
- key,
170
- modifiers
27
+ html: dom.innerHTML,
28
+ text
171
29
  };
172
- }
30
+ };
173
31
  //#endregion
174
32
  //#region src/typing/keyIdentity.ts
175
33
  var namedKeys = new Map(Object.entries({
@@ -488,6 +346,351 @@ function keyIdentity(key) {
488
346
  };
489
347
  }
490
348
  //#endregion
349
+ //#region src/clipboard/ClipboardEventMock.ts
350
+ var ClipboardEventMock = class extends Event {
351
+ clipboardData;
352
+ constructor(type, clipboardData) {
353
+ super(type, {
354
+ bubbles: true,
355
+ cancelable: true,
356
+ composed: true
357
+ });
358
+ this.clipboardData = clipboardData;
359
+ }
360
+ };
361
+ //#endregion
362
+ //#region src/clipboard/DataTransferItemMock.ts
363
+ var DataTransferItemMock = class {
364
+ kind = "file";
365
+ type;
366
+ file;
367
+ constructor(file) {
368
+ this.file = file;
369
+ this.type = file.type;
370
+ }
371
+ getAsFile() {
372
+ return this.file;
373
+ }
374
+ getAsString() {}
375
+ webkitGetAsEntry() {
376
+ return null;
377
+ }
378
+ };
379
+ //#endregion
380
+ //#region src/clipboard/DataTransferMock.ts
381
+ var normalizeType = (format) => format.toLowerCase() === "text" ? "text/plain" : format.toLowerCase();
382
+ var DataTransferMock = class {
383
+ dropEffect = "none";
384
+ effectAllowed = "uninitialized";
385
+ files;
386
+ items;
387
+ get types() {
388
+ return this.files.length > 0 ? [...this.store.keys(), "Files"] : [...this.store.keys()];
389
+ }
390
+ store = /* @__PURE__ */ new Map();
391
+ constructor(files = []) {
392
+ this.files = Object.assign([...files], { item: (index) => files[index] ?? null });
393
+ this.items = Object.assign(files.map((file) => new DataTransferItemMock(file)), {
394
+ add: () => null,
395
+ clear: () => void 0,
396
+ remove: () => {
397
+ throw new DOMException("The DataTransferItemList is not in the read/write mode.", "InvalidStateError");
398
+ }
399
+ });
400
+ }
401
+ clearData(format) {
402
+ if (format === void 0) this.store.clear();
403
+ else this.store.delete(normalizeType(format));
404
+ }
405
+ getData(format) {
406
+ return this.store.get(normalizeType(format)) ?? "";
407
+ }
408
+ setData(format, data) {
409
+ this.store.set(normalizeType(format), data);
410
+ }
411
+ setDragImage() {
412
+ throw new Error("setDragImage is not supported: ProseMirrorTester models clipboard paste/copy, not drag-and-drop.");
413
+ }
414
+ };
415
+ //#endregion
416
+ //#region src/clipboard/paste.ts
417
+ var dispatchShift = (view, type) => {
418
+ const identity = keyIdentity("Shift");
419
+ view.dispatchEvent(new KeyboardEvent(type, {
420
+ bubbles: true,
421
+ cancelable: true,
422
+ code: identity.code,
423
+ composed: true,
424
+ key: identity.key,
425
+ keyCode: identity.keyCode,
426
+ location: identity.location,
427
+ shiftKey: true
428
+ }));
429
+ };
430
+ var normalize = (view, content) => {
431
+ if (typeof content === "string") return { text: content };
432
+ if (content instanceof Node) {
433
+ const { dom, text } = view.serializeForClipboard(new Slice(Fragment.from(content), 0, 0));
434
+ return {
435
+ html: dom.innerHTML,
436
+ text
437
+ };
438
+ }
439
+ return content;
440
+ };
441
+ var paste = (view, content) => {
442
+ const { files, html, plainText, text } = normalize(view, content);
443
+ const data = new DataTransferMock(files);
444
+ if (text !== void 0) data.setData("text/plain", text);
445
+ if (html !== void 0) data.setData("text/html", html);
446
+ if (plainText === true) dispatchShift(view, "keydown");
447
+ view.dom.dispatchEvent(new ClipboardEventMock("paste", data));
448
+ if (plainText === true) dispatchShift(view, "keyup");
449
+ };
450
+ //#endregion
451
+ //#region src/domInteraction.ts
452
+ var clickHandlerProps = [
453
+ "handleClick",
454
+ "handleClickOn",
455
+ "handleDoubleClick",
456
+ "handleDoubleClickOn",
457
+ "handleTripleClick",
458
+ "handleTripleClickOn"
459
+ ];
460
+ var element = (view, selector) => {
461
+ const found = view.dom.querySelector(selector);
462
+ if (found === null) throw new Error(`No element matching "${selector}" was found in the editor DOM.`);
463
+ return found;
464
+ };
465
+ var elements = (view, selector) => [...view.dom.querySelectorAll(selector)];
466
+ var click = (view, target) => {
467
+ if (clickHandlerProps.some((prop) => view.someProp(prop, () => true) === true)) throw new Error("click() cannot reach handleClick / handleClickOn / handleDoubleClick* / handleTripleClick* handlers: jsdom has no layout, so posAtCoords is always null and ProseMirror's mousedown handler bails before calling them. Click a node view's own DOM element instead.");
468
+ const target_ = typeof target === "string" ? element(view, target) : target;
469
+ const event = new MouseEvent("click", {
470
+ bubbles: true,
471
+ cancelable: true
472
+ });
473
+ target_.dispatchEvent(event);
474
+ return event.defaultPrevented;
475
+ };
476
+ //#endregion
477
+ //#region src/mockRangeRects.ts
478
+ var mockRangeRects = () => {
479
+ const zeroRect = () => new DOMRect(0, 0, 0, 0);
480
+ const emptyRectList = () => [];
481
+ const rangePrototype = Range.prototype;
482
+ if (typeof rangePrototype.getBoundingClientRect !== "function") Range.prototype.getBoundingClientRect = zeroRect;
483
+ if (typeof rangePrototype.getClientRects !== "function") Range.prototype.getClientRects = emptyRectList;
484
+ };
485
+ //#endregion
486
+ //#region src/MutationObserverMock.ts
487
+ var MutationObserverMock = class MutationObserverMock {
488
+ static activeObservers = /* @__PURE__ */ new Map();
489
+ callback;
490
+ target;
491
+ constructor(callback) {
492
+ this.callback = callback;
493
+ this.target = void 0;
494
+ }
495
+ static createMutation(target, mutationRecords) {
496
+ const observer = MutationObserverMock.activeObservers.get(target);
497
+ if (observer === void 0) return;
498
+ observer.callback(mutationRecords.map((record) => ({
499
+ addedNodes: [],
500
+ attributeName: null,
501
+ attributeNamespace: null,
502
+ nextSibling: null,
503
+ oldValue: null,
504
+ previousSibling: null,
505
+ removedNodes: [],
506
+ ...record
507
+ })), observer);
508
+ }
509
+ disconnect() {
510
+ if (this.target !== void 0) MutationObserverMock.activeObservers.delete(this.target);
511
+ this.target = void 0;
512
+ }
513
+ observe(target) {
514
+ this.target = target;
515
+ MutationObserverMock.activeObservers.set(target, this);
516
+ }
517
+ takeRecords() {
518
+ return [];
519
+ }
520
+ };
521
+ //#endregion
522
+ //#region src/selection.ts
523
+ var tagsOf = (doc) => doc.tag ?? {};
524
+ var resolvePos = (doc, value) => {
525
+ if (typeof value === "number") return value;
526
+ const tags = tagsOf(doc);
527
+ if (value in tags) return tags[value];
528
+ const available = Object.keys(tags);
529
+ throw new Error(`selectText: no tag named "${value}" in the document. ${available.length > 0 ? `Available tags: ${available.map((tag) => `"${tag}"`).join(", ")}.` : "The document has no tags."}`);
530
+ };
531
+ var resolveSelection = (doc, selection) => {
532
+ if (typeof selection === "object" && "$anchor" in selection && "$head" in selection) return selection;
533
+ if (typeof selection === "object" && "anchor" in selection && "head" in selection) return TextSelection.between(doc.resolve(resolvePos(doc, selection.anchor)), doc.resolve(resolvePos(doc, selection.head)));
534
+ if (typeof selection === "object" && "from" in selection && "to" in selection) throw new Error("selectText no longer accepts { from, to } — use { anchor, head } instead. The values map directly: { from: a, to: b } becomes { anchor: a, head: b }.");
535
+ if (typeof selection === "number") return TextSelection.near(doc.resolve(selection));
536
+ const tags = tagsOf(doc);
537
+ if ([
538
+ "all",
539
+ "end",
540
+ "start"
541
+ ].includes(selection)) {
542
+ if (selection in tags) throw new Error(`selectText: "${selection}" is both a reserved selection and a tag in the document. Use { anchor, head } to select the tag, or rename the tag.`);
543
+ if (selection === "all") return new AllSelection(doc);
544
+ return TextSelection.near(doc.resolve(selection === "end" ? doc.nodeSize - 2 : 0));
545
+ }
546
+ return TextSelection.near(doc.resolve(resolvePos(doc, selection)));
547
+ };
548
+ //#endregion
549
+ //#region src/typing/direction.ts
550
+ var rtl = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}]/u;
551
+ function containsRTL(text) {
552
+ return rtl.test(text);
553
+ }
554
+ //#endregion
555
+ //#region src/typing/dom.ts
556
+ var characterDataAt = (node, offset) => {
557
+ if (node instanceof CharacterData) return {
558
+ offset,
559
+ target: node
560
+ };
561
+ const after = node.childNodes[offset];
562
+ if (after instanceof CharacterData) return {
563
+ offset: 0,
564
+ target: after
565
+ };
566
+ return null;
567
+ };
568
+ //#endregion
569
+ //#region src/typing/grapheme.ts
570
+ var segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
571
+ var graphemeBoundary = (text, offset, direction) => {
572
+ const segment = segmenter.segment(text).containing(direction > 0 ? offset : offset - 1);
573
+ if (segment === void 0) return null;
574
+ return direction > 0 ? segment.index + segment.segment.length : segment.index;
575
+ };
576
+ function deleteText(view, direction) {
577
+ const { selection } = view.state;
578
+ const from = selection.empty && direction === -1 ? selection.from - 1 : selection.from;
579
+ const to = selection.empty && direction === 1 ? selection.to + 1 : selection.to;
580
+ const fromDOM = view.domAtPos(from, -1);
581
+ const target = characterDataAt(fromDOM.node, fromDOM.offset)?.target;
582
+ if (target === void 0) throw new Error("Cannot simulate deleting a range that is not inside a single text node");
583
+ const nodeStart = view.posAtDOM(target, 0);
584
+ const fromOffset = from - nodeStart;
585
+ const toOffset = to - nodeStart;
586
+ if (fromOffset < 0 || toOffset > target.data.length) throw new Error("Cannot simulate deleting a range that is not inside a single text node");
587
+ const oldValue = target.data;
588
+ target.data = target.data.slice(0, fromOffset) + target.data.slice(toOffset);
589
+ MutationObserverMock.createMutation(view.dom, [{
590
+ oldValue,
591
+ target,
592
+ type: "characterData"
593
+ }]);
594
+ }
595
+ function moveCaret(view, direction, extend) {
596
+ const { doc, selection } = view.state;
597
+ if (extend) {
598
+ const head = graphemeStep(view, selection.head, direction);
599
+ view.dispatch(view.state.tr.setSelection(TextSelection.between(doc.resolve(selection.anchor), doc.resolve(head), direction)));
600
+ return;
601
+ }
602
+ let pos = direction === -1 ? selection.from : selection.to;
603
+ if (selection.empty) pos = graphemeStep(view, pos, direction);
604
+ else if (containsRTL(doc.textBetween(selection.from, selection.to))) throwRTL(direction);
605
+ view.dispatch(view.state.tr.setSelection(Selection.near(doc.resolve(pos), direction)));
606
+ }
607
+ function typeCharacter(view, character) {
608
+ if (!view.state.selection.empty) deleteText(view, -1);
609
+ const { node, offset: domOffset } = view.domAtPos(view.state.selection.from, -1);
610
+ const point = characterDataAt(node, domOffset);
611
+ if (point !== null) {
612
+ const { offset, target } = point;
613
+ const oldValue = target.data;
614
+ target.data = target.data.slice(0, offset) + character + target.data.slice(offset);
615
+ MutationObserverMock.createMutation(view.dom, [{
616
+ oldValue,
617
+ target,
618
+ type: "characterData"
619
+ }]);
620
+ return;
621
+ }
622
+ const textNode = new Text(character);
623
+ const nextSibling = node.childNodes.item(domOffset);
624
+ node.insertBefore(textNode, nextSibling);
625
+ MutationObserverMock.createMutation(view.dom, [{
626
+ addedNodes: [textNode],
627
+ nextSibling,
628
+ previousSibling: textNode.previousSibling,
629
+ target: node,
630
+ type: "childList"
631
+ }]);
632
+ }
633
+ function graphemeStep(view, pos, direction) {
634
+ const dom = view.domAtPos(pos, direction);
635
+ const point = characterDataAt(dom.node, dom.offset);
636
+ if (point === null) return pos + direction;
637
+ if (containsRTL(point.target.data)) throwRTL(direction);
638
+ const nodeStart = view.posAtDOM(point.target, 0);
639
+ const boundary = graphemeBoundary(point.target.data, pos - nodeStart, direction);
640
+ return boundary === null ? pos + direction : nodeStart + boundary;
641
+ }
642
+ function throwRTL(direction) {
643
+ throw new Error(`Cannot simulate the "${direction === -1 ? "ArrowLeft" : "ArrowRight"}" key in right-to-left text.`);
644
+ }
645
+ //#endregion
646
+ //#region src/typing/keyboardInput.ts
647
+ function tokenizeKeyboardInput(input) {
648
+ const output = [];
649
+ let currentGroupOpener = null;
650
+ let group = "";
651
+ for (const char of input) if (currentGroupOpener !== null) {
652
+ if (["]", "}"].includes(char)) {
653
+ if (group.endsWith("\\") && char === matchingBrace(currentGroupOpener)) group = group.slice(0, -2) + char;
654
+ else if (char === matchingBrace(currentGroupOpener)) {
655
+ if (group.length === 4 && group.startsWith("Key")) output.push(group.slice(3).toLowerCase());
656
+ else output.push(group);
657
+ currentGroupOpener = null;
658
+ group = "";
659
+ } else group += char;
660
+ } else if (group === "" && currentGroupOpener === char) {
661
+ output.push(char);
662
+ currentGroupOpener = null;
663
+ group = "";
664
+ } else group += char;
665
+ } else if (["[", "{"].includes(char)) currentGroupOpener = char;
666
+ else output.push(char);
667
+ output.forEach(assertSupported);
668
+ return output;
669
+ }
670
+ function assertSupported(character) {
671
+ if (/^\/.+/u.exec(character) || /.+>[\d]*\/?$/u.exec(character)) throw new Error("Unsupported keyboard input");
672
+ }
673
+ function matchingBrace(opener) {
674
+ return opener === "{" ? "}" : "]";
675
+ }
676
+ //#endregion
677
+ //#region src/typing/keyChord.ts
678
+ function parseKeyChord(token) {
679
+ const parts = token.split(/-(?!$)/u);
680
+ const key = parts[parts.length - 1] === "Space" ? " " : parts[parts.length - 1];
681
+ const modifiers = {};
682
+ for (const modifier of parts.slice(0, -1)) if (/^(cmd|meta|m)$/iu.test(modifier)) modifiers.metaKey = true;
683
+ else if (/^a(lt)?$/iu.test(modifier)) modifiers.altKey = true;
684
+ else if (/^(c|ctrl|control)$/iu.test(modifier)) modifiers.ctrlKey = true;
685
+ else if (/^s(hift)?$/iu.test(modifier)) modifiers.shiftKey = true;
686
+ else if (/^mod$/iu.test(modifier)) modifiers.ctrlKey = true;
687
+ else throw new Error(`Unrecognized modifier name: ${modifier}`);
688
+ return {
689
+ key,
690
+ modifiers
691
+ };
692
+ }
693
+ //#endregion
491
694
  //#region src/typing/typing.ts
492
695
  var ignoredKeys = /* @__PURE__ */ new Set([
493
696
  "Alt",
@@ -523,7 +726,7 @@ function insertText(view, text) {
523
726
  if (!keydownEvent.defaultPrevented) {
524
727
  const action = keyAction(character, modifiers);
525
728
  if (action.type === "delete") deleteText(view, action.direction);
526
- else if (action.type === "moveCaret") moveCaret(view, action.direction);
729
+ else if (action.type === "moveCaret") moveCaret(view, action.direction, action.extend);
527
730
  else if (action.type === "type") {
528
731
  view.dispatchEvent(new KeyboardEvent("keypress", {
529
732
  ...eventInit,
@@ -546,10 +749,19 @@ function keyAction(key, modifiers) {
546
749
  direction: 1,
547
750
  type: "delete"
548
751
  };
549
- if ((key === "ArrowLeft" || key === "ArrowRight") && !hasModifiers(modifiers)) return {
550
- direction: key === "ArrowLeft" ? -1 : 1,
551
- type: "moveCaret"
552
- };
752
+ if (key === "ArrowLeft" || key === "ArrowRight") {
753
+ const direction = key === "ArrowLeft" ? -1 : 1;
754
+ if (!hasModifiers(modifiers)) return {
755
+ direction,
756
+ extend: false,
757
+ type: "moveCaret"
758
+ };
759
+ if (modifiers?.shiftKey === true && !suppressesCharacter(modifiers)) return {
760
+ direction,
761
+ extend: true,
762
+ type: "moveCaret"
763
+ };
764
+ }
553
765
  if (key.length === 1) {
554
766
  if (suppressesCharacter(modifiers)) return { type: "ignore" };
555
767
  return {
@@ -585,36 +797,64 @@ var ProseMirrorTester = class {
585
797
  return this.view.dom.textContent;
586
798
  }
587
799
  destroyed = false;
588
- element;
800
+ mountPoint;
589
801
  view;
590
802
  constructor(documentRoot, options = {}) {
591
803
  if (typeof document === "undefined") throw new Error("TODO");
592
- this.element = document.createElement("div");
593
- document.body.append(this.element);
804
+ this.mountPoint = document.createElement("div");
805
+ document.body.append(this.mountPoint);
806
+ const { autoCleanup = true, plugins = [], ...editorProps } = options;
594
807
  const state = EditorState.create({
595
808
  doc: documentRoot,
596
- plugins: options.plugins ?? []
809
+ plugins
597
810
  });
598
811
  global.MutationObserver = MutationObserverMock;
599
812
  mockRangeRects();
600
- this.view = new EditorView(this.element, { state });
813
+ this.view = new EditorView(this.mountPoint, {
814
+ state,
815
+ ...editorProps
816
+ });
601
817
  liveTesters.add(this);
602
- if (options.autoCleanup ?? true) autoCleanupTesters.add(this);
818
+ if (autoCleanup) autoCleanupTesters.add(this);
819
+ }
820
+ click(target) {
821
+ this.assertAlive();
822
+ return click(this.view, target);
823
+ }
824
+ command(command) {
825
+ this.assertAlive();
826
+ return command(this.view.state, this.view.dispatch.bind(this.view), this.view);
827
+ }
828
+ copy() {
829
+ this.assertAlive();
830
+ return copy(this.view);
603
831
  }
604
832
  destroy() {
605
833
  if (this.destroyed) return;
606
834
  this.destroyed = true;
607
835
  this.view.destroy();
608
- this.element.remove();
836
+ this.mountPoint.remove();
609
837
  document.getSelection()?.removeAllRanges();
610
838
  autoCleanupTesters.delete(this);
611
839
  liveTesters.delete(this);
612
840
  if (liveTesters.size === 0) global.MutationObserver = originalMutationObserver;
613
841
  }
842
+ element(selector) {
843
+ this.assertAlive();
844
+ return element(this.view, selector);
845
+ }
846
+ elements(selector) {
847
+ this.assertAlive();
848
+ return elements(this.view, selector);
849
+ }
614
850
  insertText(text) {
615
851
  this.assertAlive();
616
852
  insertText(this.view, text);
617
853
  }
854
+ paste(content) {
855
+ this.assertAlive();
856
+ paste(this.view, content);
857
+ }
618
858
  selectText(selection) {
619
859
  this.assertAlive();
620
860
  this.view.dispatch(this.view.state.tr.setSelection(resolveSelection(this.doc, selection)));
@@ -624,28 +864,27 @@ var ProseMirrorTester = class {
624
864
  }
625
865
  };
626
866
  //#endregion
867
+ //#region src/stringifyMark.ts
868
+ var stringifyObjectOptions = {
869
+ indent: " ",
870
+ inlineCharacterLimit: 1e3
871
+ };
872
+ var stringifyMark = (mark) => Object.keys(mark.attrs).length > 0 ? `${mark.type.name}(${stringifyObject(mark.attrs, stringifyObjectOptions)})` : mark.type.name;
873
+ //#endregion
627
874
  //#region src/stringifyProseMirrorNode.ts
628
- function stringifyProseMirrorNode(node, indentation = "") {
629
- if (node.type.spec.toDebugString !== void 0) return `${indentation}${wrapMarks(node.marks, node.type.spec.toDebugString(node))}`;
630
- if (node.type.name === "text") return `${indentation}${wrapMarks(node.marks, escapeText(node.textContent))}`;
631
- const type = node.type.name;
632
- const content = [];
633
- const nextIndentation = `${indentation} `;
634
- if (Object.keys(node.attrs).length > 0) content.push(`${nextIndentation}${stringifyObject(node.attrs, {
635
- indent: " ",
636
- inlineCharacterLimit: 1e3
637
- })},`);
638
- node.content.forEach((item) => {
639
- content.push(`${stringifyProseMirrorNode(item, nextIndentation)},`);
640
- });
641
- let body;
642
- if (content.length === 0) body = `${type}()`;
643
- else if (content.length === 1 && node.content.firstChild?.type.name === "text") body = `${type}(${stringifyProseMirrorNode(node.content.firstChild, "")})`;
644
- else body = `${type}(\n${content.join("\n")}\n${indentation})`;
645
- return `${indentation}${wrapMarks(node.marks, body)}`;
875
+ function stringifyProseMirrorNode(node, selection, indentation = "") {
876
+ const markers = /* @__PURE__ */ new Map();
877
+ if (selection !== void 0) {
878
+ if (selection.anchor === selection.head) markers.set(selection.anchor, "<cursor>");
879
+ else {
880
+ markers.set(selection.anchor, "<anchor>");
881
+ markers.set(selection.head, "<head>");
882
+ }
883
+ }
884
+ return render(node, markers, indentation, 0);
646
885
  }
647
886
  function escapeText(text) {
648
- return `'${text.replace(/[\p{Cc}'\\]/gu, (char) => {
887
+ return text.replace(/[\p{Cc}\p{Cf}'\\]/gu, (char) => {
649
888
  switch (char) {
650
889
  case "\f": return "\\f";
651
890
  case "\n": return "\\n";
@@ -656,37 +895,153 @@ function escapeText(text) {
656
895
  case "\\": return "\\\\";
657
896
  default: return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
658
897
  }
659
- })}'`;
898
+ });
899
+ }
900
+ function quoteWithMarkers(text, start, markers) {
901
+ const inside = [...markers.entries()].filter(([pos]) => pos >= start && pos <= start + text.length).sort(([a], [b]) => a - b);
902
+ const wrapMarker = containsRTL(text) ? (notation) => `\u2066${notation}\u2069` : (notation) => notation;
903
+ let quoted = "";
904
+ let last = 0;
905
+ for (const [pos, notation] of inside) {
906
+ markers.delete(pos);
907
+ const offset = pos - start;
908
+ quoted += escapeText(text.slice(last, offset)) + wrapMarker(notation);
909
+ last = offset;
910
+ }
911
+ quoted += escapeText(text.slice(last));
912
+ return `'${quoted}'`;
913
+ }
914
+ function render(node, markers, indentation, contentStart) {
915
+ if (node.type.spec.toDebugString !== void 0) return `${indentation}${wrapMarks(node.marks, node.type.spec.toDebugString(node))}`;
916
+ if (node.type.name === "text") return `${indentation}${wrapMarks(node.marks, quoteWithMarkers(node.textContent, contentStart, markers))}`;
917
+ return renderElement(node, markers, indentation, contentStart);
918
+ }
919
+ function renderElement(node, markers, indentation, contentStart) {
920
+ const type = node.type.name;
921
+ const hasAttrs = Object.keys(node.attrs).length > 0;
922
+ const nextIndentation = `${indentation} `;
923
+ const args = [];
924
+ const pushArg = (content) => {
925
+ args.push(`${nextIndentation}${content},`);
926
+ };
927
+ const pushMarkerAt = (pos) => {
928
+ const notation = markers.get(pos);
929
+ if (notation !== void 0) {
930
+ markers.delete(pos);
931
+ pushArg(`'${notation}'`);
932
+ }
933
+ };
934
+ if (hasAttrs) pushArg(stringifyObject(node.attrs, stringifyObjectOptions));
935
+ if (!node.type.isLeaf) {
936
+ if (node.content.childCount === 0) pushMarkerAt(contentStart);
937
+ node.content.forEach((child, offset, index) => {
938
+ const childStart = contentStart + offset;
939
+ const isText = child.type.name === "text";
940
+ if (!(index > 0 && node.content.child(index - 1).type.name === "text") && !isText) pushMarkerAt(childStart);
941
+ if (isText) pushArg(wrapMarks(child.marks, quoteWithMarkers(child.textContent, childStart, markers)));
942
+ else args.push(`${render(child, markers, nextIndentation, childStart + 1)},`);
943
+ });
944
+ if (node.content.lastChild?.type.name !== "text") pushMarkerAt(contentStart + node.content.size);
945
+ }
946
+ const body = !hasAttrs && (node.content.childCount === 0 || node.content.childCount === 1 && node.content.firstChild?.type.name === "text") ? `${type}(${args.map((arg) => arg.slice(nextIndentation.length, -1)).join("")})` : `${type}(\n${args.join("\n")}\n${indentation})`;
947
+ return `${indentation}${wrapMarks(node.marks, body)}`;
660
948
  }
661
949
  function wrapMarks(marks, origContent) {
662
950
  let content = origContent;
663
951
  for (const mark of [...marks].reverse()) {
664
952
  const hasAttrs = Object.keys(mark.attrs).length > 0;
665
953
  const items = [content];
666
- if (hasAttrs) items.unshift(stringifyObject(mark.attrs, {
667
- indent: " ",
668
- inlineCharacterLimit: 1e3
669
- }));
954
+ if (hasAttrs) items.unshift(stringifyObject(mark.attrs, stringifyObjectOptions));
670
955
  content = `${mark.type.name}(${items.join(", ")})`;
671
956
  }
672
957
  return content;
673
958
  }
674
959
  //#endregion
960
+ //#region src/stringifyEditorState.ts
961
+ function stringifyEditorState(state, indentation = "") {
962
+ const { doc, selection, storedMarks } = state;
963
+ const lines = [stringifyProseMirrorNode(doc, selection, indentation), `${indentation}# selection: ${selection.constructor.name} ${String(selection.from)}..${String(selection.to)}`];
964
+ if (storedMarks !== null) lines.push(`${indentation}# storedMarks: [${storedMarks.map(stringifyMark).join(", ")}]`);
965
+ return lines.join("\n");
966
+ }
967
+ //#endregion
968
+ //#region src/parseHTML.ts
969
+ var wrapMap = {
970
+ caption: ["table"],
971
+ col: ["table", "colgroup"],
972
+ colgroup: ["table"],
973
+ tbody: ["table"],
974
+ td: [
975
+ "table",
976
+ "tbody",
977
+ "tr"
978
+ ],
979
+ tfoot: ["table"],
980
+ th: [
981
+ "table",
982
+ "tbody",
983
+ "tr"
984
+ ],
985
+ thead: ["table"],
986
+ tr: ["table", "tbody"]
987
+ };
988
+ function parseHTML(html, schema, options) {
989
+ return DOMParser.fromSchema(schema).parse(readHTML(html), options);
990
+ }
991
+ function readHTML(rawHtml) {
992
+ const metas = /^(\s*<meta [^>]*>)*/u.exec(rawHtml);
993
+ const stripped = metas ? rawHtml.slice(metas[0].length) : rawHtml;
994
+ const firstTag = /<([a-z][^>\s]+)/iu.exec(stripped);
995
+ const wrap = firstTag ? wrapMap[firstTag[1].toLowerCase()] : void 0;
996
+ const html = wrap === void 0 ? stripped : wrap.map((n) => `<${n}>`).join("") + stripped + wrap.map((n) => `</${n}>`).reverse().join("");
997
+ let elt = document.implementation.createHTMLDocument("title").body;
998
+ elt.innerHTML = html;
999
+ if (wrap) for (const n of wrap) elt = elt.querySelector(n) ?? elt;
1000
+ return elt;
1001
+ }
1002
+ //#endregion
675
1003
  //#region src/index.ts
676
1004
  afterEach(cleanupTesters);
677
- expect.extend({ toEqualProseMirrorNode(received, expected) {
678
- const receivedDoc = `\n${stringifyProseMirrorNode(received)}\n`;
679
- const expectedDoc = `\n${stringifyProseMirrorNode(expected)}\n`;
680
- const sameSchema = received.type.schema === expected.type.schema;
681
- const pass = sameSchema && this.equals(receivedDoc, expectedDoc);
682
- return {
683
- message: pass ? () => `${this.utils.matcherHint(".not.toEqualProsemirrorNode")}\n\nExpected value of document to not equal:\n ${this.utils.printExpected(expectedDoc)}\nActual:\n ${this.utils.printReceived(receivedDoc)}` : () => {
684
- if (!sameSchema && this.equals(receivedDoc, expectedDoc)) return `${this.utils.matcherHint(".toEqualProsemirrorNode")}\n\nThe documents stringify identically but come from different schemas:\n${this.utils.printReceived(receivedDoc)}`;
685
- const diffString = this.utils.diff(expectedDoc, receivedDoc);
686
- return `${this.utils.matcherHint(".toEqualProsemirrorNode")}\n\nExpected value of document to equal:\n${this.utils.printExpected(expectedDoc)}\nActual:\n${this.utils.printReceived(receivedDoc)}${diffString === void 0 ? "" : `\n\nDifference:\n\n${diffString}`}`;
687
- },
688
- pass
689
- };
690
- } });
1005
+ expect.extend({
1006
+ toEqualProseMirrorNode(received, expected) {
1007
+ const receivedDoc = `\n${stringifyProseMirrorNode(received)}\n`;
1008
+ const expectedDoc = `\n${stringifyProseMirrorNode(expected)}\n`;
1009
+ const sameSchema = received.type.schema === expected.type.schema;
1010
+ const pass = sameSchema && this.equals(receivedDoc, expectedDoc);
1011
+ return {
1012
+ message: pass ? () => `${this.utils.matcherHint(".not.toEqualProseMirrorNode")}\n\nExpected value of document to not equal:\n ${this.utils.printExpected(expectedDoc)}\nActual:\n ${this.utils.printReceived(receivedDoc)}` : () => {
1013
+ if (!sameSchema && this.equals(receivedDoc, expectedDoc)) return `${this.utils.matcherHint(".toEqualProseMirrorNode")}\n\nThe documents stringify identically but come from different schemas:\n${this.utils.printReceived(receivedDoc)}`;
1014
+ const diffString = this.utils.diff(expectedDoc, receivedDoc);
1015
+ return `${this.utils.matcherHint(".toEqualProseMirrorNode")}\n\nExpected value of document to equal:\n${this.utils.printExpected(expectedDoc)}\nActual:\n${this.utils.printReceived(receivedDoc)}${diffString === void 0 ? "" : `\n\nDifference:\n\n${diffString}`}`;
1016
+ },
1017
+ pass
1018
+ };
1019
+ },
1020
+ toHaveSelection(received, expected) {
1021
+ const state = received instanceof ProseMirrorTester ? received.state : received;
1022
+ const receivedSelection = state.selection;
1023
+ const expectedSelection = resolveSelection(state.doc, expected);
1024
+ const receivedDoc = `\n${stringifyProseMirrorNode(state.doc, receivedSelection)}\n`;
1025
+ const expectedDoc = `\n${stringifyProseMirrorNode(state.doc, expectedSelection)}\n`;
1026
+ const pass = receivedSelection.eq(expectedSelection);
1027
+ return {
1028
+ message: pass ? () => `${this.utils.matcherHint(".not.toHaveSelection")}\n\nExpected selection to not equal:\n${this.utils.printExpected(expectedDoc)}\nActual:\n${this.utils.printReceived(receivedDoc)}` : () => {
1029
+ const diffString = this.utils.diff(expectedDoc, receivedDoc);
1030
+ return `${this.utils.matcherHint(".toHaveSelection")}\n\nExpected selection to equal (${this.utils.printExpected(expectedSelection.constructor.name)}):\n${this.utils.printExpected(expectedDoc)}\nActual (${this.utils.printReceived(receivedSelection.constructor.name)}):\n${this.utils.printReceived(receivedDoc)}${diffString === void 0 ? "" : `\n\nDifference:\n\n${diffString}`}`;
1031
+ },
1032
+ pass
1033
+ };
1034
+ }
1035
+ });
1036
+ expect.addSnapshotSerializer({
1037
+ serialize: (val, _config, indentation) => stringifyProseMirrorNode(val, void 0, indentation).slice(indentation.length),
1038
+ test: isProseMirrorNode
1039
+ });
1040
+ expect.addSnapshotSerializer({
1041
+ serialize: (val, _config, indentation) => {
1042
+ return stringifyEditorState(val instanceof ProseMirrorTester ? val.state : val, indentation).slice(indentation.length);
1043
+ },
1044
+ test: (val) => val instanceof ProseMirrorTester || isEditorState(val)
1045
+ });
691
1046
  //#endregion
692
- export { ProseMirrorTester };
1047
+ export { ProseMirrorTester, parseHTML };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-prosemirror",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A plugin for Vitest that enables you to write tests using the ProseMirror editor",
5
5
  "keywords": [
6
6
  "vitest",
@@ -63,6 +63,7 @@
63
63
  "@types/stringify-object": "^4.0.5",
64
64
  "@vitest/coverage-v8": "^5.0.0",
65
65
  "@vitest/eslint-plugin": "^1.3.26",
66
+ "blns": "^2.0.4",
66
67
  "es-check": "^9.6.4",
67
68
  "eslint": "^10.0.2",
68
69
  "eslint-config-prettier": "^10.0.1",
@@ -70,6 +71,7 @@
70
71
  "eslint-plugin-perfectionist": "^5.0.0",
71
72
  "eslint-plugin-prefer-arrow-functions": "^3.9.1",
72
73
  "eslint-plugin-prettier": "^5.1.3",
74
+ "jiti": "^2.7.0",
73
75
  "jsdom": "^30.0.0",
74
76
  "npm-run-all2": "^9.0.3",
75
77
  "prettier": "^3.3.0",
@@ -77,6 +79,7 @@
77
79
  "prosemirror-inputrules": "^1.5.0",
78
80
  "prosemirror-keymap": "^1.2.3",
79
81
  "prosemirror-schema-basic": "^1.2.3",
82
+ "prosemirror-test-builder": "^1.1.1",
80
83
  "rimraf": "^6.0.1",
81
84
  "typescript": "^6.0.2",
82
85
  "typescript-eslint": "^8.58.0",