Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rich-text-editor): Add tagLink, subscript, superscript, kbd, and spoiler input #158

Merged
merged 35 commits into from
Aug 4, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2727669
saving progress on tag
Jul 6, 2022
c0c828b
Adding overflow dropdown for tag command
Jul 7, 2022
8350275
chore: fix minor typos (#156)
KyleMit Jul 7, 2022
9b01814
Updating localization for tagLink
Jul 7, 2022
9106c38
Adding new mark buttons for sup/sub and spoiler wrap.
Jul 7, 2022
9164fea
Undo commonmark changes
Jul 7, 2022
4ab904f
Fix broken active methods. Renamed test var.
Jul 7, 2022
f3b402c
Adding tests for wrapping node with spoiler/blockquote
Jul 8, 2022
201d174
Forgot docstring
Jul 8, 2022
c54909d
Merge branch 'main' into tmcentee/editor-51/new-rich-text-nodes
Jul 13, 2022
b7af4cc
Updated tagLink validation.
Jul 13, 2022
16edd14
Merge branch 'main' into tmcentee/editor-51/new-rich-text-nodes
b-kelly Jul 13, 2022
13993ee
readd items mistakenly removed during merge
b-kelly Jul 13, 2022
ad31959
tweak name
b-kelly Jul 13, 2022
33ef1d7
documentation
b-kelly Jul 13, 2022
61048ab
tag -> tagLink
Jul 14, 2022
da48d6c
overflow -> More formatting
Jul 14, 2022
8475514
Fix renamed SVG. Fixed tagname validation preventing toggle
Jul 14, 2022
950198b
adding basic docstrings for exported functions
Jul 14, 2022
836f92b
update strings in marks test
Jul 14, 2022
cad22e5
Created new NodeSelection test helper methods. Updated tagLink test t…
Jul 15, 2022
19419c4
Added more thorough tagLink target validation with tests
Jul 15, 2022
3d31a91
Adding <kbd> mark type to Rich Text input
Jul 15, 2022
2838e95
Changing keyboard shortcuts for sub/sup to match Google Docs
Jul 18, 2022
79b1113
Added trailing space logic to adjust text selection on tag creation
Jul 18, 2022
6b42611
Adding meta tag input. Reworked keyboard shortcuts
Jul 18, 2022
c4b2117
Merge branch 'main' into tmcentee/editor-51/new-rich-text-nodes
b-kelly Jul 19, 2022
75f443b
update to use TagLinkOptions.validate adding in main branch
b-kelly Jul 19, 2022
f1b0648
cleanup
b-kelly Jul 19, 2022
2582885
fix failing tests
b-kelly Jul 19, 2022
e49a2f8
cleanup
b-kelly Jul 19, 2022
802cec6
Merge branch 'main' into tmcentee/editor-51/new-rich-text-nodes
b-kelly Jul 25, 2022
8c1b168
Merge branch 'main' into tmcentee/editor-51/new-rich-text-nodes
b-kelly Jul 27, 2022
28e25d7
cleanup
b-kelly Jul 27, 2022
e682c75
move overflow dropdown
b-kelly Jul 27, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/rich-text/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,43 @@ const headingDropdown = (schema: Schema) =>
)
);

const overflowDropdown = (schema: Schema, options: CommonViewOptions) =>
tmcentee marked this conversation as resolved.
Show resolved Hide resolved
makeMenuDropdown(
"Overflow",
tmcentee marked this conversation as resolved.
Show resolved Hide resolved
_t("commands.overflow"),
"overflow-dropdown",
() => true,
() => false,
tmcentee marked this conversation as resolved.
Show resolved Hide resolved
dropdownItem(
_t("commands.tag", { shortcut: getShortcut("Mod-[") }),
tmcentee marked this conversation as resolved.
Show resolved Hide resolved
toggleTagCommand(options.parserFeatures.tagLinks.allowNonAscii),
"tag-btn",
nodeTypeActive(schema.nodes.tagLink),
["fs-body1", "mt8"]
tmcentee marked this conversation as resolved.
Show resolved Hide resolved
),
dropdownItem(
_t("commands.spoiler", { shortcut: getShortcut("Mod-]") }),
toggleWrapIn(schema.nodes.spoiler),
"spoiler-btn",
nodeTypeActive(schema.nodes.spoiler),
["fs-body1", "mt8"]
),
dropdownItem(
_t("commands.sub", { shortcut: getShortcut("Mod-;") }),
toggleMark(schema.marks.sub),
"subscript-btn",
markActive(schema.marks.sub),
["fs-body1", "mt8"]
),
dropdownItem(
_t("commands.sup", { shortcut: getShortcut("Mod-:") }),
toggleMark(schema.marks.sup),
"superscript-btn",
markActive(schema.marks.sup),
["fs-body1", "mt8"]
)
);

