From e37a959bf21949792bcedac249f3f60ced66daee Mon Sep 17 00:00:00 2001 From: b-kelly Date: Thu, 17 Mar 2022 13:53:40 -0400 Subject: [PATCH] feat: allow users to exit certain marks and code blocks by using the arrow keys fixes #64 --- src/rich-text/commands/index.ts | 68 ++++++++++++++++++++++++++- src/rich-text/key-bindings.ts | 11 ++++- src/shared/schema.ts | 32 ++++++++++--- test/rich-text/commands/index.test.ts | 64 +++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 9 deletions(-) diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts index 07a3e3c3..74b97917 100644 --- a/src/rich-text/commands/index.ts +++ b/src/rich-text/commands/index.ts @@ -1,7 +1,12 @@ import { setBlockType, toggleMark, wrapIn } from "prosemirror-commands"; import { redo, undo } from "prosemirror-history"; -import { MarkType, NodeType } from "prosemirror-model"; -import { EditorState, Plugin, Transaction } from "prosemirror-state"; +import { Mark, MarkType, NodeType } from "prosemirror-model"; +import { + EditorState, + Plugin, + TextSelection, + Transaction, +} from "prosemirror-state"; import { liftTarget } from "prosemirror-transform"; import { EditorView } from "prosemirror-view"; import { @@ -181,6 +186,65 @@ function markActive(mark: MarkType) { }; } +/** + * Exits an inclusive mark that has been marked as exitable by toggling the mark type + * and optionally adding a trailing space if the mark is at the end of the document + */ +export function exitInclusiveMarkCommand( + state: EditorState, + dispatch: (tr: Transaction) => void +) { + const $cursor = (state.selection).$cursor; + const marks = state.storedMarks || $cursor.marks(); + + if (!marks?.length) { + return false; + } + + // check if the current mark is exitable + const exitables = marks.filter((mark) => mark.type.spec.exitable); + + if (!exitables?.length) { + return false; + } + + // check if we're at the end of the exitable mark + const nextNode = $cursor.nodeAfter; + let endExitables: Mark[]; + + let tr = state.tr; + + if (nextNode && nextNode.marks?.length) { + // marks might be nested, so check each mark + endExitables = exitables.filter( + (mark) => !mark.type.isInSet(nextNode.marks) + ); + } else { + // no next node, so *all* marks are exitable + endExitables = exitables; + } + + if (!endExitables.length) { + return false; + } + + if (dispatch) { + // remove the exitable marks from the cursor + endExitables.forEach((e) => { + tr = tr.removeStoredMark(e); + }); + + // if there's no characters to the right of the cursor, add a space + if (!nextNode) { + tr = tr.insertText(" "); + } + + dispatch(tr); + } + + return true; +} + const tableDropdown = () => makeMenuDropdown( "Table", diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts index ee9c5578..2f06820b 100644 --- a/src/rich-text/key-bindings.ts +++ b/src/rich-text/key-bindings.ts @@ -1,4 +1,9 @@ -import { toggleMark, wrapIn, setBlockType } from "prosemirror-commands"; +import { + toggleMark, + wrapIn, + setBlockType, + exitCode, +} from "prosemirror-commands"; import { redo, undo } from "prosemirror-history"; import { undoInputRule } from "prosemirror-inputrules"; import { keymap } from "prosemirror-keymap"; @@ -18,6 +23,7 @@ import { moveToPreviousCellCommand, moveSelectionAfterTableCommand, insertTableCommand, + exitInclusiveMarkCommand, } from "./commands"; export const richTextKeymap = keymap({ @@ -42,6 +48,9 @@ export const richTextKeymap = keymap({ "Mod-h": setBlockType(schema.nodes.heading), "Mod-r": insertHorizontalRuleCommand, "Mod-m": setBlockType(schema.nodes.code_block), + // users expect to be able to leave certain blocks/marks using the arrow keys + "ArrowRight": exitInclusiveMarkCommand, + "ArrowDown": exitCode, }); export const tableKeymap = keymap({ diff --git a/src/shared/schema.ts b/src/shared/schema.ts index e6d4e0ca..aa8e4c6c 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -330,8 +330,12 @@ const nodes = defaultNodes * Creates a generic html MarkSpec for an inline html tag * @param tag The name of the tag to use in the Prosemirror dom */ -function genHtmlInlineMarkSpec(...tags: string[]): MarkSpec { +function genHtmlInlineMarkSpec( + attributes: Record, + ...tags: string[] +): MarkSpec { return { + ...attributes, toDOM() { return [tags[0], 0]; }, @@ -362,12 +366,28 @@ const extendedLinkMark: MarkSpec = { }, }; +const defaultCodeMark = defaultMarks.get("code"); +const extendedCodeMark: MarkSpec = { + ...defaultCodeMark, + exitable: true, + inclusive: true, +}; + const marks = defaultMarks - .addBefore("strong", "strike", genHtmlInlineMarkSpec("del", "s", "strike")) - .addBefore("strong", "kbd", genHtmlInlineMarkSpec("kbd")) - .addBefore("strong", "sup", genHtmlInlineMarkSpec("sup")) - .addBefore("strong", "sub", genHtmlInlineMarkSpec("sub")) - .update("link", extendedLinkMark); + .addBefore( + "strong", + "strike", + genHtmlInlineMarkSpec({}, "del", "s", "strike") + ) + .addBefore( + "strong", + "kbd", + genHtmlInlineMarkSpec({ exitable: true, inclusive: true }, "kbd") + ) + .addBefore("strong", "sup", genHtmlInlineMarkSpec({}, "sup")) + .addBefore("strong", "sub", genHtmlInlineMarkSpec({}, "sub")) + .update("link", extendedLinkMark) + .update("code", extendedCodeMark); // for *every* mark, add in support for the `markup` attribute // we use this to save the "original" html tag used to create the mark when converting from html markdown diff --git a/test/rich-text/commands/index.test.ts b/test/rich-text/commands/index.test.ts index 5f031913..6c39a765 100644 --- a/test/rich-text/commands/index.test.ts +++ b/test/rich-text/commands/index.test.ts @@ -1,6 +1,70 @@ +import { EditorState } from "prosemirror-state"; +import { exitInclusiveMarkCommand } from "../../../src/rich-text/commands"; +import { richTextSchema } from "../../../src/shared/schema"; +import { applySelection, createState } from "../test-helpers"; + +function getEndOfNode(state: EditorState, nodePos: number) { + let from = nodePos; + state.doc.nodesBetween(1, state.doc.content.size, (node, pos) => { + from = pos + node.nodeSize - 1; + return true; + }); + + return from; +} + describe("commands", () => { describe("toggleBlockType", () => { it.todo("should insert a paragraph at the end of the doc"); it.todo("should not insert a paragraph at the end of the doc"); }); + + describe("exitMarkCommand", () => { + it("all exitable marks should also be inclusive: true", () => { + Object.keys(richTextSchema.marks).forEach((markName) => { + const mark = richTextSchema.marks[markName]; + + try { + // require exitable marks to be explicitly marked as inclusive + expect(!mark.spec.exitable || mark.spec.inclusive).toBe( + true + ); + } catch { + // add a custom error message when the test fails + throw `${markName} is not both exitable *and* inclusive\ninclusive: ${String( + mark.spec.inclusive + )}`; + } + }); + }); + + it.each([ + [`middle of some text`, false], + [`cannot exit emphasis from anywhere`, true], + [`cannot exit code from middle`, false], + ])("should not exit unexitable marks", (input, positionCursorAtEnd) => { + let state = createState(input, []); + + let from = Math.floor(input.length / 2); + + if (positionCursorAtEnd) { + from = getEndOfNode(state, 0); + } + + state = applySelection(state, from); + + expect(exitInclusiveMarkCommand(state, null)).toBe(false); + }); + + it.each([`exit code mark`, `exit kbd mark`])( + "should exit exitable marks", + (input) => { + let state = createState(input, []); + const from = getEndOfNode(state, 0); + + state = applySelection(state, from); + expect(exitInclusiveMarkCommand(state, null)).toBe(true); + } + ); + }); });