diff --git a/src/rich-text/commands/index.ts b/src/rich-text/commands/index.ts
index e4e255fa..eed259e9 100644
--- a/src/rich-text/commands/index.ts
+++ b/src/rich-text/commands/index.ts
@@ -6,6 +6,7 @@ import {
EditorState,
TextSelection,
Transaction,
+ Selection,
} from "prosemirror-state";
import { liftTarget } from "prosemirror-transform";
import { EditorView } from "prosemirror-view";
@@ -23,7 +24,7 @@ import {
showImageUploader,
} from "../../shared/prosemirror-plugins/image-upload";
import { getCurrentTextNode, getShortcut } from "../../shared/utils";
-import type { CommonViewOptions } from "../../shared/view";
+import type { CommonViewOptions, TagLinkOptions } from "../../shared/view";
import { showLinkEditor } from "../plugins/link-editor";
import { insertParagraphIfAtDocEnd } from "./helpers";
import {
@@ -158,6 +159,92 @@ function getHeadingLevel(state: EditorState): number {
return level;
}
+/**
+ * Creates a command that toggles tagLink formatting for a node
+ * @param validate The function to validate the tagName with
+ * @param isMetaTag Whether the tag to be created is a meta tag or not
+ */
+export function toggleTagLinkCommand(
+ validate: TagLinkOptions["validate"],
+ isMetaTag: boolean
+) {
+ return (state: EditorState, dispatch?: (tr: Transaction) => void) => {
+ if (state.selection.empty) {
+ return false;
+ }
+
+ if (!isValidTagLinkTarget(state.schema, state.selection)) {
+ return false;
+ }
+
+ if (!dispatch) {
+ return true;
+ }
+
+ let tr = state.tr;
+ const nodeCheck = nodeTypeActive(state.schema.nodes.tagLink);
+ if (nodeCheck(state)) {
+ const selectedText = state.selection.content().content.firstChild
+ .attrs["tagName"] as string;
+
+ tr = state.tr.replaceSelectionWith(state.schema.text(selectedText));
+ } else {
+ const selectedText =
+ state.selection.content().content.firstChild?.textContent;
+
+ // If we have a trailing space, update the selection to not include it.
+ if (selectedText.endsWith(" ")) {
+ const { from, to } = state.selection;
+ state.selection = TextSelection.create(state.doc, from, to - 1);
+ }
+
+ if (!validate(selectedText.trim(), isMetaTag)) {
+ return false;
+ }
+
+ const newTagNode = state.schema.nodes.tagLink.create({
+ tagName: selectedText.trim(),
+ tagType: isMetaTag ? "meta-tag" : "tag",
+ });
+
+ tr = state.tr.replaceSelectionWith(newTagNode);
+ }
+
+ dispatch(tr);
+
+ return true;
+ };
+}
+
+/**
+ * Validates whether the target of our selection is within a valid context. e.g. not in a link
+ * @param schema Current editor schema
+ * @param selection Current selection handle
+ */
+function isValidTagLinkTarget(schema: Schema, selection: Selection): boolean {
+ const invalidNodeTypes = [
+ schema.nodes.horizontal_rule,
+ schema.nodes.code_block,
+ schema.nodes.image,
+ ];
+
+ const invalidNodeMarks = [schema.marks.link, schema.marks.code];
+
+ const hasInvalidMark =
+ selection.$head.marks().filter((f) => invalidNodeMarks.includes(f.type))
+ .length != 0;
+
+ return (
+ !invalidNodeTypes.includes(selection.$head.parent.type) &&
+ !hasInvalidMark
+ );
+}
+
+/**
+ * Creates a command that inserts a horizontal rule node
+ * @param state The current editor state
+ * @param dispatch The dispatch function to use
+ */
export function insertHorizontalRuleCommand(
state: EditorState,
dispatch: (tr: Transaction) => void
@@ -191,6 +278,12 @@ export function insertHorizontalRuleCommand(
return true;
}
+/**
+ * Opens the image uploader pane
+ * @param state The current editor state
+ * @param dispatch The dispatch function to use
+ * @param view The current editor view
+ */
export function insertImageCommand(
state: EditorState,
dispatch: (tr: Transaction) => void,
@@ -208,6 +301,9 @@ export function insertImageCommand(
/**
* Inserts a link into the document and opens the link edit tooltip at the cursor
+ * @param state The current editor state
+ * @param dispatch The dispatch function to use
+ * @param view The current editor view
*/
export function insertLinkCommand(
state: EditorState,
@@ -309,6 +405,8 @@ 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
+ * @param state The current editor state
+ * @param dispatch The dispatch function to use
*/
export function exitInclusiveMarkCommand(
state: EditorState,
@@ -365,6 +463,9 @@ export function exitInclusiveMarkCommand(
return true;
}
+/**
+ * Creates a dropdown menu for table edit functionality
+ */
const tableDropdown = () =>
makeMenuDropdown(
"Table",
@@ -408,6 +509,10 @@ const tableDropdown = () =>
)
);
+/**
+ * Creates a dropdown menu for heading formatting
+ * @param schema The finalized rich-text schema
+ */
const headingDropdown = (schema: Schema) =>
makeMenuDropdown(
"Header",
@@ -438,6 +543,62 @@ const headingDropdown = (schema: Schema) =>
)
);
+/**
+ * Creates a dropdown menu containing misc formatting tools
+ * @param schema The finalized rich-text schema
+ * @param options The options for the editor
+ */
+const moreFormattingDropdown = (schema: Schema, options: CommonViewOptions) =>
+ makeMenuDropdown(
+ "EllipsisHorizontal",
+ _t("commands.moreFormatting"),
+ "more-formatting-dropdown",
+ () => true,
+ () => false,
+ dropdownItem(
+ _t("commands.tagLink", { shortcut: getShortcut("Mod-[") }),
+ toggleTagLinkCommand(
+ options.parserFeatures.tagLinks.validate,
+ false
+ ),
+ "tag-btn",
+ nodeTypeActive(schema.nodes.tagLink)
+ ),
+ dropdownItem(
+ _t("commands.metaTagLink", { shortcut: getShortcut("Mod-]") }),
+ toggleTagLinkCommand(
+ options.parserFeatures.tagLinks.validate,
+ true
+ ),
+ "tag-btn",
+ nodeTypeActive(schema.nodes.tagLink)
+ ),
+ dropdownItem(
+ _t("commands.spoiler", { shortcut: getShortcut("Mod-/") }),
+ toggleWrapIn(schema.nodes.spoiler),
+ "spoiler-btn",
+ nodeTypeActive(schema.nodes.spoiler)
+ ),
+ dropdownItem(
+ _t("commands.sub", { shortcut: getShortcut("Mod-,") }),
+ toggleMark(schema.marks.sub),
+ "subscript-btn",
+ markActive(schema.marks.sub)
+ ),
+ dropdownItem(
+ _t("commands.sup", { shortcut: getShortcut("Mod-.") }),
+ toggleMark(schema.marks.sup),
+ "superscript-btn",
+ markActive(schema.marks.sup)
+ ),
+ dropdownItem(
+ _t("commands.kbd", { shortcut: getShortcut("Mod-'") }),
+ toggleMark(schema.marks.kbd),
+ "kbd-btn",
+ markActive(schema.marks.kbd)
+ )
+ );
+
// TODO ensure that all names and priorities match those found in the rich-text editor
/**
* Creates all menu entries for the commonmark editor
@@ -610,6 +771,7 @@ export const createMenuEntries = (
"horizontal-rule-btn"
),
},
+ moreFormattingDropdown(schema, options),
],
},
{
diff --git a/src/rich-text/key-bindings.ts b/src/rich-text/key-bindings.ts
index 05e8fbfd..3c6af1fb 100644
--- a/src/rich-text/key-bindings.ts
+++ b/src/rich-text/key-bindings.ts
@@ -29,6 +29,7 @@ import {
insertTableCommand,
exitInclusiveMarkCommand,
toggleHeadingLevel,
+ toggleTagLinkCommand,
} from "./commands";
export function allKeymaps(
@@ -70,6 +71,19 @@ export function allKeymaps(
...bindLetterKeymap("Mod-h", toggleHeadingLevel()),
...bindLetterKeymap("Mod-r", insertHorizontalRuleCommand),
...bindLetterKeymap("Mod-m", setBlockType(schema.nodes.code_block)),
+ ...bindLetterKeymap(
+ "Mod-[",
+ toggleTagLinkCommand(parserFeatures.tagLinks.validate, false)
+ ),
+ ...bindLetterKeymap(
+ "Mod-]",
+ toggleTagLinkCommand(parserFeatures.tagLinks.validate, true)
+ ),
+ ...bindLetterKeymap("Mod-/", wrapIn(schema.nodes.spoiler)),
+ ...bindLetterKeymap("Mod-,", toggleMark(schema.marks.sub)),
+ ...bindLetterKeymap("Mod-.", toggleMark(schema.marks.sup)),
+ ...bindLetterKeymap("Mod-'", toggleMark(schema.marks.kbd)),
+
// users expect to be able to leave certain blocks/marks using the arrow keys
"ArrowRight": exitInclusiveMarkCommand,
"ArrowDown": exitCode,
diff --git a/src/shared/localization.ts b/src/shared/localization.ts
index 87144d4a..9084b28e 100644
--- a/src/shared/localization.ts
+++ b/src/shared/localization.ts
@@ -27,9 +27,15 @@ export const defaultStrings = {
horizontal_rule: shortcut("Horizontal rule"),
image: shortcut("Image"),
inline_code: shortcut("Inline code"),
+ kbd: shortcut("Keyboard"),
link: shortcut("Link"),
+ metaTagLink: shortcut("Meta tag"),
+ moreFormatting: "More formatting",
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"),
@@ -43,6 +49,7 @@ export const defaultStrings = {
insert_before: "Insert row before",
remove: "Remove row",
},
+ tagLink: shortcut("Tag"),
undo: shortcut("Undo"),
unordered_list: shortcut("Bulleted list"),
},
diff --git a/src/styles/icons.less b/src/styles/icons.less
index 222bcc30..e661b644 100644
--- a/src/styles/icons.less
+++ b/src/styles/icons.less
@@ -111,3 +111,7 @@
width: 21px;
--bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/RichText.svg");
}
+
+.icon-bg.iconEllipsisHorizontal {
+ --bg-icon: url("~@stackoverflow/stacks-icons/src/Icon/EllipsisHorizontal.svg");
+}
diff --git a/test/rich-text/commands/index.test.ts b/test/rich-text/commands/index.test.ts
index 03c99e85..3ac4ce03 100644
--- a/test/rich-text/commands/index.test.ts
+++ b/test/rich-text/commands/index.test.ts
@@ -3,13 +3,17 @@ import {
exitInclusiveMarkCommand,
insertHorizontalRuleCommand,
toggleHeadingLevel,
+ toggleTagLinkCommand,
toggleWrapIn,
} from "../../../src/rich-text/commands";
import {
+ applyNodeSelection,
applySelection,
createState,
testRichTextSchema,
} from "../test-helpers";
+import { toggleMark } from "prosemirror-commands";
+import { MarkType } from "prosemirror-model";
function getEndOfNode(state: EditorState, nodePos: number) {
let from = nodePos;
@@ -454,6 +458,216 @@ describe("commands", () => {
});
});
+ describe("toggleTagLinkCommand", () => {
+ it("should not insert with no text selected", () => {
+ const state = createState("", []);
+
+ const { newState, isValid } = executeTransaction(
+ state,
+ toggleTagLinkCommand(() => true, false)
+ );
+
+ 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 when the text fails validation", () => {
+ let state = createState("tag with spaces", []);
+
+ state = applySelection(state, 1, 15);
+
+ const { newState, isValid } = executeTransaction(
+ state,
+ toggleTagLinkCommand(() => false, false)
+ );
+
+ 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.each([
+ [createState("", []).schema.marks.link],
+ [createState("", []).schema.marks.code],
+ ])(
+ "should not insert tag in text node with certain marks",
+ (mark: MarkType) => {
+ let state = createState("thisIsMyText", []);
+
+ state = applySelection(state, 1, 12);
+
+ const markResult = executeTransaction(state, toggleMark(mark));
+
+ expect(markResult.isValid).toBeTruthy();
+
+ markResult.newState = applySelection(markResult.newState, 2, 6);
+
+ const tagLinkResult = executeTransaction(
+ markResult.newState,
+ toggleTagLinkCommand(() => true, false)
+ );
+
+ expect(tagLinkResult.isValid).toBeFalsy();
+
+ let containsTagLink = false;
+ tagLinkResult.newState.doc.nodesBetween(
+ 0,
+ tagLinkResult.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,
+ toggleTagLinkCommand(() => true, false)
+ );
+
+ 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",
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it("should untoggle tagLink when selected", () => {
+ let state = createState("someText", []);
+
+ state = applySelection(state, 0, 8); // cursor is inside the tag
+
+ const { newState, isValid } = executeTransaction(
+ state,
+ toggleTagLinkCommand(() => true, false)
+ );
+
+ expect(isValid).toBeTruthy();
+
+ expect(newState.doc).toMatchNodeTree({
+ "type.name": "doc",
+ "content": [
+ {
+ "type.name": "paragraph",
+ "content": [
+ {
+ "type.name": "tagLink",
+ },
+ ],
+ },
+ ],
+ });
+
+ const nodeSelection = applyNodeSelection(newState, 1);
+
+ const { newState: newerState, isValid: isStillValid } =
+ executeTransaction(
+ nodeSelection,
+ toggleTagLinkCommand(() => true, false)
+ );
+
+ expect(isStillValid).toBeTruthy();
+
+ expect(newerState.doc).toMatchNodeTree({
+ "type.name": "doc",
+ "content": [
+ {
+ "type.name": "paragraph",
+ "content": [
+ {
+ isText: true,
+ text: "someText",
+ },
+ ],
+ },
+ ],
+ });
+ });
+ });
+
+ 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) => {
@@ -476,7 +690,10 @@ describe("commands", () => {
it.each([
[`middle of some text`, false],
[`cannot exit emphasis from anywhere`, true],
+ [`cannot exit sup from anywhere`, true],
+ [`cannot exit sub from anywhere`, true],
[`cannot exit code from middle
`, false],
+ [`cannot exit kbd from middle`, false],
])("should not exit unexitable marks", (input, positionCursorAtEnd) => {
let state = createState(input, []);
diff --git a/test/rich-text/test-helpers.ts b/test/rich-text/test-helpers.ts
index 666b6f0e..ae3e9f65 100644
--- a/test/rich-text/test-helpers.ts
+++ b/test/rich-text/test-helpers.ts
@@ -1,6 +1,7 @@
import { DOMParser, Node, Schema, Slice } from "prosemirror-model";
import {
EditorState,
+ NodeSelection,
Plugin,
TextSelection,
Transaction,
@@ -89,6 +90,22 @@ export function setSelection(
return tr;
}
+/** Applies a node selection to the passed state based on the given from */
+export function applyNodeSelection(
+ state: EditorState,
+ from: number
+): EditorState {
+ const tr = setNodeSelection(state.tr, from);
+ return state.apply(tr);
+}
+
+/** Creates a node selection transaction based on the given from */
+export function setNodeSelection(tr: Transaction, from: number): Transaction {
+ tr = tr.setSelection(NodeSelection.create(tr.doc, from));
+
+ return tr;
+}
+
/** Applies a command to the state and expects it to apply correctly */
export function runCommand(
state: EditorState,