diff --git a/shared/editor/commands/toggleMark.ts b/shared/editor/commands/toggleMark.ts index d67b2fc2be..376ffc648f 100644 --- a/shared/editor/commands/toggleMark.ts +++ b/shared/editor/commands/toggleMark.ts @@ -1,24 +1,120 @@ import { toggleMark as pmToggleMark } from "prosemirror-commands"; import type { MarkType } from "prosemirror-model"; -import type { Command } from "prosemirror-state"; +import type { Command, EditorState } from "prosemirror-state"; +import { TextSelection } from "prosemirror-state"; import type { Primitive } from "utility-types"; import { chainTransactions } from "../lib/chainTransactions"; +import { getMarksBetween } from "../queries/getMarksBetween"; import { isMarkActive } from "../queries/isMarkActive"; +const wordCharRegex = /[\p{L}\p{N}_]/u; + +const ATOM_PLACEHOLDER = ""; + +/** + * If the selection is an empty cursor sitting inside a word (with word + * characters on both sides) in a textblock that allows the given mark type, + * returns the document positions spanning that word. Returns null otherwise — + * including when the cursor is at the leading or trailing edge of a word, + * since intent is ambiguous in those cases. + */ +function findWordRangeAtCursor( + state: EditorState, + type: MarkType +): { from: number; to: number } | null { + const { selection } = state; + if (!(selection instanceof TextSelection)) { + return null; + } + const $cursor = selection.$cursor; + if (!$cursor || !$cursor.parent.isTextblock) { + return null; + } + if (!$cursor.parent.type.allowsMarkType(type)) { + return null; + } + + const parentStart = $cursor.start(); + const parentEnd = $cursor.end(); + const text = state.doc.textBetween( + parentStart, + parentEnd, + undefined, + ATOM_PLACEHOLDER + ); + const offset = $cursor.pos - parentStart; + + const before = offset > 0 ? text[offset - 1] : ""; + const after = offset < text.length ? text[offset] : ""; + if (!wordCharRegex.test(before) || !wordCharRegex.test(after)) { + return null; + } + + let start = offset; + let end = offset; + while (start > 0 && wordCharRegex.test(text[start - 1])) { + start--; + } + while (end < text.length && wordCharRegex.test(text[end])) { + end++; + } + + return { from: parentStart + start, to: parentStart + end }; +} + +function rangeHasMarkWithAttrs( + state: EditorState, + type: MarkType, + attrs: Record | undefined, + from: number, + to: number +): boolean { + if (!state.doc.rangeHasMark(from, to, type)) { + return false; + } + if (!attrs) { + return true; + } + return getMarksBetween(from, to, state).some( + ({ mark }) => + mark.type === type && + Object.keys(attrs).every((key) => mark.attrs[key] === attrs[key]) + ); +} + /** * Toggles a mark on the current selection, if the mark is already with * matching attributes it will remove the mark instead, if the mark is active * but with different attributes it will update the mark with the new attributes. * + * When invoked with an empty cursor sitting inside a word (with word + * characters on both sides), the mark is applied to that whole word without + * altering the user's selection. + * * @param type - The mark type to toggle. * @param attrs - The attributes to apply to the mark. * @returns A prosemirror command. */ export function toggleMark( type: MarkType, - attrs: Record | undefined + attrs?: Record ): Command { return (state, dispatch) => { + const wordRange = findWordRangeAtCursor(state, type); + if (wordRange) { + const { from, to } = wordRange; + const hasMatching = rangeHasMarkWithAttrs(state, type, attrs, from, to); + + if (dispatch) { + const tr = state.tr.removeMark(from, to, type); + if (!hasMatching) { + tr.addMark(from, to, type.create(attrs)); + } + dispatch(tr); + } + return true; + } + if (isMarkActive(type, attrs)(state)) { return pmToggleMark(type)(state, dispatch); } diff --git a/shared/editor/marks/Bold.ts b/shared/editor/marks/Bold.ts index f585bffce3..b7d4d30420 100644 --- a/shared/editor/marks/Bold.ts +++ b/shared/editor/marks/Bold.ts @@ -1,6 +1,6 @@ -import { toggleMark } from "prosemirror-commands"; import type { InputRule } from "prosemirror-inputrules"; import type { MarkSpec, MarkType } from "prosemirror-model"; +import { toggleMark } from "../commands/toggleMark"; import { markInputRuleForPattern } from "../lib/markInputRule"; import Mark from "./Mark"; diff --git a/shared/editor/marks/Code.ts b/shared/editor/marks/Code.ts index b296143f6f..68ed70c1f7 100644 --- a/shared/editor/marks/Code.ts +++ b/shared/editor/marks/Code.ts @@ -1,5 +1,4 @@ import codemark from "prosemirror-codemark"; -import { toggleMark } from "prosemirror-commands"; import type { MarkSpec, MarkType, @@ -9,6 +8,7 @@ import type { } from "prosemirror-model"; import { Plugin, TextSelection } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; +import { toggleMark } from "../commands/toggleMark"; import { markInputRuleForPattern } from "../lib/markInputRule"; import type { MarkdownSerializerState } from "../lib/markdown/serializer"; import { isInCode } from "../queries/isInCode"; diff --git a/shared/editor/marks/Highlight.ts b/shared/editor/marks/Highlight.ts index 654706f2fc..a96be19f78 100644 --- a/shared/editor/marks/Highlight.ts +++ b/shared/editor/marks/Highlight.ts @@ -1,7 +1,7 @@ import { isHexColor } from "class-validator"; import { parseToRgb, rgba } from "polished"; -import { toggleMark } from "prosemirror-commands"; import type { MarkSpec, MarkType } from "prosemirror-model"; +import { toggleMark } from "../commands/toggleMark"; import { markInputRuleForPattern } from "../lib/markInputRule"; import markRule from "../rules/mark"; import Mark from "./Mark"; diff --git a/shared/editor/marks/Italic.ts b/shared/editor/marks/Italic.ts index e5f25cafc9..c76c952cd3 100644 --- a/shared/editor/marks/Italic.ts +++ b/shared/editor/marks/Italic.ts @@ -1,7 +1,7 @@ -import { toggleMark } from "prosemirror-commands"; import type { InputRule } from "prosemirror-inputrules"; import type { MarkSpec, MarkType } from "prosemirror-model"; import type { Command } from "prosemirror-state"; +import { toggleMark } from "../commands/toggleMark"; import { markInputRuleForPattern } from "../lib/markInputRule"; import Mark from "./Mark"; diff --git a/shared/editor/marks/Strikethrough.ts b/shared/editor/marks/Strikethrough.ts index 58b12ae5e8..e4eddcc99c 100644 --- a/shared/editor/marks/Strikethrough.ts +++ b/shared/editor/marks/Strikethrough.ts @@ -1,5 +1,5 @@ -import { toggleMark } from "prosemirror-commands"; import type { MarkSpec, MarkType } from "prosemirror-model"; +import { toggleMark } from "../commands/toggleMark"; import { markInputRuleForPattern } from "../lib/markInputRule"; import Mark from "./Mark"; diff --git a/shared/editor/marks/Underline.ts b/shared/editor/marks/Underline.ts index 5e93378b59..4026a11df7 100644 --- a/shared/editor/marks/Underline.ts +++ b/shared/editor/marks/Underline.ts @@ -1,5 +1,5 @@ -import { toggleMark } from "prosemirror-commands"; import type { MarkSpec, MarkType } from "prosemirror-model"; +import { toggleMark } from "../commands/toggleMark"; import { markInputRuleForPattern } from "../lib/markInputRule"; import underlinesRule from "../rules/underlines"; import Mark from "./Mark";