Files
outline/app/editor/extensions/Suggestion.ts
T
Tom Moor 9811ab6aea feat: Emoji reaction shorthand (#12650)
* Add "+:emoji:" reaction shorthand to comment form

Typing a comment that consists solely of a leading "+" followed by a
single emoji now adds that emoji as a reaction to the comment above,
instead of posting a new reply — mirroring the Slack shorthand.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Move parseReactionShorthand into editor/lib/emoji

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Open emoji menu when colon is preceded by a plus

The suggestion menu's trigger boundary excluded "+", so typing "+:" never
opened the emoji menu — preventing the "+:emoji:" reaction shorthand from
being typed. Add a configurable `precededBy` option to the Suggestion
extension and set it to "+" for the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Always allow "+" before suggestion trigger

Simplify by adding "+" to the trigger boundary for all suggestion menus
rather than making it a per-menu option. This lets the "+:emoji:" reaction
shorthand open the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 21:51:11 -04:00

126 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { escapeRegExp } from "es-toolkit/compat";
import { action, observable } from "mobx";
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 {
isTriggerMarked,
SuggestionsMenuPlugin,
} from "@shared/editor/plugins/SuggestionsMenuPlugin";
import { isInCode } from "@shared/editor/queries/isInCode";
/**
* Options shared by all suggestion-style extensions (block menu, emoji menu,
* mention menu).
*/
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. */
allowSpaces: boolean;
/** Whether the menu only opens once at least one character has been typed after the trigger. */
requireSearchTerm: boolean;
};
export default class Suggestion<
TOptions extends SuggestionOptions = SuggestionOptions,
> extends Extension<TOptions> {
constructor(options: TOptions) {
super(options);
const triggers = Array.isArray(this.options.trigger)
? this.options.trigger
: [this.options.trigger];
const triggerPattern =
triggers.length === 1
? escapeRegExp(triggers[0])
: `(?:${triggers.map(escapeRegExp).join("|")})`;
this.openRegex = new RegExp(
`(?:^|\\s|\\(|\\+|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.\\-_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
);
}
get plugins(): Plugin[] {
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() {
return {
Space: action(() => {
if (this.state.open && !this.options.allowSpaces) {
this.state.open = false;
}
return false;
}),
};
}
inputRules = (_options: { type: NodeType; schema: Schema }) => [
new InputRule(
this.openRegex,
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];
}
return null;
}
)
),
];
protected openRegex: RegExp;
protected state: {
open: boolean;
query: string;
} = observable({
open: false,
query: "",
});
/** Whether the suggestion menu is currently open. */
get isOpen(): boolean {
return this.state.open;
}
}