import { type as assertType } from "@heydovetail/assert"; import "jest"; import { deleteSelection } from "prosemirror-commands"; import { redo, undo } from "prosemirror-history"; import { EditorState, PluginKey, Transaction } from "prosemirror-state"; import { historyPlugin } from "../../historyPlugin"; import { Command } from "../../types"; import { buildEditorState, BuildEditorStateOptions, buildNode, DovetailNodeFactory } from "../../__specs__/buildEditorState"; import "../../__specs__/expect.toMapEditorState"; import * as commands from "../commands"; import { RangeCreateJson } from "../RangeCreateStep"; import { RangeDeleteJson } from "../RangeDeleteStep"; import { RangePlugin } from "../RangePlugin"; import { RangeType } from "../types"; it("grows a range when text is inserted inside", () => expect(insertText("y")).toMapEditorState( build(({ doc, p }) => doc(p("x{range}x{^}x{/range}x"))), build(({ doc, p }) => doc(p("x{range}xy{^}x{/range}x"))) )); it("does not grow a range when text is at the end", () => expect(insertText("y")).toMapEditorState( build(({ doc, p }) => doc(p("x{range}xx{^}{/range}x"))), build(({ doc, p }) => doc(p("x{range}xx{/range}y{^}x"))) )); it("does not grow a range when text is at the start", () => expect(insertText("y")).toMapEditorState( build(({ doc, p }) => doc(p("x{range}{^}xx{/range}x"))), build(({ doc, p }) => doc(p("xy{^}{range}xx{/range}x"))) )); it("shrinks a range when partial text is deleted", () => expect(deleteSelection).toMapEditorState( build(({ doc, p }) => doc(p("x{range}{^}x{$}x{/range}x"))), build(({ doc, p }) => doc(p("x{range}{^}x{/range}x"))) )); it("shrinks a range when the tail is deleted", () => expect(deleteSelection).toMapEditorState( build(({ doc, p }) => doc(p("x{range}x{^}x{/range}x{$}"))), build(({ doc, p }) => doc(p("x{range}x{^}{/range}"))) )); it("shrinks a range when the head is deleted", () => expect(deleteSelection).toMapEditorState( build(({ doc, p }) => doc(p("{^}x{range}x{$}x{/range}x"))), build(({ doc, p }) => doc(p("{^}{range}x{/range}x"))) )); it("deletes a range when entire range text is deleted", () => expect(deleteSelection).toMapEditorState( build(({ doc, p }) => doc(p("x{range}{^}xx{$}{/range}x"))), build(({ doc, p }) => doc(p("x{^}x"))) )); it("deletes a range when AllSelection is deleted", () => expect(deleteSelection).toMapEditorState( build(({ doc, p }) => doc(p("x{range}xx{/range}x")), { selection: ({ all }) => all() }), build(({ doc, p }) => doc(p())) )); it("restores a range when entire range text is deleted, then history undo", () => { historyTest( [deleteSelection, undo], build(({ doc, p }) => doc(p("h{range}{^}ell{$}{/range}o"))), build(({ doc, p }) => doc(p("h{range}{^}ell{$}{/range}o"))) ); }); it("restores a range when the head text is deleted, then history undo", () => { historyTest( [deleteSelection, undo], build(({ doc, p }) => doc(p("h{range}{^}el{$}l{/range}o"))), build(({ doc, p }) => doc(p("h{range}{^}el{$}l{/range}o"))) ); }); it("does not affect a range when before the head text is deleted, then history undo", () => { historyTest( [deleteSelection, undo], build(({ doc, p }) => doc(p("{^}h{$}{range}ell{/range}o"))), build(({ doc, p }) => doc(p("{^}h{$}{range}ell{/range}o"))) ); }); it("restores a range when the tail text is deleted, then history undo", () => { historyTest( [deleteSelection, undo], build(({ doc, p }) => doc(p("h{range}el{^}l{$}{/range}o"))), build(({ doc, p }) => doc(p("h{range}el{^}l{$}{/range}o"))) ); }); it("does not affect a range when after the tail text is deleted, then history undo", () => { historyTest( [deleteSelection, undo], build(({ doc, p }) => doc(p("h{range}ell{/range}{^}o{$}"))), build(({ doc, p }) => doc(p("h{range}ell{/range}{^}o{$}"))) ); }); it("restores a range when `AllSelection` is deleted, then history undo", () => { historyTest( [deleteSelection, undo], build(({ doc, p }) => doc(p("h{range}ell{/range}o")), { selection: ({ all }) => all() }), build(({ doc, p }) => doc(p("h{range}ell{/range}o")), { selection: ({ all }) => all() }) ); }); it("deletes a range when more than the entire range text is deleted", () => expect(deleteSelection).toMapEditorState( build(({ doc, p }) => doc(p("{^}x{range}xx{/range}x{$}"))), build(({ doc, p }) => doc(p("{^}"))) )); it("copies transaction meta when replacing a transaction", () => { const state = build(({ doc, p }) => doc(p("h{range}el{^}l{/range}o{$}"))); const rangePlugin = state.plugins[0]; const key = new PluginKey(); const tr = state.tr; // This deletion (and specifically the range being deleted) causes the plugin // to rewrite the transaction in order to insert the custom `Range____Step` // steps. tr.deleteSelection(); tr.setMeta("fooKey", "foo"); tr.setMeta(rangePlugin, "plugin"); tr.setMeta(key, "pluginKey"); const newState = state.apply(tr); const newTr = rangePlugin.spec.replaceTransaction(tr, state, newState) as Transaction; expect(newTr.getMeta("fooKey")).toBe("foo"); expect(newTr.getMeta(rangePlugin)).toBe("plugin"); expect(newTr.getMeta(key)).toBe("pluginKey"); }); describe(assertType("range.delete"), () => { it("allows history undo to restore the range", () => historyTest( [commands.deleteRange("range"), undo], build(({ doc, p }) => doc(p("h{range}ell{/range}o"))), build(({ doc, p }) => doc(p("h{range}ell{/range}o"))) )); it("survives history undo→redo", () => historyTest( [commands.deleteRange("range"), undo, redo], build(({ doc, p }) => doc(p("h{range}ell{/range}o"))), build(({ doc, p }) => doc(p("hello"))) )); it("deletes a range", () => expect(commands.deleteRange("range")).toMapEditorState( build(({ doc, p }) => doc(p("h{range}ell{/range}o"))), build(({ doc, p }) => doc(p("hello"))) )); }); describe(assertType("range.create"), () => { function buildDocAndSetRangeCommand(factory: DovetailNodeFactory) { const doc = buildNode(factory); const setRangeCommand = commands.createRange({ id: "range", from: doc.refMap.get("a")!, to: doc.refMap.get("/a")!, type: RangeType.HIGHLIGHT, attrs: { tagIds: [] } }); return { doc, setRangeCommand }; } it("allows history undo to delete the range", () => { const { doc, setRangeCommand } = buildDocAndSetRangeCommand(({ doc, p }) => doc(p("h{a}ell{/a}o"))); historyTest([setRangeCommand, undo], build(() => doc), build(() => doc)); }); it("survives history undo→redo", () => { const { doc, setRangeCommand } = buildDocAndSetRangeCommand(({ doc, p }) => doc(p("h{a}ell{/a}o"))); historyTest([setRangeCommand, undo, redo], build(() => doc), build(({ doc, p }) => doc(p("h{range}ell{/range}o")))); }); it("sets a range", () => { const { doc, setRangeCommand } = buildDocAndSetRangeCommand(({ doc, p }) => doc(p("h{a}ell{/a}o"))); historyTest(setRangeCommand, build(() => doc), build(({ doc, p }) => doc(p("h{range}ell{/range}o")))); }); }); const build = (docFactory: DovetailNodeFactory, options: Pick = {}) => buildEditorState(docFactory, { plugins: ({ refs }) => [ new RangePlugin( refs.has("range") ? [ { id: "range", from: refs.get("range")!, to: refs.get("/range")!, type: RangeType.HIGHLIGHT, attrs: { tagIds: [] } } ] : [] ) ], ...options }); const insertText = (text: string): Command => (state, dispatch) => { if (dispatch !== undefined) { dispatch(state.tr.insertText(text)); } return true; }; function addHistoryPlugin(editorState: EditorState): EditorState { return editorState.reconfigure({ plugins: [...editorState.plugins, historyPlugin] }); } function removeHistoryPlugin(editorState: EditorState): EditorState { return editorState.reconfigure({ plugins: editorState.plugins.filter(plugin => plugin !== historyPlugin) }); } function historyTest(command: Command | ReadonlyArray, initial: EditorState, expected: EditorState) { const commands = typeof command !== "function" ? command : [command]; let result = addHistoryPlugin(initial); for (const command of commands) { command(result, tr => { result = result.apply(tr); }); } expect(removeHistoryPlugin(result)).toMatchEditorState(expected); }