Files
outline/shared/editor/plugins/SuggestionsMenuPlugin.ts
T
Tom Moor 1eba87020c fix: Prevent block menu trigger when marked (#12515)
* Prevent block menu trigger when marked

* PR feedback
2026-05-28 21:30:53 -04:00

202 lines
6.2 KiB
TypeScript

import { action } from "mobx";
import type { EditorState } from "prosemirror-state";
import { Plugin } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
const MAX_MATCH = 500;
type ExtensionState = {
open: boolean;
query: string;
};
/**
* Determine whether the trigger character of a suggestion match carries any
* marks (e.g. bold, code, link).
*
* @param state The editor state.
* @param cursorPos The document position of the cursor (end of the match).
* @param match The regex match where group 1 is the search term.
* @returns True if the trigger character has one or more marks applied.
*/
export function isTriggerMarked(
state: EditorState,
cursorPos: number,
match: RegExpMatchArray
): boolean {
const queryLength = match[1]?.length ?? 0;
const triggerEnd = cursorPos - queryLength;
const triggerStart = triggerEnd - 1;
if (triggerStart < 0) {
return false;
}
return getMarksBetween(triggerStart, triggerEnd, state).length > 0;
}
export class SuggestionsMenuPlugin extends Plugin {
constructor(
extensionState: ExtensionState,
openRegex: RegExp,
enabledInMarks: boolean
) {
super({
props: {
handleDOMEvents: {
// IME composition (e.g. Korean, Japanese, Chinese) fires compositionupdate
// as each character is being built up. ProseMirror's view.composing flag
// blocks the normal handleKeyDown path, so we handle it separately here.
compositionupdate: (view) => {
setTimeout(() => {
const { pos: fromPos } = view.state.selection.$from;
const state = view.state;
const $from = state.doc.resolve(fromPos);
if ($from.parent.type.spec.code) {
return;
}
const textBefore = $from.parent.textBetween(
Math.max(0, $from.parentOffset - MAX_MATCH),
$from.parentOffset,
undefined,
"\ufffc"
);
const match = openRegex.exec(textBefore);
action(() => {
if (
match &&
(enabledInMarks || !isTriggerMarked(state, fromPos, match))
) {
if (match[0].length <= 2) {
extensionState.open = true;
}
extensionState.query = match[1];
}
})();
}, 0);
return false;
},
},
handleKeyDown: (view, event) => {
// Prosemirror input rules are not triggered on backspace, however
// we need them to be evaluated for the filter trigger to work
// correctly. This additional handler adds inputrules-like handling.
if (event.key === "Backspace") {
// timeout ensures that the delete has been handled by prosemirror
// and any characters removed, before we evaluate the rule.
setTimeout(() => {
const { pos: fromPos } = view.state.selection.$from;
this.execute(
view,
fromPos,
fromPos,
openRegex,
action((state, match) => {
if (
match &&
(enabledInMarks || !isTriggerMarked(state, fromPos, match))
) {
extensionState.query = match[1];
} else {
extensionState.open = false;
}
return null;
})
);
}, 0);
}
// Another plugin (e.g. the Placeholder mark) may consume the
// handleTextInput event by returning true, which prevents the
// InputRule from evaluating the trigger character. We use a timeout
// here so the re-evaluation happens after all synchronous handlers
// have run, ensuring the suggestion menu still opens in those cases.
if (
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key.length === 1
) {
setTimeout(() => {
const { pos: fromPos } = view.state.selection.$from;
this.execute(
view,
fromPos,
fromPos,
openRegex,
action((state, match) => {
if (
match &&
(enabledInMarks || !isTriggerMarked(state, fromPos, match))
) {
if (match[0].length <= 2) {
extensionState.open = true;
}
extensionState.query = match[1];
}
return null;
})
);
}, 0);
}
// If the menu is open then just ignore the key events in the editor
// itself until we're done.
if (
event.key === "Enter" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown" ||
event.key === "Tab"
) {
return extensionState.open;
}
return false;
},
},
});
}
// based on the input rules code in Prosemirror, here:
// https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/inputrules.js
private execute(
view: EditorView,
from: number,
to: number,
regex: RegExp,
handler: (
state: EditorState,
match: RegExpExecArray | null,
from?: number,
to?: number
) => boolean | null
) {
if (view.composing) {
return false;
}
const state = view.state;
const $from = state.doc.resolve(from);
if ($from.parent.type.spec.code) {
return false;
}
const textBefore = $from.parent.textBetween(
Math.max(0, $from.parentOffset - MAX_MATCH),
$from.parentOffset,
undefined,
"\ufffc"
);
const match = regex.exec(textBefore);
const result = handler(
state,
match,
match ? from - match[0].length : from,
to
);
if (!result) {
return false;
}
return true;
}
}