Files
outline/app/editor/extensions/Suggestion.ts
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

98 lines
2.9 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 { 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;
/** 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)];
}
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) => {
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;
}
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;
}
}