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,