Refactor Editor components to be injected by associated extension (#6093)
This commit is contained in:
@@ -1,99 +0,0 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { SuggestionsMenuType } from "../plugins/Suggestions";
|
||||
import { findParentNode } from "../queries/findParentNode";
|
||||
import { EventType } from "../types";
|
||||
import Suggestion from "./Suggestion";
|
||||
|
||||
export default class BlockMenu extends Suggestion {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
type: SuggestionsMenuType.Block,
|
||||
openRegex: /^\/(\w+)?$/,
|
||||
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/,
|
||||
};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "blockmenu";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const button = document.createElement("button");
|
||||
button.className = "block-menu-trigger";
|
||||
button.type = "button";
|
||||
ReactDOM.render(<PlusIcon />, button);
|
||||
|
||||
return [
|
||||
...super.plugins,
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const parent = findParentNode(
|
||||
(node) => node.type.name === "paragraph"
|
||||
)(state.selection);
|
||||
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTopLevel = state.selection.$from.depth === 1;
|
||||
if (!isTopLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
const isEmptyNode = parent && parent.node.content.size === 0;
|
||||
const isSlash = parent && parent.node.textContent === "/";
|
||||
|
||||
if (isEmptyNode) {
|
||||
decorations.push(
|
||||
Decoration.widget(
|
||||
parent.pos,
|
||||
() => {
|
||||
button.addEventListener("click", () => {
|
||||
this.editor.events.emit(EventType.SuggestionsMenuOpen, {
|
||||
type: SuggestionsMenuType.Block,
|
||||
query: "",
|
||||
});
|
||||
});
|
||||
return button;
|
||||
},
|
||||
{
|
||||
key: "block-trigger",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const isEmptyDoc = state.doc.textContent === "";
|
||||
if (!isEmptyDoc) {
|
||||
decorations.push(
|
||||
Decoration.node(
|
||||
parent.pos,
|
||||
parent.pos + parent.node.nodeSize,
|
||||
{
|
||||
class: "placeholder",
|
||||
"data-empty-text": this.options.dictionary.newLineEmpty,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (isSlash) {
|
||||
decorations.push(
|
||||
Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, {
|
||||
class: "placeholder",
|
||||
"data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return DecorationSet.create(state.doc, decorations);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { Command, Plugin, PluginKey } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
const pluginKey = new PluginKey("find-and-replace");
|
||||
|
||||
export default class FindAndReplace extends Extension {
|
||||
public get name() {
|
||||
return "find-and-replace";
|
||||
}
|
||||
|
||||
public get defaultOptions() {
|
||||
return {
|
||||
resultClassName: "find-result",
|
||||
resultCurrentClassName: "current-result",
|
||||
caseSensitive: false,
|
||||
regexEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
public commands() {
|
||||
return {
|
||||
/**
|
||||
* Find all matching results in the document for the given options
|
||||
*
|
||||
* @param attrs.text The search query
|
||||
* @param attrs.caseSensitive Whether the search should be case sensitive
|
||||
* @param attrs.regexEnabled Whether the search should be a regex
|
||||
*
|
||||
* @returns A command that finds all matching results
|
||||
*/
|
||||
find: (attrs: {
|
||||
text: string;
|
||||
caseSensitive?: boolean;
|
||||
regexEnabled?: boolean;
|
||||
}) => this.find(attrs.text, attrs.caseSensitive, attrs.regexEnabled),
|
||||
|
||||
/**
|
||||
* Find and highlight the next matching result in the document
|
||||
*/
|
||||
nextSearchMatch: () => this.goToMatch(1),
|
||||
|
||||
/**
|
||||
* Find and highlight the previous matching result in the document
|
||||
*/
|
||||
prevSearchMatch: () => this.goToMatch(-1),
|
||||
|
||||
/**
|
||||
* Replace the current highlighted result with the given text
|
||||
*
|
||||
* @param attrs.text The text to replace the current result with
|
||||
*/
|
||||
replace: (attrs: { text: string }) => this.replace(attrs.text),
|
||||
|
||||
/**
|
||||
* Replace all matching results with the given text
|
||||
*
|
||||
* @param attrs.text The text to replace all results with
|
||||
*/
|
||||
replaceAll: (attrs: { text: string }) => this.replaceAll(attrs.text),
|
||||
|
||||
/**
|
||||
* Clear the current search
|
||||
*/
|
||||
clearSearch: () => this.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
private get decorations() {
|
||||
return this.results.map((deco, index) =>
|
||||
Decoration.inline(deco.from, deco.to, {
|
||||
class:
|
||||
this.options.resultClassName +
|
||||
(this.currentResultIndex === index
|
||||
? ` ${this.options.resultCurrentClassName}`
|
||||
: ""),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public replace(replace: string): Command {
|
||||
return (state, dispatch) => {
|
||||
const result = this.results[this.currentResultIndex];
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { from, to } = result;
|
||||
dispatch?.(state.tr.insertText(replace, from, to).setMeta(pluginKey, {}));
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
public replaceAll(replace: string): Command {
|
||||
return ({ tr }, dispatch) => {
|
||||
let offset: number | undefined;
|
||||
|
||||
if (!this.results.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.results.forEach(({ from, to }, index) => {
|
||||
tr.insertText(replace, from, to);
|
||||
offset = this.rebaseNextResult(replace, index, offset);
|
||||
});
|
||||
|
||||
dispatch?.(tr);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
public find(
|
||||
searchTerm: string,
|
||||
caseSensitive = this.options.caseSensitive,
|
||||
regexEnabled = this.options.regexEnabled
|
||||
): Command {
|
||||
return (state, dispatch) => {
|
||||
this.options.caseSensitive = caseSensitive;
|
||||
this.options.regexEnabled = regexEnabled;
|
||||
this.searchTerm = regexEnabled ? searchTerm : escapeRegExp(searchTerm);
|
||||
this.currentResultIndex = 0;
|
||||
|
||||
dispatch?.(state.tr.setMeta(pluginKey, {}));
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
public clear(): Command {
|
||||
return (state, dispatch) => {
|
||||
this.searchTerm = "";
|
||||
this.currentResultIndex = 0;
|
||||
|
||||
dispatch?.(state.tr.setMeta(pluginKey, {}));
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
private get findRegExp() {
|
||||
try {
|
||||
return RegExp(
|
||||
this.searchTerm.replace(/\\+$/, ""),
|
||||
!this.options.caseSensitive ? "gui" : "gu"
|
||||
);
|
||||
} catch (err) {
|
||||
return RegExp("");
|
||||
}
|
||||
}
|
||||
|
||||
private goToMatch(direction: number): Command {
|
||||
return (state, dispatch) => {
|
||||
if (direction > 0) {
|
||||
if (this.currentResultIndex === this.results.length - 1) {
|
||||
this.currentResultIndex = 0;
|
||||
} else {
|
||||
this.currentResultIndex += 1;
|
||||
}
|
||||
} else {
|
||||
if (this.currentResultIndex === 0) {
|
||||
this.currentResultIndex = this.results.length - 1;
|
||||
} else {
|
||||
this.currentResultIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch?.(state.tr.setMeta(pluginKey, {}));
|
||||
|
||||
const element = window.document.querySelector(
|
||||
`.${this.options.resultCurrentClassName}`
|
||||
);
|
||||
if (element) {
|
||||
void scrollIntoView(element, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
|
||||
const nextIndex = index + 1;
|
||||
|
||||
if (!this.results[nextIndex]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { from: currentFrom, to: currentTo } = this.results[index];
|
||||
const offset = currentTo - currentFrom - replace.length + lastOffset;
|
||||
const { from, to } = this.results[nextIndex];
|
||||
|
||||
this.results[nextIndex] = {
|
||||
to: to - offset,
|
||||
from: from - offset,
|
||||
};
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
private search(doc: Node) {
|
||||
this.results = [];
|
||||
const mergedTextNodes: {
|
||||
text: string | undefined;
|
||||
pos: number;
|
||||
}[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (!this.searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
if (mergedTextNodes[index]) {
|
||||
mergedTextNodes[index] = {
|
||||
text: mergedTextNodes[index].text + (node.text ?? ""),
|
||||
pos: mergedTextNodes[index].pos,
|
||||
};
|
||||
} else {
|
||||
mergedTextNodes[index] = {
|
||||
text: node.text,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
});
|
||||
|
||||
mergedTextNodes.forEach(({ text = "", pos }) => {
|
||||
const search = this.findRegExp;
|
||||
let m;
|
||||
|
||||
while ((m = search.exec(text))) {
|
||||
if (m[0] === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
this.results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private createDeco(doc: Node) {
|
||||
this.search(doc);
|
||||
return this.decorations
|
||||
? DecorationSet.create(doc, this.decorations)
|
||||
: DecorationSet.empty;
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get focusAfterExecution() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: pluginKey,
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply: (tr, decorationSet) => {
|
||||
const action = tr.getMeta(pluginKey);
|
||||
|
||||
if (action) {
|
||||
return this.createDeco(tr.doc);
|
||||
}
|
||||
|
||||
if (tr.docChanged) {
|
||||
return decorationSet.map(tr.mapping, tr.doc);
|
||||
}
|
||||
|
||||
return decorationSet;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private results: { from: number; to: number }[] = [];
|
||||
private currentResultIndex = 0;
|
||||
private searchTerm = "";
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
interface HoverPreviewsOptions {
|
||||
/** Callback when a hover target is found or lost. */
|
||||
onHoverLink?: (target: Element | null) => void;
|
||||
|
||||
/** Delay before the target is considered "hovered" and callback is triggered. */
|
||||
delay: number;
|
||||
}
|
||||
|
||||
export default class HoverPreviews extends Extension {
|
||||
get defaultOptions(): HoverPreviewsOptions {
|
||||
return {
|
||||
delay: 500,
|
||||
};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "hover-previews";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const isHoverTarget = (target: Element | null, view: EditorView) =>
|
||||
target instanceof HTMLElement &&
|
||||
this.editor.elementRef.current?.contains(target) &&
|
||||
(!view.editable || (view.editable && !view.hasFocus()));
|
||||
|
||||
let hoveringTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mouseover: (view: EditorView, event: MouseEvent) => {
|
||||
const target = (event.target as HTMLElement)?.closest(
|
||||
".use-hover-preview"
|
||||
);
|
||||
if (isHoverTarget(target, view)) {
|
||||
if (this.options.onHoverLink) {
|
||||
hoveringTimeout = setTimeout(() => {
|
||||
this.options.onHoverLink?.(target);
|
||||
}, this.options.delay);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
mouseout: (view: EditorView, event: MouseEvent) => {
|
||||
const target = (event.target as HTMLElement)?.closest(
|
||||
".use-hover-preview"
|
||||
);
|
||||
if (isHoverTarget(target, view)) {
|
||||
clearTimeout(hoveringTimeout);
|
||||
this.options.onHoverLink?.(null);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { NodeType, Schema } from "prosemirror-model";
|
||||
import { EditorState, Plugin } from "prosemirror-state";
|
||||
import { isInTable } from "prosemirror-tables";
|
||||
import Extension from "../lib/Extension";
|
||||
import { SuggestionsMenuPlugin } from "../plugins/Suggestions";
|
||||
import isInCode from "../queries/isInCode";
|
||||
import { EventType } from "../types";
|
||||
|
||||
export default class Suggestion extends Extension {
|
||||
get plugins(): Plugin[] {
|
||||
return [new SuggestionsMenuPlugin(this.editor, this.options)];
|
||||
}
|
||||
|
||||
keys() {
|
||||
return {
|
||||
Backspace: (state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const textBefore = $from.parent.textBetween(
|
||||
Math.max(0, $from.parentOffset - 500), // 500 = max match
|
||||
Math.max(0, $from.parentOffset - 1), // 1 = account for deleted character
|
||||
null,
|
||||
"\ufffc"
|
||||
);
|
||||
|
||||
if (this.options.openRegex.test(textBefore)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.editor.events.emit(
|
||||
EventType.SuggestionsMenuClose,
|
||||
this.options.type
|
||||
);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
inputRules = (_options: { type: NodeType; schema: Schema }) => [
|
||||
new InputRule(this.options.openRegex, (state, match) => {
|
||||
const { parent } = state.selection.$from;
|
||||
if (
|
||||
match &&
|
||||
(parent.type.name === "paragraph" || parent.type.name === "heading") &&
|
||||
(!isInCode(state) || this.options.enabledInCode) &&
|
||||
(!isInTable(state) || this.options.enabledInTable)
|
||||
) {
|
||||
this.editor.events.emit(EventType.SuggestionsMenuOpen, {
|
||||
type: this.options.type,
|
||||
query: match[1],
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
new InputRule(this.options.closeRegex, (state, match) => {
|
||||
if (match) {
|
||||
this.editor.events.emit(
|
||||
EventType.SuggestionsMenuClose,
|
||||
this.options.type
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { Editor } from "../../../app/editor";
|
||||
|
||||
export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;
|
||||
|
||||
export type WidgetProps = { rtl: boolean };
|
||||
|
||||
export default class Extension {
|
||||
options: any;
|
||||
editor: Editor;
|
||||
@@ -50,6 +52,22 @@ export default class Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget is a React component to be rendered in the editor's context, independent of any
|
||||
* specific node or mark. It can be used to render things like toolbars, menus, etc. Note that
|
||||
* all widgets are observed automatically, so you can use observable values.
|
||||
*
|
||||
* @returns A React component
|
||||
*/
|
||||
widget(_props: WidgetProps): React.ReactElement | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A map of ProseMirror keymap bindings. It can be used to bind keyboard shortcuts to commands.
|
||||
*
|
||||
* @returns An object mapping key bindings to commands
|
||||
*/
|
||||
keys(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
@@ -57,6 +75,12 @@ export default class Extension {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* A map of ProseMirror input rules. It can be used to automatically replace certain patterns
|
||||
* while typing.
|
||||
*
|
||||
* @returns An array of input rules
|
||||
*/
|
||||
inputRules(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
@@ -64,6 +88,12 @@ export default class Extension {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A map of ProseMirror commands. It can be used to expose commands to the editor. If a single
|
||||
* command is returned, it will be available under the extension's name.
|
||||
*
|
||||
* @returns An object mapping command names to command factories, or a command factory
|
||||
*/
|
||||
commands(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PluginSimple } from "markdown-it";
|
||||
import { observer } from "mobx-react";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import { MarkdownParser } from "prosemirror-markdown";
|
||||
import { Schema } from "prosemirror-model";
|
||||
@@ -41,8 +42,20 @@ export default class ExtensionManager {
|
||||
});
|
||||
}
|
||||
|
||||
get nodes() {
|
||||
get widgets() {
|
||||
return this.extensions
|
||||
.filter((extension) => extension.widget({ rtl: false }))
|
||||
.reduce(
|
||||
(nodes, node: Node) => ({
|
||||
...nodes,
|
||||
[node.name]: observer(node.widget as any),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
get nodes() {
|
||||
const nodes = this.extensions
|
||||
.filter((extension) => extension.type === "node")
|
||||
.reduce(
|
||||
(nodes, node: Node) => ({
|
||||
@@ -51,6 +64,19 @@ export default class ExtensionManager {
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
for (const i in nodes) {
|
||||
if (nodes[i].marks) {
|
||||
// We must filter marks from the marks list that are not defined
|
||||
// in the schema for the current editor.
|
||||
nodes[i].marks = nodes[i].marks
|
||||
.split(" ")
|
||||
.filter((m: string) => Object.keys(nodes).includes(m))
|
||||
.join(" ");
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
get marks() {
|
||||
|
||||
@@ -7,41 +7,16 @@ import {
|
||||
} from "prosemirror-model";
|
||||
import { Command, TextSelection } from "prosemirror-state";
|
||||
import { Primitive } from "utility-types";
|
||||
import Suggestion from "../extensions/Suggestion";
|
||||
import Extension from "../lib/Extension";
|
||||
import { getEmojiFromName } from "../lib/emoji";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { SuggestionsMenuType } from "../plugins/Suggestions";
|
||||
import emojiRule from "../rules/emoji";
|
||||
|
||||
/**
|
||||
* Languages using the colon character with a space in front in standard
|
||||
* punctuation. In this case the trigger is only matched once there is additional
|
||||
* text after the colon.
|
||||
*/
|
||||
const languagesUsingColon = ["fr"];
|
||||
|
||||
export default class Emoji extends Suggestion {
|
||||
export default class Emoji extends Extension {
|
||||
get type() {
|
||||
return "node";
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
const languageIsUsingColon =
|
||||
typeof window === "undefined"
|
||||
? false
|
||||
: languagesUsingColon.includes(window.navigator.language.slice(0, 2));
|
||||
|
||||
return {
|
||||
type: SuggestionsMenuType.Emoji,
|
||||
openRegex: new RegExp(
|
||||
`(?:^|\\s):([0-9a-zA-Z_+-]+)${languageIsUsingColon ? "" : "?"}$`
|
||||
),
|
||||
closeRegex:
|
||||
/(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
|
||||
enabledInTable: true,
|
||||
};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "emoji";
|
||||
}
|
||||
|
||||
@@ -7,26 +7,15 @@ import {
|
||||
} from "prosemirror-model";
|
||||
import { Command, TextSelection } from "prosemirror-state";
|
||||
import { Primitive } from "utility-types";
|
||||
import Suggestion from "../extensions/Suggestion";
|
||||
import Extension from "../lib/Extension";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { SuggestionsMenuType } from "../plugins/Suggestions";
|
||||
import mentionRule from "../rules/mention";
|
||||
|
||||
export default class Mention extends Suggestion {
|
||||
export default class Mention extends Extension {
|
||||
get type() {
|
||||
return "node";
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
return {
|
||||
type: SuggestionsMenuType.Mention,
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
openRegex: /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u,
|
||||
closeRegex: /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
|
||||
enabledInTable: true,
|
||||
};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "mention";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import BlockMenu from "../extensions/BlockMenu";
|
||||
import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer";
|
||||
import DateTime from "../extensions/DateTime";
|
||||
import FindAndReplace from "../extensions/FindAndReplace";
|
||||
import History from "../extensions/History";
|
||||
import HoverPreviews from "../extensions/HoverPreviews";
|
||||
import Keys from "../extensions/Keys";
|
||||
import MaxLength from "../extensions/MaxLength";
|
||||
import PasteHandler from "../extensions/PasteHandler";
|
||||
@@ -109,12 +106,9 @@ export const richExtensions: Nodes = [
|
||||
TableRow,
|
||||
Highlight,
|
||||
TemplatePlaceholder,
|
||||
BlockMenu,
|
||||
Math,
|
||||
MathBlock,
|
||||
PreventTab,
|
||||
FindAndReplace,
|
||||
HoverPreviews,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import { action } from "mobx";
|
||||
import { EditorState, Plugin } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import type { Editor } from "../../../app/editor";
|
||||
import { EventType } from "../types";
|
||||
|
||||
const MAX_MATCH = 500;
|
||||
|
||||
export enum SuggestionsMenuType {
|
||||
Emoji = "emoji",
|
||||
Block = "block",
|
||||
Mention = "mention",
|
||||
}
|
||||
|
||||
type Options = {
|
||||
type: SuggestionsMenuType;
|
||||
openRegex: RegExp;
|
||||
closeRegex: RegExp;
|
||||
enabledInCode: true;
|
||||
enabledInTable: true;
|
||||
};
|
||||
|
||||
type ExtensionState = {
|
||||
open: boolean;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export class SuggestionsMenuPlugin extends Plugin {
|
||||
constructor(editor: Editor, options: Options) {
|
||||
constructor(options: Options, extensionState: ExtensionState) {
|
||||
super({
|
||||
props: {
|
||||
handleClick: () => {
|
||||
editor.events.emit(options.type);
|
||||
return false;
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
// Prosemirror input rules are not triggered on backspace, however
|
||||
// we need them to be evaluted for the filter trigger to work
|
||||
@@ -41,20 +34,16 @@ export class SuggestionsMenuPlugin extends Plugin {
|
||||
pos,
|
||||
pos,
|
||||
options.openRegex,
|
||||
(state, match) => {
|
||||
action((_, match) => {
|
||||
if (match) {
|
||||
editor.events.emit(EventType.SuggestionsMenuOpen, {
|
||||
type: options.type,
|
||||
query: match[1],
|
||||
});
|
||||
extensionState.open = true;
|
||||
extensionState.query = match[1];
|
||||
} else {
|
||||
editor.events.emit(
|
||||
EventType.SuggestionsMenuClose,
|
||||
options.type
|
||||
);
|
||||
extensionState.open = false;
|
||||
extensionState.query = "";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user