feat: Format word at cursor position (#12492)

* wip

* refactor

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Tom Moor
2026-05-27 18:44:07 -04:00
committed by GitHub
parent b424d92724
commit 45c797653f
7 changed files with 104 additions and 8 deletions
+98 -2
View File
@@ -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<string, Primitive> | 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<string, Primitive> | undefined
attrs?: Record<string, Primitive>
): 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);
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";