Refactor Editor components to be injected by associated extension (#6093)
This commit is contained in:
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { s } from "@shared/styles";
|
||||
import Collection from "~/models/Collection";
|
||||
import Arrow from "~/components/Arrow";
|
||||
@@ -12,9 +13,19 @@ import ButtonLink from "~/components/ButtonLink";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
const extensions = [
|
||||
...richExtensions,
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
HoverPreviewsExtension,
|
||||
];
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
@@ -104,6 +115,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
readOnly={!isEditing}
|
||||
autoFocus={isEditing}
|
||||
onBlur={handleStopEditing}
|
||||
extensions={extensions}
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
canUpdate
|
||||
|
||||
@@ -19,7 +19,6 @@ import { AttachmentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
@@ -47,7 +46,6 @@ export type Props = Optional<
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
previewsDisabled?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => void;
|
||||
@@ -62,7 +60,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
previewsDisabled,
|
||||
} = props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
@@ -73,22 +70,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
const [activeLinkElement, setActiveLink] =
|
||||
React.useState<HTMLAnchorElement | null>(null);
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleLinkActive = React.useCallback(
|
||||
(element: HTMLAnchorElement | null) => {
|
||||
setActiveLink(element);
|
||||
return false;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleLinkInactive = React.useCallback(() => {
|
||||
setActiveLink(null);
|
||||
}, []);
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
async (term: string) => {
|
||||
if (isInternalUrl(term)) {
|
||||
@@ -339,7 +322,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={previewsDisabled ? undefined : handleLinkActive}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
@@ -354,12 +336,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
{!shareId && (
|
||||
<HoverPreview
|
||||
element={activeLinkElement}
|
||||
onClose={handleLinkInactive}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ const POINTER_WIDTH = 22;
|
||||
|
||||
type Props = {
|
||||
/** The HTML element that is being hovered over, or null if none. */
|
||||
element: HTMLAnchorElement | null;
|
||||
element: HTMLElement | null;
|
||||
/** A callback on close of the hover preview. */
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -35,7 +35,7 @@ enum Direction {
|
||||
}
|
||||
|
||||
function HoverPreviewDesktop({ element, onClose }: Props) {
|
||||
const url = element?.href || element?.dataset.url;
|
||||
const url = element?.getAttribute("href") || element?.dataset.url;
|
||||
const previousUrl = usePrevious(url, true);
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
@@ -200,7 +200,7 @@ function useHoverPosition({
|
||||
isVisible,
|
||||
}: {
|
||||
cardRef: React.RefObject<HTMLDivElement>;
|
||||
element: HTMLAnchorElement | null;
|
||||
element: HTMLElement | null;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const [cardLeft, setCardLeft] = React.useState(0);
|
||||
|
||||
@@ -64,7 +64,6 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
{notification.comment && (
|
||||
<StyledCommentEditor
|
||||
defaultValue={toJS(notification.comment.data)}
|
||||
previewsDisabled
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "trigger"
|
||||
> &
|
||||
Required<Pick<SuggestionsMenuProps, "onLinkToolbarOpen" | "embeds">>;
|
||||
Required<Pick<SuggestionsMenuProps, "embeds">>;
|
||||
|
||||
function BlockMenu(props: Props) {
|
||||
const dictionary = useDictionary();
|
||||
|
||||
@@ -28,7 +28,7 @@ let searcher: FuzzySearch<TEmoji>;
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<Emoji>,
|
||||
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger"
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
>;
|
||||
|
||||
const EmojiMenu = (props: Props) => {
|
||||
|
||||
@@ -33,7 +33,7 @@ interface MentionItem extends MenuItem {
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<MentionItem>,
|
||||
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger"
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
>;
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
@@ -60,7 +60,6 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
onFileUploadStart?: () => void;
|
||||
onFileUploadStop?: () => void;
|
||||
onLinkToolbarOpen?: () => void;
|
||||
onClose: (insertNewLine?: boolean) => void;
|
||||
embeds?: EmbedDescriptor[];
|
||||
renderMenuItem: (
|
||||
@@ -252,17 +251,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return triggerFilePick("*");
|
||||
case "embed":
|
||||
return triggerLinkInput(item);
|
||||
case "link": {
|
||||
handleClearSearch();
|
||||
props.onClose();
|
||||
props.onLinkToolbarOpen?.();
|
||||
return;
|
||||
}
|
||||
default:
|
||||
insertNode(item);
|
||||
}
|
||||
},
|
||||
[insertNode, handleClearSearch, props]
|
||||
[insertNode]
|
||||
);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
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 { SuggestionsMenuType } from "../plugins/Suggestions";
|
||||
import { findParentNode } from "../queries/findParentNode";
|
||||
import { EventType } from "../types";
|
||||
import Suggestion from "./Suggestion";
|
||||
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 BlockMenu extends Suggestion {
|
||||
export default class BlockMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
type: SuggestionsMenuType.Block,
|
||||
openRegex: /^\/(\w+)?$/,
|
||||
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/,
|
||||
};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "blockmenu";
|
||||
return "block-menu";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
@@ -54,12 +54,12 @@ export default class BlockMenu extends Suggestion {
|
||||
Decoration.widget(
|
||||
parent.pos,
|
||||
() => {
|
||||
button.addEventListener("click", () => {
|
||||
this.editor.events.emit(EventType.SuggestionsMenuOpen, {
|
||||
type: SuggestionsMenuType.Block,
|
||||
query: "",
|
||||
});
|
||||
});
|
||||
button.addEventListener(
|
||||
"click",
|
||||
action(() => {
|
||||
this.state.open = true;
|
||||
})
|
||||
);
|
||||
return button;
|
||||
},
|
||||
{
|
||||
@@ -96,4 +96,28 @@ export default class BlockMenu extends Suggestion {
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
45
app/editor/extensions/EmojiMenu.tsx
Normal file
45
app/editor/extensions/EmojiMenu.tsx
Normal 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;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,14 @@ 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 "../lib/Extension";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import FindAndReplace from "../components/FindAndReplace";
|
||||
|
||||
const pluginKey = new PluginKey("find-and-replace");
|
||||
|
||||
export default class FindAndReplace extends Extension {
|
||||
export default class FindAndReplaceExtension extends Extension {
|
||||
public get name() {
|
||||
return "find-and-replace";
|
||||
}
|
||||
@@ -292,6 +294,10 @@ export default class FindAndReplace extends Extension {
|
||||
];
|
||||
}
|
||||
|
||||
public widget = () => (
|
||||
<FindAndReplace readOnly={this.editor.props.readOnly} />
|
||||
);
|
||||
|
||||
private results: { from: number; to: number }[] = [];
|
||||
private currentResultIndex = 0;
|
||||
private searchTerm = "";
|
||||
@@ -1,16 +1,22 @@
|
||||
import { action, observable } from "mobx";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import Extension from "../lib/Extension";
|
||||
import * as React from "react";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
|
||||
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 {
|
||||
state: {
|
||||
activeLinkElement: HTMLElement | null;
|
||||
} = observable({
|
||||
activeLinkElement: null,
|
||||
});
|
||||
|
||||
get defaultOptions(): HoverPreviewsOptions {
|
||||
return {
|
||||
delay: 500,
|
||||
@@ -38,27 +44,37 @@ export default class HoverPreviews extends Extension {
|
||||
".use-hover-preview"
|
||||
);
|
||||
if (isHoverTarget(target, view)) {
|
||||
if (this.options.onHoverLink) {
|
||||
hoveringTimeout = setTimeout(() => {
|
||||
this.options.onHoverLink?.(target);
|
||||
}, this.options.delay);
|
||||
}
|
||||
hoveringTimeout = setTimeout(
|
||||
action(() => {
|
||||
this.state.activeLinkElement = target as HTMLElement;
|
||||
}),
|
||||
this.options.delay
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
mouseout: (view: EditorView, event: MouseEvent) => {
|
||||
mouseout: action((view: EditorView, event: MouseEvent) => {
|
||||
const target = (event.target as HTMLElement)?.closest(
|
||||
".use-hover-preview"
|
||||
);
|
||||
if (isHoverTarget(target, view)) {
|
||||
clearTimeout(hoveringTimeout);
|
||||
this.options.onHoverLink?.(null);
|
||||
this.state.activeLinkElement = null;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
widget = () => (
|
||||
<HoverPreview
|
||||
element={this.state.activeLinkElement}
|
||||
onClose={action(() => {
|
||||
this.state.activeLinkElement = null;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
app/editor/extensions/MentionMenu.tsx
Normal file
31
app/editor/extensions/MentionMenu.tsx
Normal 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;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
app/editor/extensions/Suggestion.ts
Normal file
73
app/editor/extensions/Suggestion.ts
Normal 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;
|
||||
})
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -26,15 +26,17 @@ import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import Styles from "@shared/editor/components/Styles";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import Extension, {
|
||||
CommandFactory,
|
||||
WidgetProps,
|
||||
} from "@shared/editor/lib/Extension";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import { SuggestionsMenuType } from "@shared/editor/plugins/Suggestions";
|
||||
import { EventType } from "@shared/editor/types";
|
||||
import { UserPreferences } from "@shared/types";
|
||||
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
|
||||
@@ -43,21 +45,13 @@ import Flex from "~/components/Flex";
|
||||
import { PortalContext } from "~/components/Portal";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
import BlockMenu from "./components/BlockMenu";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
import EmojiMenu from "./components/EmojiMenu";
|
||||
import FindAndReplace from "./components/FindAndReplace";
|
||||
import { SearchResult } from "./components/LinkEditor";
|
||||
import LinkToolbar from "./components/LinkToolbar";
|
||||
import MentionMenu from "./components/MentionMenu";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
|
||||
const extensions = withComments(richExtensions);
|
||||
|
||||
export { default as Extension } from "@shared/editor/lib/Extension";
|
||||
|
||||
export type Props = {
|
||||
/** An optional identifier for the editor context. It is used to persist local settings */
|
||||
id?: string;
|
||||
@@ -124,8 +118,6 @@ export type Props = {
|
||||
href: string,
|
||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
/** Callback when user hovers on any link in the document */
|
||||
onHoverLink?: (element: HTMLAnchorElement | null) => boolean;
|
||||
/** Callback when user presses any key with document focused */
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
/** Collection of embed types to render in the document */
|
||||
@@ -148,12 +140,8 @@ type State = {
|
||||
isEditorFocused: boolean;
|
||||
/** If the toolbar for a text selection is visible */
|
||||
selectionToolbarOpen: boolean;
|
||||
/** If a suggestions menu is visible */
|
||||
suggestionsMenuOpen: SuggestionsMenuType | false;
|
||||
/** If the insert link toolbar is visible */
|
||||
linkToolbarOpen: boolean;
|
||||
/** The query for the suggestion menu */
|
||||
query: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -182,10 +170,8 @@ export class Editor extends React.PureComponent<
|
||||
state: State = {
|
||||
isRTL: false,
|
||||
isEditorFocused: false,
|
||||
suggestionsMenuOpen: false,
|
||||
selectionToolbarOpen: false,
|
||||
linkToolbarOpen: false,
|
||||
query: "",
|
||||
};
|
||||
|
||||
isBlurred = true;
|
||||
@@ -204,6 +190,7 @@ export class Editor extends React.PureComponent<
|
||||
[name: string]: NodeViewConstructor;
|
||||
};
|
||||
|
||||
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
|
||||
nodes: { [name: string]: NodeSpec };
|
||||
marks: { [name: string]: MarkSpec };
|
||||
commands: Record<string, CommandFactory>;
|
||||
@@ -214,14 +201,6 @@ export class Editor extends React.PureComponent<
|
||||
public constructor(props: Props & ThemeProps<DefaultTheme>) {
|
||||
super(props);
|
||||
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
|
||||
this.events.on(
|
||||
EventType.SuggestionsMenuOpen,
|
||||
this.handleOpenSuggestionsMenu
|
||||
);
|
||||
this.events.on(
|
||||
EventType.SuggestionsMenuClose,
|
||||
this.handleCloseSuggestionsMenu
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +258,6 @@ export class Editor extends React.PureComponent<
|
||||
if (
|
||||
!this.isBlurred &&
|
||||
!this.state.isEditorFocused &&
|
||||
!this.state.suggestionsMenuOpen &&
|
||||
!this.state.linkToolbarOpen &&
|
||||
!this.state.selectionToolbarOpen
|
||||
) {
|
||||
@@ -290,7 +268,6 @@ export class Editor extends React.PureComponent<
|
||||
if (
|
||||
this.isBlurred &&
|
||||
(this.state.isEditorFocused ||
|
||||
this.state.suggestionsMenuOpen ||
|
||||
this.state.linkToolbarOpen ||
|
||||
this.state.selectionToolbarOpen)
|
||||
) {
|
||||
@@ -310,6 +287,7 @@ export class Editor extends React.PureComponent<
|
||||
this.nodes = this.createNodes();
|
||||
this.marks = this.createMarks();
|
||||
this.schema = this.createSchema();
|
||||
this.widgets = this.createWidgets();
|
||||
this.plugins = this.createPlugins();
|
||||
this.rulePlugins = this.createRulePlugins();
|
||||
this.keymaps = this.createKeymaps();
|
||||
@@ -378,6 +356,10 @@ export class Editor extends React.PureComponent<
|
||||
});
|
||||
}
|
||||
|
||||
private createWidgets() {
|
||||
return this.extensions.widgets;
|
||||
}
|
||||
|
||||
private createNodes() {
|
||||
return this.extensions.nodes;
|
||||
}
|
||||
@@ -702,8 +684,6 @@ export class Editor extends React.PureComponent<
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
selectionToolbarOpen: true,
|
||||
suggestionsMenuOpen: false,
|
||||
query: "",
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -720,9 +700,7 @@ export class Editor extends React.PureComponent<
|
||||
private handleOpenLinkToolbar = () => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
suggestionsMenuOpen: false,
|
||||
linkToolbarOpen: true,
|
||||
query: "",
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -733,37 +711,6 @@ export class Editor extends React.PureComponent<
|
||||
}));
|
||||
};
|
||||
|
||||
private handleOpenSuggestionsMenu = (data: {
|
||||
type: SuggestionsMenuType;
|
||||
query: string;
|
||||
}) => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
suggestionsMenuOpen: data.type,
|
||||
query: data.query,
|
||||
}));
|
||||
};
|
||||
|
||||
private handleCloseSuggestionsMenu = (
|
||||
type: SuggestionsMenuType,
|
||||
insertNewLine?: boolean
|
||||
) => {
|
||||
if (insertNewLine) {
|
||||
const transaction = this.view.state.tr.split(
|
||||
this.view.state.selection.to
|
||||
);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
}
|
||||
if (type && this.state.suggestionsMenuOpen !== type) {
|
||||
return;
|
||||
}
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
suggestionsMenuOpen: false,
|
||||
}));
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } =
|
||||
this.props;
|
||||
@@ -792,84 +739,31 @@ export class Editor extends React.PureComponent<
|
||||
ref={this.elementRef}
|
||||
/>
|
||||
{this.view && (
|
||||
<>
|
||||
<SelectionToolbar
|
||||
rtl={isRTL}
|
||||
readOnly={readOnly}
|
||||
canComment={this.props.canComment}
|
||||
isTemplate={this.props.template === true}
|
||||
onOpen={this.handleOpenSelectionToolbar}
|
||||
onClose={this.handleCloseSelectionToolbar}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
/>
|
||||
{this.commands.find && <FindAndReplace readOnly={readOnly} />}
|
||||
</>
|
||||
<SelectionToolbar
|
||||
rtl={isRTL}
|
||||
readOnly={readOnly}
|
||||
canComment={this.props.canComment}
|
||||
isTemplate={this.props.template === true}
|
||||
onOpen={this.handleOpenSelectionToolbar}
|
||||
onClose={this.handleCloseSelectionToolbar}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && this.view && (
|
||||
<>
|
||||
{this.marks.link && (
|
||||
<LinkToolbar
|
||||
isActive={this.state.linkToolbarOpen}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onClose={this.handleCloseLinkToolbar}
|
||||
/>
|
||||
)}
|
||||
{this.nodes.emoji && (
|
||||
<EmojiMenu
|
||||
rtl={isRTL}
|
||||
isActive={
|
||||
this.state.suggestionsMenuOpen ===
|
||||
SuggestionsMenuType.Emoji
|
||||
}
|
||||
search={this.state.query}
|
||||
onClose={(insertNewLine) =>
|
||||
this.handleCloseSuggestionsMenu(
|
||||
SuggestionsMenuType.Emoji,
|
||||
insertNewLine
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{this.nodes.mention && (
|
||||
<MentionMenu
|
||||
rtl={isRTL}
|
||||
isActive={
|
||||
this.state.suggestionsMenuOpen ===
|
||||
SuggestionsMenuType.Mention
|
||||
}
|
||||
search={this.state.query}
|
||||
onClose={(insertNewLine) =>
|
||||
this.handleCloseSuggestionsMenu(
|
||||
SuggestionsMenuType.Mention,
|
||||
insertNewLine
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<BlockMenu
|
||||
rtl={isRTL}
|
||||
isActive={
|
||||
this.state.suggestionsMenuOpen === SuggestionsMenuType.Block
|
||||
}
|
||||
search={this.state.query}
|
||||
onClose={(insertNewLine) =>
|
||||
this.handleCloseSuggestionsMenu(
|
||||
SuggestionsMenuType.Block,
|
||||
insertNewLine
|
||||
)
|
||||
}
|
||||
uploadFile={this.props.uploadFile}
|
||||
onLinkToolbarOpen={this.handleOpenLinkToolbar}
|
||||
onFileUploadStart={this.props.onFileUploadStart}
|
||||
onFileUploadStop={this.props.onFileUploadStop}
|
||||
embeds={this.props.embeds}
|
||||
/>
|
||||
</>
|
||||
{!readOnly && this.view && this.marks.link && (
|
||||
<LinkToolbar
|
||||
isActive={this.state.linkToolbarOpen}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onClose={this.handleCloseLinkToolbar}
|
||||
/>
|
||||
)}
|
||||
{this.widgets &&
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget key={String(index)} rtl={isRTL} />
|
||||
))}
|
||||
</Flex>
|
||||
</EditorContext.Provider>
|
||||
</PortalContext.Provider>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
StarredIcon,
|
||||
WarningIcon,
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
AttachmentIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
@@ -95,13 +94,6 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
icon: <ImageIcon />,
|
||||
keywords: "picture photo",
|
||||
},
|
||||
{
|
||||
name: "link",
|
||||
title: dictionary.link,
|
||||
icon: <LinkIcon />,
|
||||
shortcut: `${metaDisplay} k`,
|
||||
keywords: "link url uri href",
|
||||
},
|
||||
{
|
||||
name: "video",
|
||||
title: dictionary.video,
|
||||
|
||||
@@ -2,8 +2,14 @@ import * as React from "react";
|
||||
import { basicExtensions, withComments } from "@shared/editor/nodes";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import type { Editor as SharedEditor } from "~/editor";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
|
||||
|
||||
const extensions = withComments(basicExtensions);
|
||||
const extensions = [
|
||||
...withComments(basicExtensions),
|
||||
EmojiMenuExtension,
|
||||
MentionMenuExtension,
|
||||
];
|
||||
|
||||
const CommentEditor = (
|
||||
props: EditorProps,
|
||||
|
||||
@@ -8,8 +8,14 @@ import { TeamPreference } from "@shared/types";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
@@ -20,14 +26,20 @@ import {
|
||||
documentPath,
|
||||
matchDocumentHistory,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { useDocumentContext } from "../../../components/DocumentContext";
|
||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||
import DocumentMeta from "./DocumentMeta";
|
||||
import DocumentTitle from "./DocumentTitle";
|
||||
|
||||
const extensions = withComments(richExtensions);
|
||||
const extensions = [
|
||||
...withComments(richExtensions),
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
MentionMenuExtension,
|
||||
FindAndReplaceExtension,
|
||||
HoverPreviewsExtension,
|
||||
];
|
||||
|
||||
type Props = Omit<EditorProps, "extensions" | "editorStyle"> & {
|
||||
type Props = Omit<EditorProps, "editorStyle"> & {
|
||||
onChangeTitle: (title: string) => void;
|
||||
onChangeEmoji: (emoji: string | null) => void;
|
||||
id: string;
|
||||
|
||||
@@ -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