Skip to content

Commit

Permalink
feat: allow users to exit certain marks and code blocks by using the …
Browse files Browse the repository at this point in the history
…arrow keys

fixes #64
  • Loading branch information
b-kelly committed Mar 17, 2022
1 parent d3c6975 commit e37a959
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 9 deletions.
68 changes: 66 additions & 2 deletions src/rich-text/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 = (<TextSelection>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",
Expand Down
11 changes: 10 additions & 1 deletion src/rich-text/key-bindings.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -18,6 +23,7 @@ import {
moveToPreviousCellCommand,
moveSelectionAfterTableCommand,
insertTableCommand,
exitInclusiveMarkCommand,
} from "./commands";

export const richTextKeymap = keymap({
Expand All @@ -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({
Expand Down
32 changes: 26 additions & 6 deletions src/shared/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
...tags: string[]
): MarkSpec {
return {
...attributes,
toDOM() {
return [tags[0], 0];
},
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions test/rich-text/commands/index.test.ts
Original file line number Diff line number Diff line change
@@ -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],
[`<em>cannot exit emphasis from anywhere</em>`, true],
[`<code>cannot exit code from middle</code>`, 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([`<code>exit code mark</code>`, `<kbd>exit kbd mark</kbd>`])(
"should exit exitable marks",
(input) => {
let state = createState(input, []);
const from = getEndOfNode(state, 0);

state = applySelection(state, from);
expect(exitInclusiveMarkCommand(state, null)).toBe(true);
}
);
});
});

0 comments on commit e37a959

Please sign in to comment.