diff --git a/app/editor/extensions/BlockMenu.tsx b/app/editor/extensions/BlockMenu.tsx index bc3fedaca7..0499f6d6df 100644 --- a/app/editor/extensions/BlockMenu.tsx +++ b/app/editor/extensions/BlockMenu.tsx @@ -17,6 +17,7 @@ export default class BlockMenuExtension extends Suggestion { allowSpaces: false, requireSearchTerm: false, enabledInCode: false, + enabledInMarks: false, }; } @@ -87,7 +88,8 @@ export default class BlockMenuExtension extends Suggestion { condition: ({ node, $start, state }) => $start.depth === 1 && state.selection.$from.pos === $start.pos + node.content.size && - node.textContent === "/", + node.textContent === "/" && + node.firstChild?.marks.length === 0, text: ` ${t("Keep typing to filter")}…`, }, ]), diff --git a/app/editor/extensions/Suggestion.ts b/app/editor/extensions/Suggestion.ts index bead0e9cae..b258b76cf1 100644 --- a/app/editor/extensions/Suggestion.ts +++ b/app/editor/extensions/Suggestion.ts @@ -4,7 +4,10 @@ import { InputRule } from "prosemirror-inputrules"; import type { NodeType, Schema } from "prosemirror-model"; import type { EditorState, Plugin } from "prosemirror-state"; import Extension from "@shared/editor/lib/Extension"; -import { SuggestionsMenuPlugin } from "@shared/editor/plugins/SuggestionsMenuPlugin"; +import { + isTriggerMarked, + SuggestionsMenuPlugin, +} from "@shared/editor/plugins/SuggestionsMenuPlugin"; import { isInCode } from "@shared/editor/queries/isInCode"; /** @@ -14,6 +17,12 @@ import { isInCode } from "@shared/editor/queries/isInCode"; export type SuggestionOptions = { /** Whether the suggestion menu is allowed to open inside code blocks or inline code. */ enabledInCode: boolean; + /** + * Whether the suggestion menu may open when the trigger character carries a + * mark (e.g. bold, italic, link). Defaults to true – disable for menus where + * the trigger is only meaningful as plain text, such as the block menu. + */ + enabledInMarks?: boolean; /** Character (or list of characters) that opens the suggestion menu. */ trigger: string | string[]; /** Whether spaces are allowed inside the search term. */ @@ -45,7 +54,18 @@ export default class Suggestion< } get plugins(): Plugin[] { - return [new SuggestionsMenuPlugin(this.state, this.openRegex)]; + return [ + new SuggestionsMenuPlugin( + this.state, + this.openRegex, + this.enabledInMarks + ), + ]; + } + + /** Whether the menu may open when the trigger character carries a mark. */ + protected get enabledInMarks(): boolean { + return this.options.enabledInMarks ?? true; } keys() { @@ -62,21 +82,29 @@ export default class Suggestion< inputRules = (_options: { type: NodeType; schema: Schema }) => [ new InputRule( this.openRegex, - action((state: EditorState, match: RegExpMatchArray) => { - const { parent } = state.selection.$from; - if ( - match && - (parent.type.name === "paragraph" || - parent.type.name === "heading") && - (!isInCode(state) || this.options.enabledInCode) - ) { - if (match[0].length <= 2) { - this.state.open = true; + action( + ( + state: EditorState, + match: RegExpMatchArray, + _start: number, + end: number + ) => { + const { parent } = state.selection.$from; + if ( + match && + (parent.type.name === "paragraph" || + parent.type.name === "heading") && + (!isInCode(state) || this.options.enabledInCode) && + (this.enabledInMarks || !isTriggerMarked(state, end, match)) + ) { + if (match[0].length <= 2) { + this.state.open = true; + } + this.state.query = match[1]; } - this.state.query = match[1]; + return null; } - return null; - }) + ) ), ]; diff --git a/shared/editor/plugins/SuggestionsMenuPlugin.ts b/shared/editor/plugins/SuggestionsMenuPlugin.ts index d9ff158baf..c3e26baf49 100644 --- a/shared/editor/plugins/SuggestionsMenuPlugin.ts +++ b/shared/editor/plugins/SuggestionsMenuPlugin.ts @@ -2,6 +2,7 @@ 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; @@ -10,8 +11,35 @@ type ExtensionState = { 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) { + constructor( + extensionState: ExtensionState, + openRegex: RegExp, + enabledInMarks: boolean + ) { super({ props: { handleDOMEvents: { @@ -34,7 +62,10 @@ export class SuggestionsMenuPlugin extends Plugin { ); const match = openRegex.exec(textBefore); action(() => { - if (match) { + if ( + match && + (enabledInMarks || !isTriggerMarked(state, fromPos, match)) + ) { if (match[0].length <= 2) { extensionState.open = true; } @@ -59,8 +90,11 @@ export class SuggestionsMenuPlugin extends Plugin { fromPos, fromPos, openRegex, - action((_, match) => { - if (match) { + action((state, match) => { + if ( + match && + (enabledInMarks || !isTriggerMarked(state, fromPos, match)) + ) { extensionState.query = match[1]; } else { extensionState.open = false; @@ -89,8 +123,11 @@ export class SuggestionsMenuPlugin extends Plugin { fromPos, fromPos, openRegex, - action((_, match) => { - if (match) { + action((state, match) => { + if ( + match && + (enabledInMarks || !isTriggerMarked(state, fromPos, match)) + ) { if (match[0].length <= 2) { extensionState.open = true; }