Skip to content

Commit

Permalink
feat: add support for mark (em, strong, code, link) input rules (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ham Vocke authored Jul 7, 2021
1 parent 064ce83 commit 4814922
Show file tree
Hide file tree
Showing 5 changed files with 350 additions and 2 deletions.
Empty file modified .husky/commit-msg
100644 → 100755
Empty file.
107 changes: 107 additions & 0 deletions src/rich-text/inputrules.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import {
InputRule,
inputRules,
textblockTypeInputRule,
wrappingInputRule,
} from "prosemirror-inputrules";
import { MarkType } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { richTextSchema as schema } from "../shared/schema";
import { validateLink } from "../shared/utils";

const blockquoteInputRule = wrappingInputRule(
/^\s*>\s$/,
Expand All @@ -27,6 +31,103 @@ const orderedListRule = wrappingInputRule(
(match, node) => node.childCount + <number>node.attrs.order == +match[1]
);

// matches: `some text`, but not ` text `
const inlineCodeRegex = /`(\S(?:|.*?\S))`$/;
// matches: **some text**, but not ** text **
const boldRegex = /\*\*(\S(?:|.*?\S))\*\*$/;
// matches: *some text*, but not **text* or * text *
const emphasisRegex = /(?<!\*)\*([^*\s](?:|.*?[^*\s]))\*$/;
// matches: __some text__, but not __ text __
const boldUnderlineRegex = /__(\S(?:|.*?\S))__$/;
// matches: _some text_, but not __text_ or _ text _
const emphasisUnderlineRegex = /(?<!_)_([^_\s](?:|.*?[^*\s]))_$/;
// matches: [ *any* thing ]( any thing )
const linkRegex = /\[(.+)\]\((.+)\)$/;

const inlineCodeRule = markInputRule(inlineCodeRegex, schema.marks.code);
const boldRule = markInputRule(boldRegex, schema.marks.strong);
const emphasisRule = markInputRule(emphasisRegex, schema.marks.em);
const boldUnderlineRule = markInputRule(
boldUnderlineRegex,
schema.marks.strong
);
const emphasisUnderlineRule = markInputRule(
emphasisUnderlineRegex,
schema.marks.em
);
const linkRule = markInputRule(
linkRegex,
schema.marks.link,
(match: RegExpMatchArray) => {
return { href: match[2] };
},
(match) => validateLink(match[2]) // only apply link input rule, if the matched URL is valid
);

/**
* Create an input rule that applies a mark to the text matched by a regular expression.
* @param regexp The regular expression to match the text. The text to be wrapped in a mark needs to be marked by the _first_ capturing group.
* @param markType The mark type to apply
* @param getAttrs A function returning the attributes to be applied to the node
* @param matchValidator An optional function that allows validating the match before applying the mark
* @returns A mark input rule
*/
function markInputRule(
regexp: RegExp,
markType: MarkType,
getAttrs?: (p: string[]) => { [key: string]: unknown } | null | undefined,
matchValidator?: (match: RegExpMatchArray) => boolean
) {
return new InputRule(
regexp,
(
state: EditorState,
match: RegExpMatchArray,
start: number,
end: number
) => {
const attrs = getAttrs ? getAttrs(match) : {};
const tr = state.tr;

// if the current node doesn't allow this mark, don't attempt to transform
if (
!state.doc.resolve(start).parent.type.allowsMarkType(markType)
) {
return null;
}

// validate the match if a validator is given
// and skip applying the mark if the validation fails
if (matchValidator && !matchValidator(match)) {
return null;
}

const matchedString = match[0];
const capturedGroup = match[1];
if (capturedGroup) {
const textStart = start + matchedString.indexOf(capturedGroup);
const textEnd = textStart + capturedGroup.length;

if (textEnd < end) {
tr.delete(textEnd, end);
}

if (textStart > start) {
tr.delete(start, textStart);
}

end = start + capturedGroup.length;
}
// add mark to matching text
tr.addMark(start, end, markType.create(attrs));

// don't use mark for new text that's gonna follow
tr.removeStoredMark(markType);
return tr;
}
);
}

/**
* Defines all input rules we're using in our rich-text editor.
* Input rules are formatting operations that trigger as you type based on regular expressions
Expand All @@ -43,5 +144,11 @@ export const richTextInputRules = inputRules({
codeBlockRule,
unorderedListRule,
orderedListRule,
inlineCodeRule,
boldRule,
boldUnderlineRule,
emphasisRule,
emphasisUnderlineRule,
linkRule,
],
});
53 changes: 51 additions & 2 deletions test/e2e/editor.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,15 @@ const getMarkdownContent = async () => {
return text;
};

const typeText = async (text: string) => {
return await page.fill(editorSelector, text);
/**
* Type text into the editor window
* @param text the text to type
* @param simulateTyping if `true` this will type characters one by one instead of entering the text all at once. That's slower but sometimes necessary
*/
const typeText = async (text: string, simulateTyping = false) => {
return simulateTyping
? await page.type(editorSelector, text)
: await page.fill(editorSelector, text);
};

const enterTextAsMarkdown = async (text: string) => {
Expand Down Expand Up @@ -158,6 +165,48 @@ describe("rich-text mode", () => {
expect(doc.content[0].type).toBe(expectedNodeType);
}
);

it.each([
// valid inline mark rules
["**bold** ", "strong"],
["*emphasis* ", "em"],
["__bold__ ", "strong"],
["_emphasis_ ", "em"],
["`code` ", "code"],
["[a link](https://example.com)", "link"],
])(
"should create a mark on input '%s'",
async (input, expectedMarkType) => {
await clearEditor();
const simulateTyping = true;
await typeText(input, simulateTyping);
// TODO HACK don't use the debugging instance on window since it is unique to our specific view
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const doc = await page.evaluate(() =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(<any>window).editorInstance.editorView.state.doc.toJSON()
);

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(doc.content[0].content[0].marks[0].type).toContain(
expectedMarkType
);
}
);

it("should validate links for link input rule", async () => {
await clearEditor();
const simulateTyping = true;
await typeText("[invalid link](example)", simulateTyping);
// TODO HACK don't use the debugging instance on window since it is unique to our specific view
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const doc = await page.evaluate(() =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(<any>window).editorInstance.editorView.state.doc.toJSON()
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(doc.content[0].content[0].marks).toBeUndefined();
});
});

describe("editing images", () => {
Expand Down
185 changes: 185 additions & 0 deletions test/rich-text/inputrules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { MarkType } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { richTextInputRules } from "../../src/rich-text/inputrules";
import { richTextSchema } from "../../src/shared/schema";
import "../matchers";
import {
applySelection,
cleanupPasteSupport,
createState,
createView,
setupPasteSupport,
sleepAsync,
} from "./test-helpers";

function dispatchInputAsync(view: EditorView, inputStr: string) {
// insert all but the last character
const toInsert = inputStr.slice(0, -1);
view.dispatch(view.state.tr.insertText(toInsert));
applySelection(view.state, toInsert.length);

// fire the handleTextInput by appending to the final character dom directly
if (view.dom.children.length) {
view.dom.children[0].append(
document.createTextNode(inputStr.slice(-1))
);
}

// TODO HACK
// the above is triggered asyncronously via a dom observer,
// so defer execution so it can finish and update the state
return sleepAsync(0);
}

function markInputRuleTest(expectedMark: MarkType, charactersTrimmed: number) {
return async (testString: string, matches: boolean) => {
const state = createState("", [richTextInputRules]);
const view = createView(state);

await dispatchInputAsync(view, testString);

const matchedText: Record<string, unknown> = {
"isText": true,
"text": testString,
"marks.length": 0,
};

if (matches) {
matchedText.text = testString.slice(
charactersTrimmed,
charactersTrimmed * -1
);
matchedText["marks.length"] = 1;
matchedText["marks.0.type.name"] = expectedMark.name;
}

expect(view.state.doc).toMatchNodeTree({
content: [
{
"type.name": "paragraph",
"content": [
{
...matchedText,
},
],
},
],
});
};
}

describe("mark input rules", () => {
// TODO rename?
// these are necessary due to potential dom interaction
beforeAll(setupPasteSupport);
afterAll(cleanupPasteSupport);

const emphasisTests = [
["*match*", true],
["*should match*", true],
["**no-match*", false],
["**not a match*", false],
["* no-match*", false],
["*no-match *", false],
];
test.each(emphasisTests)(
"*emphasis* (%#)",
markInputRuleTest(richTextSchema.marks.em, 1)
);

const emphasisUnderlineTests = [
["_match_", true],
["_should match_", true],
["__no-match_", false],
["__not a match_", false],
["_ no-match_", false],
["_no-match _", false],
];
test.each(emphasisUnderlineTests)(
"_emphasis_ (%#)",
markInputRuleTest(richTextSchema.marks.em, 1)
);

const boldTests = [
["**match**", true],
["**should match**", true],
["** no-match**", false],
["**no-match **", false],
];
test.each(boldTests)(
"**strong** (%#)",
markInputRuleTest(richTextSchema.marks.strong, 2)
);

const boldUnderlineTests = [
["__match__", true],
["__should match__", true],
["__ no-match__", false],
["__no-match __", false],
];
test.each(boldUnderlineTests)(
"__strong__ (%#)",
markInputRuleTest(richTextSchema.marks.strong, 2)
);

const codeTests = [
["`match`", true],
["`should match`", true],
["` no-match`", false],
["`no-match `", false],
];
test.each(codeTests)(
"`code` (%#)",
markInputRuleTest(richTextSchema.marks.code, 1)
);

const linkTests = [
["[match](https://example.com)", true],
["[ this *is* a __match__ ](https://example.com)", true],
["[match](something)", false],
["[this is not a match](badurl)", false],
["[no-match(https://example.com)", false],
["[no-match)(https://example.com", false],
["no-match](https://example.com)", false],
["[no-match]()", false],
["[no-match]", false],
];
test.each(linkTests)(
"links (%#)",
async (testString: string, matches: boolean) => {
const state = createState("", [richTextInputRules]);
const view = createView(state);

await dispatchInputAsync(view, testString);

const matchedText: Record<string, unknown> = {
"isText": true,
"text": testString,
"marks.length": 0,
};

if (matches) {
matchedText.text = /\[(.+?)\]/.exec(testString)[1];
matchedText["marks.length"] = 1;
matchedText["marks.0.type.name"] =
richTextSchema.marks.link.name;
matchedText["marks.0.attrs.href"] = /\((.+?)\)/.exec(
testString
)[1];
}

expect(view.state.doc).toMatchNodeTree({
content: [
{
"type.name": "paragraph",
"content": [
{
...matchedText,
},
],
},
],
});
}
);
});
7 changes: 7 additions & 0 deletions test/rich-text/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,10 @@ export function dispatchPasteEvent(

el.dispatchEvent(event);
}

/** Returns a promise that is resolved delayMs from when it is called */
export function sleepAsync(delayMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => resolve(), delayMs);
});
}

0 comments on commit 4814922

Please sign in to comment.