// TODO ensure that all names and priorities match those found in the rich-text editor
/**
* Creates all menu entries for the commonmark editor
Expand Down Expand Up @@ -552,6 +589,7 @@ export const createMenuEntries = (
options.parserFeatures.tables
),
addIf(tableDropdown(), options.parserFeatures.tables),
overflowDropdown(schema, options),
],
},
{
Expand Down
9 changes: 9 additions & 0 deletions src/rich-text/key-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
insertTableCommand,
exitInclusiveMarkCommand,
toggleHeadingLevel,
toggleTagCommand,
} from "./commands";

export function allKeymaps(
Expand Down Expand Up @@ -70,6 +71,14 @@ export function allKeymaps(
...bindLetterKeymap("Mod-h", toggleHeadingLevel()),
...bindLetterKeymap("Mod-r", insertHorizontalRuleCommand),
...bindLetterKeymap("Mod-m", setBlockType(schema.nodes.code_block)),
...bindLetterKeymap(
"Mod-[",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewer's note: mod+[/] are commonly used in IDEs to (un)indent a line. This shouldn't pose too much of an issue though, as we can chain commands together, placing the code indent command first, falling back to creating a taglink.

No action necessary.

toggleTagCommand(parserFeatures.tagLinks.allowNonAscii)
),
...bindLetterKeymap("Mod-]", wrapIn(schema.nodes.spoiler)),
...bindLetterKeymap("Mod-;", toggleMark(schema.marks.sub)),
...bindLetterKeymap("Mod-:", toggleMark(schema.marks.sup)),

// users expect to be able to leave certain blocks/marks using the arrow keys
"ArrowRight": exitInclusiveMarkCommand,
"ArrowDown": exitCode,
Expand Down
5 changes: 5 additions & 0 deletions src/shared/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const defaultStrings = {
bold: shortcut("Bold"),
code_block: shortcut("Code block"),
emphasis: shortcut("Italic"),
overflow: "Overflow",
heading: {
dropdown: shortcut("Heading"),
entry: ({ level }: { level: number }) => `Heading ${level}`,
Expand All @@ -30,6 +31,9 @@ export const defaultStrings = {
link: shortcut("Link"),
ordered_list: shortcut("Numbered list"),
redo: shortcut("Redo"),
spoiler: shortcut("Spoiler"),
sub: shortcut("Subscript"),
sup: shortcut("Superscript"),
strikethrough: "Strikethrough",
table_edit: "Edit table",
table_insert: shortcut("Table"),
Expand All @@ -43,6 +47,7 @@ export const defaultStrings = {
insert_before: "Insert row before",
remove: "Remove row",
},
tag: shortcut("Create Tag"),
undo: shortcut("Undo"),
unordered_list: shortcut("Bulleted list"),
},
Expand Down
22 changes: 22 additions & 0 deletions src/shared/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { escapeHtml } from "markdown-it/lib/common/utils";
import { Command, EditorState } from "prosemirror-state";
import type { TagLinkOptions } from "./view";

/**
* Recursively deep merges two objects into a new object, leaving the original two untouched
Expand Down Expand Up @@ -306,3 +307,24 @@ export function bindLetterKeymap(
[prefix + letter.toUpperCase()]: command,
};
}

/**
* Tests whether a string is a valid tag name.
* @param input The string to test
* @param options TagLink configuration options
*/
export function validateTagName(
tmcentee marked this conversation as resolved.
Show resolved Hide resolved
tagName: string,
allowNonAscii: boolean
): boolean {
const validationRegex = allowNonAscii
? /([a-z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF#+.-]+)/i
: /([a-z0-9#+.-]+)/i;

// test above regex as well as for any whitespace
if (/\s/.test(tagName) || !validationRegex.exec(tagName)) {
return false;
}

return true;
}
8 changes: 8 additions & 0 deletions src/styles/icons.less
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,11 @@
width: 21px;
--bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/Markdown.svg");
}

.icon-bg.iconTag {
--bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/Tag.svg");
}

.icon-bg.iconOverflow {
--bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/EllipsisHorizontal.svg");
}
124 changes: 124 additions & 0 deletions test/rich-text/commands/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
exitInclusiveMarkCommand,
insertHorizontalRuleCommand,
toggleHeadingLevel,
toggleTagCommand,
toggleWrapIn,
} from "../../../src/rich-text/commands";
import {
applySelection,
Expand Down Expand Up @@ -390,6 +392,126 @@ describe("commands", () => {
});
});

describe("toggleTagCommand", () => {
it("should not insert with no text selected", () => {
const state = createState("", []);

const { newState, isValid } = executeTransaction(
state,
toggleTagCommand(true)
);

expect(isValid).toBeFalsy();
let containsTagLink = false;

newState.doc.nodesBetween(0, newState.doc.content.size, (node) => {
containsTagLink = node.type.name === "tagLink";

return !containsTagLink;
});

expect(containsTagLink).toBeFalsy();
});

it("should not insert with whitespace in tag name", () => {
let state = createState("tag with spaces", []);

state = applySelection(state, 0, 16); //"is"

const { newState, isValid } = executeTransaction(
state,
toggleTagCommand(true)
);

expect(isValid).toBeFalsy();
let containsTagLink = false;

newState.doc.nodesBetween(0, newState.doc.content.size, (node) => {
containsTagLink = node.type.name === "tagLink";

return !containsTagLink;
});

expect(containsTagLink).toBeFalsy();
});

it("should replace selected text with tagLink", () => {
let state = createState("this is my state", []);

state = applySelection(state, 5, 7); //"is"

const { newState, isValid } = executeTransaction(
state,
toggleTagCommand(true)
);

expect(isValid).toBeTruthy();

expect(newState.doc).toMatchNodeTree({
"type.name": "doc",
"content": [
{
"type.name": "paragraph",
"content": [
{
isText: true,
text: "this ",
},
{
"type.name": "tagLink",
},
{
isText: true,
text: " my state",
},
],
},
],
});
});
});

describe("wrapInCommand", () => {
it.each([
[createState("", []).schema.nodes.spoiler, "spoiler"],
[createState("", []).schema.nodes.blockquote, "blockquote"],
])(
"should wrap selected node with nodeType",
(nodeType, nodeTypeText) => {
let state = createState("asdf", []);

state = applySelection(state, 0, 4);

const { newState, isValid } = executeTransaction(
state,
toggleWrapIn(nodeType)
);

expect(isValid).toBeTruthy();

expect(newState.doc).toMatchNodeTree({
"type.name": "doc",
"content": [
{
"type.name": nodeTypeText,
"content": [
{
"type.name": "paragraph",
"content": [
{
isText: true,
text: "asdf",
},
],
},
],
},
],
});
}
);
});

describe("exitMarkCommand", () => {
it("all exitable marks should also be inclusive: true", () => {
Object.keys(testRichTextSchema.marks).forEach((markName) => {
Expand All @@ -412,6 +534,8 @@ describe("commands", () => {
it.each([
[`middle of some text`, false],
[`<em>cannot exit emphasis from anywhere</em>`, true],
[`<sup>cannot exit emphasis from anywhere</sup>`, true],
[`<sub>cannot exit emphasis from anywhere</sub>`, true],
tmcentee marked this conversation as resolved.
Show resolved Hide resolved
[`<code>cannot exit code from middle</code>`, false],
])("should not exit unexitable marks", (input, positionCursorAtEnd) => {
let state = createState(input, []);
Expand Down