Refactor Editor components to be injected by associated extension (#6093)

This commit is contained in:
Tom Moor
2023-10-31 21:55:55 -04:00
committed by GitHub
parent 44198732d3
commit df6d8c12cc
25 changed files with 371 additions and 354 deletions

View File

@@ -0,0 +1,123 @@
import { action } from "mobx";
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 { WidgetProps } from "@shared/editor/lib/Extension";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import Suggestion from "~/editor/extensions/Suggestion";
import BlockMenu from "../components/BlockMenu";
export default class BlockMenuExtension extends Suggestion {
get defaultOptions() {
return {
openRegex: /^\/(\w+)?$/,
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/,
};
}
get name() {
return "block-menu";
}
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",
action(() => {
this.state.open = true;
})
);
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);
},
},
}),
];
}
widget = ({ rtl }: WidgetProps) => {
const { props, view } = this.editor;
return (
<BlockMenu
rtl={rtl}
isActive={this.state.open}
search={this.state.query}
onClose={action((insertNewLine) => {
if (insertNewLine) {
const transaction = view.state.tr.split(view.state.selection.to);
view.dispatch(transaction);
view.focus();
}
this.state.open = false;
})}
uploadFile={props.uploadFile}
onFileUploadStart={props.onFileUploadStart}
onFileUploadStop={props.onFileUploadStop}
embeds={props.embeds}
/>
);
};
}

View File

@@ -0,0 +1,45 @@
import { action } from "mobx";
import * as React from "react";
import { WidgetProps } from "@shared/editor/lib/Extension";
import Suggestion from "~/editor/extensions/Suggestion";
import EmojiMenu from "../components/EmojiMenu";
/**
* 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 EmojiMenuExtension extends Suggestion {
get defaultOptions() {
const languageIsUsingColon =
typeof window === "undefined"
? false
: languagesUsingColon.includes(window.navigator.language.slice(0, 2));
return {
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-menu";
}
widget = ({ rtl }: WidgetProps) => (
<EmojiMenu
rtl={rtl}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
this.state.open = false;
})}
/>
);
}

View File

@@ -0,0 +1,304 @@
import escapeRegExp from "lodash/escapeRegExp";
import { Node } from "prosemirror-model";
import { Command, Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import Extension from "@shared/editor/lib/Extension";
import FindAndReplace from "../components/FindAndReplace";
const pluginKey = new PluginKey("find-and-replace");
export default class FindAndReplaceExtension 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);
},
},
}),
];
}
public widget = () => (
<FindAndReplace readOnly={this.editor.props.readOnly} />
);
private results: { from: number; to: number }[] = [];
private currentResultIndex = 0;
private searchTerm = "";
}

View File

@@ -0,0 +1,80 @@
import { action, observable } from "mobx";
import { Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import HoverPreview from "~/components/HoverPreview";
interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */
delay: number;
}
export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
} = observable({
activeLinkElement: null,
});
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)) {
hoveringTimeout = setTimeout(
action(() => {
this.state.activeLinkElement = target as HTMLElement;
}),
this.options.delay
);
}
return false;
},
mouseout: action((view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest(
".use-hover-preview"
);
if (isHoverTarget(target, view)) {
clearTimeout(hoveringTimeout);
this.state.activeLinkElement = null;
}
return false;
}),
},
},
}),
];
}
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
onClose={action(() => {
this.state.activeLinkElement = null;
})}
/>
);
}

View File

@@ -0,0 +1,31 @@
import { action } from "mobx";
import * as React from "react";
import { WidgetProps } from "@shared/editor/lib/Extension";
import Suggestion from "~/editor/extensions/Suggestion";
import MentionMenu from "../components/MentionMenu";
export default class MentionMenuExtension extends Suggestion {
get defaultOptions() {
return {
// 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-menu";
}
widget = ({ rtl }: WidgetProps) => (
<MentionMenu
rtl={rtl}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
this.state.open = false;
})}
/>
);
}

View File

@@ -0,0 +1,73 @@
import { action, observable } from "mobx";
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 "@shared/editor/lib/Extension";
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
import isInCode from "@shared/editor/queries/isInCode";
export default class Suggestion extends Extension {
state: {
open: boolean;
query: string;
} = observable({
open: false,
query: "",
});
get plugins(): Plugin[] {
return [new SuggestionsMenuPlugin(this.options, this.state)];
}
keys() {
return {
Backspace: action((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.state.open = false;
return false;
}),
};
}
inputRules = (_options: { type: NodeType; schema: Schema }) => [
new InputRule(
this.options.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) &&
(!isInTable(state) || this.options.enabledInTable)
) {
this.state.open = true;
this.state.query = match[1];
}
return null;
})
),
new InputRule(
this.options.closeRegex,
action((_: EditorState, match: RegExpMatchArray) => {
if (match) {
this.state.open = false;
this.state.query = "";
}
return null;
})
),
];
}