packages/jest-remirror/src/jest-remirror-editor.ts
import { prettyDOM } from '@testing-library/dom';
import {
backspace,
copyContent,
dispatchTextSelection,
fireEventAtPosition,
FireProps,
forwardDelete,
insertText,
pasteContent,
press,
shortcut,
TestEditorView,
} from 'jest-prosemirror';
import {
ActiveFromExtensions,
AnyExtension,
BuiltinPreset,
ChainedFromExtensions,
CommandFunctionProps,
CommandsFromExtensions,
EditorSchema,
EditorState,
getTextSelection,
HelpersFromExtensions,
invariant,
isFunction,
isMarkExtension,
isNodeExtension,
object,
pick,
PrimitiveSelection,
ProsemirrorAttributes,
ProsemirrorNode,
range,
RemirrorManager,
Transaction,
} from '@remirror/core';
import { createDomEditor, createDomManager } from '@remirror/dom';
import type { CorePreset } from '@remirror/preset-core';
import { markFactory, nodeFactory } from './jest-remirror-builder';
import type {
MarkWithAttributes,
MarkWithoutAttributes,
NodeWithAttributes,
NodeWithoutAttributes,
RenderEditorProps,
TaggedProsemirrorNode,
Tags,
} from './jest-remirror-types';
import { createSelectionFromTaggedDocument, replaceSelection } from './jest-remirror-utils';
const elements = new Set<Element>();
/**
* This is the renderEditor test helper.
*
* @remarks
*
* This can be used to render your editor to the dom with all the desired
* extensions and it returns chainable methods for inserting text and
* dispatching commands.
*
* By default it already has the core preset applied.
*/
export function renderEditor<Extension extends AnyExtension>(
extensions: Extension[] | (() => Extension[]),
{ props, autoClean, ...options }: RenderEditorProps<Extension> = object(),
): RemirrorTestChain<Extension | CorePreset | BuiltinPreset> {
const element = createElement(props?.element, autoClean);
const manager = createDomManager(extensions, options);
createDomEditor<Extension | CorePreset | BuiltinPreset>({ ...props, element, manager });
return RemirrorTestChain.create(manager);
}
/**
* This creates a chainable test helper for testing your remirror presets,
* extensions and commands.
*
* @typeParam Extension - All the extensions being used within this editor
*/
export class RemirrorTestChain<Extension extends AnyExtension> {
/**
* A static method for creating the test helpers when testing your remirror
* models.
*/
static create<Extension extends AnyExtension = Remirror.Extensions>(
manager: RemirrorManager<Extension>,
): RemirrorTestChain<Extension> {
return new RemirrorTestChain<Extension>(manager);
}
/** The editor manager */
#manager: RemirrorManager<Extension>;
/** Additional custom tags */
#tags?: Tags;
/**
* The nodes available for building the prosemirror document.
*/
readonly nodes: Omit<NodeWithoutAttributes<this['manager']['~N'] | 'p'>, 'text'> = object();
/**
* The marks available for building up the prosemirror document.
*/
readonly marks: MarkWithoutAttributes<this['manager']['~M']> = object();
/**
* This is similar to the `node` except that each function returned here is
* able to receive custom attributes.
*
* ```ts
* import { HeadingExtension } from 'remirror/extensions';
*
* const editor = renderEditor<HeadingExtension>([new HeadingExtension()])
* const { heading } = editor.attributeNodes;
*
* heading({ level: 4, id: '1223' })('My custom heading');
* ```
*
* This attaches the attributes `level` and `id` to the `heading` node and the
* content `My custom heading` and would be rendered to HTML as:
* ```html
* <h4 id="1224">My custom heading</h4>
* ```
*
* Use this when testing nodes that can take custom attributes.
*/
readonly attributeNodes: Omit<NodeWithAttributes<this['manager']['~N']>, 'text'> = object();
/**
* This is very similar to the `attributeNodes` except for marks which can
* need to provide custom attributes.
*
* Use this when testing marks that can take custom attributes.
*/
readonly attributeMarks: MarkWithAttributes<this['manager']['~M']> = object();
/**
* Provide access to the editor manager.
*/
get manager(): RemirrorManager<Extension> {
return this.#manager;
}
/**
* The editor view.
*/
get view(): TestEditorView {
return this.manager.view as TestEditorView;
}
/**
* The editor state.
*/
get state(): EditorState {
return this.view.state;
}
/**
* The editor state.
*/
get tr(): Transaction {
return this.view.state.tr;
}
/**
* The editor schema.
*/
get schema(): EditorSchema {
return this.manager.schema;
}
/**
* The root node for the editor.
*/
get doc(): ProsemirrorNode {
return this.state.doc;
}
/**
* The commands available in the editor. When updating the content of the
* TestEditor make sure not to use a stale copy of the actions otherwise it
* will throw errors due to using an outdated state.
*/
get commands(): CommandsFromExtensions<Extension> {
return this.#manager.store.commands;
}
/**
* The chainable commands available in the editor. When updating the content of the
* TestEditor make sure not to use a stale copy of the actions otherwise it
* will throw errors due to using an outdated state.
*/
get chain(): ChainedFromExtensions<Extension> {
return this.#manager.store.chain;
}
/**
* Access to which nodes and marks are active under the current selection.
*/
get active(): ActiveFromExtensions<Extension> {
return this.#manager.store.active;
}
/**
* The helpers available in the editor. When updating the content of the
* TestEditor make sure not to use a stale copy of the helpers object
* otherwise it will throw errors due to using an outdated state.
*/
get helpers(): HelpersFromExtensions<Extension> {
return this.#manager.store.helpers;
}
/**
* The start of the current selection
*/
get from(): number {
return this.state.selection.from;
}
/**
* The end of the current selection. For a cursor selection this will be the
* same as the start.
*/
get to(): number {
return this.state.selection.to;
}
/**
* @deprecated use `from` instead
*/
get start(): number {
return this.state.selection.from;
}
/**
* @deprecated use `to` instead
*/
get end(): number {
return this.state.selection.to;
}
/**
* The content to write to the clipboard if copy the current selection.
*/
get copied(): { text: string; html: string } {
return copyContent({ view: this.view });
}
/**
* All custom tags that have been added *not including* the following
* - `<start>`
* - `<end>`
* - `<node>`
* - `<cursor>`
* - `<all>`
* - `<anchor>`
*
* Which are all part of the formal cursor and selection API.
*/
get tags(): Tags {
return this.#tags ?? {};
}
/**
* The dom node holding the view.
*/
get dom(): HTMLElement {
return this.view.dom;
}
/**
* The innerHTML for the editor.
*/
get innerHTML(): string {
return this.dom.innerHTML;
}
private constructor(manager: RemirrorManager<Extension>) {
this.#manager = manager;
this.createDocBuilders();
this.setupCloneListener();
}
/**
* Replace the manager with the newly cloned manager when cloned.
*/
private setupCloneListener() {
const dispose = this.#manager.addHandler('clone', (newManager) => {
this.#manager = newManager as any;
dispose();
this.setupCloneListener();
});
}
/**
* Create the node and mark document builders.
*/
private createDocBuilders() {
type MarkNames = this['manager']['~M'];
type NodeNames = Exclude<this['manager']['~N'], 'text'>;
this.nodes.p = nodeFactory({ name: 'paragraph', schema: this.schema });
for (const extension of this.#manager.extensions) {
if (isMarkExtension(extension)) {
this.marks[extension.name as MarkNames] = markFactory({
name: extension.name,
schema: this.schema,
});
this.attributeMarks[extension.name as MarkNames] = (
attrs: ProsemirrorAttributes = object(),
) => markFactory({ name: extension.name, schema: this.schema, attrs });
}
if (isNodeExtension(extension)) {
this.nodes[extension.name as NodeNames] = nodeFactory({
name: extension.name,
schema: this.schema,
});
this.attributeNodes[extension.name as NodeNames] = (
attrs: ProsemirrorAttributes = object(),
) => nodeFactory({ name: extension.name, schema: this.schema, attrs });
}
}
}
/**
* Add content to the editor.
*
* If content already exists it will be overwritten.
*/
readonly add = (taggedDocument: TaggedProsemirrorNode): this => {
const { schema } = taggedDocument.type;
invariant(taggedDocument.type === schema.topNodeType, {
message: `Expected a top level "${schema.topNodeType.name}" node but received a "${taggedDocument.type.name}" node`,
disableLogging: true,
});
const { content } = taggedDocument;
const { cursor, node, start, end, all, anchor, head, ...tags } = taggedDocument.tags;
const view = this.view;
this.#tags = tags;
// Set the document content
const tr = this.tr.replaceWith(0, this.doc.nodeSize - 2, content);
tr.setMeta('addToHistory', false);
view.dispatch(tr);
// Set the selection
const selection = createSelectionFromTaggedDocument(view.state.doc, taggedDocument.tags);
if (selection) {
view.dispatch(view.state.tr.setSelection(selection));
}
return this;
};
/**
* Alias for add.
*/
readonly overwrite = (taggedDocument: TaggedProsemirrorNode): this => this.add(taggedDocument);
/**
* Updates the tags.
*/
readonly update = (tags?: Tags): this => {
this.#tags = { ...this.tags, ...tags };
return this;
};
/**
* Selects the text between the provided start and end.
*/
readonly selectText = (selection: PrimitiveSelection): this => {
const tr = this.tr;
const textSelection = getTextSelection(selection, tr.doc);
this.view.dispatch(tr.setSelection(textSelection));
return this;
};
/**
* Allows for the chaining of calls and is useful for running tests after
* actions.
*
* You shouldn't use it to call any mutative functions that would change the
* editor state.
*
* ```ts
* const create = () => renderEditor({ plainNodes: [], others: [new EmojiExtension()] });
* const {
* nodes: { p, doc },
* add,
* } = create();
*
* add(doc(p('<cursor>'))).insertText(':-)')
* .callback(content => {
* expect(content.state.doc).toEqualRemirrorDocument(doc(p('π')));
* })
* .insertText(' awesome')
* .callback(content => {
* expect(content.state.doc).toEqualRemirrorDocument(doc(p('π awesome')));
* });
* ```
*/
readonly callback = (
fn: (
content: Pick<
this,
'helpers' | 'commands' | 'to' | 'state' | 'tags' | 'from' | 'start' | 'end' | 'doc' | 'view'
>,
) => void,
): this => {
fn(
pick(this, [
'helpers',
'commands',
'to',
'state',
'tags',
'from',
'start',
'end',
'doc',
'view',
]),
);
return this;
};
/**
* Runs a keyboard shortcut. e.g. `Mod-X`
*
* @param shortcut
*/
readonly shortcut = (text: string): this => {
shortcut({ shortcut: text, view: this.view });
return this;
};
/**
* A simplistic implementation of pasting content into the editor. Underneath
* it calls the paste handler `transformPaste` and that is all.
*
* @param content - The text or node to paste into the document at the current
* βion.
*/
readonly paste = (content: TaggedProsemirrorNode | string): this => {
pasteContent({ view: this.view, content });
return this;
};
/**
* Presses a key on the keyboard. e.g. `Mod-X`
*
* @param key - the key to press (or string representing a key)
*/
readonly press = (char: string, times = 1): this => {
for (const _ of range(times)) {
press({ char, view: this.view });
}
return this;
};
/**
* Simulates a backspace keypress and deletes text backwards.
*/
readonly backspace = (times?: number): this => {
backspace({ view: this.view, times });
return this;
};
/**
* Simulates a forward deletion.
*/
readonly forwardDelete = (times?: number): this => {
forwardDelete({ view: this.view, times });
return this;
};
/**
* Takes any command as an input and dispatches it within the document
* context.
*
* @param command - the command function to run with the current state and
* view
*/
readonly dispatchCommand = (command: (props: Required<CommandFunctionProps>) => any): this => {
command({ state: this.state, dispatch: this.view.dispatch, view: this.view, tr: this.tr });
return this;
};
/**
* Fires a custom event at the specified dom node. e.g. `click`
*
* @param shortcut - the shortcut to type
*/
readonly fire = (parameters: FireProps): this => {
fireEventAtPosition({ view: this.view, ...parameters });
return this;
};
/**
* Set selection in the document to a certain position
*/
readonly jumpTo = (pos: 'start' | 'end' | number, endPos?: number): this => {
if (pos === 'start') {
dispatchTextSelection({ view: this.view, start: 1 });
return this;
}
if (pos === 'end') {
dispatchTextSelection({ view: this.view, start: this.doc.content.size - 1 });
return this;
}
dispatchTextSelection({ view: this.view, start: pos, end: endPos });
return this;
};
/**
* A function which replaces the current selection with the new content.
*
* This should be used to add new content to the dom.
*/
readonly replace = (...replacement: string[] | TaggedProsemirrorNode[]): this => {
replaceSelection({ view: this.view, content: replacement });
return this;
};
/**
* Insert text at the current starting point for the cursor. Text will be
* typed out with keys each firing a keyboard event.
*
* ! This doesn't currently support the use of tags and cursors.
*
* ! Adding multiple strings which create nodes creates an out of
* position error
*/
insertText = (text: string): this => {
const { from } = this.state.selection;
insertText({ start: from, text, view: this.view });
return this;
};
/**
* Logs the view to the dom for help debugging the html in your tests.
*/
readonly debug = (element = this.view.dom): this => {
// eslint-disable-next-line no-console
console.log(prettyDOM(element));
return this;
};
/**
* Cleanup the element from the dom. Use this if you decide against automatic
* cleanup after tests.
*/
readonly cleanup = (): void => {
this.view.dom.parentElement?.remove();
};
}
/**
* Checks whether the provided node is in the page. Used by `createElement`.
*/
function isInPage(node: Node) {
return node === document.body ? false : document.body.contains(node);
}
/**
* Create an element in a way that can be tracked for easier cleanup after
* tests.
*
* @param element - the element to create or undefined if the element should be
* created by this function. When left undefined the element defaults to a
* `div`.
* @param autoClean - Whether to automatically cleanup this element after the
* test. Defaults to `true`.
*/
function createElement(element: Element | undefined, autoClean = true): Element {
if (!element) {
// Default to using a `div` when no element provided.
element = document.createElement('div');
}
// Make sure the element hasn't been previously added to the document body
// adding it.
if (!isInPage(element)) {
document.body.append(element);
}
// Auto clean works by tracking elements in a `Set` and at the end of a test
// all elements that are included will be deleted.
if (autoClean) {
elements.add(element);
}
return element;
}
/**
* Removes all the element added during the test, which where marked for
*/
function cleanup() {
for (const element of elements) {
if (element.parentNode === document.body) {
element.remove();
}
elements.delete(element);
}
}
// Cleanup the created elements after each test.
if (isFunction(afterEach)) {
// In a jest environment this should always be true (and this is the only
// environment supported by `jest-remirror`).
afterEach(() => {
cleanup();
});
}