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

@@ -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

View File

@@ -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>
);

View File

@@ -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);

View File

@@ -64,7 +64,6 @@ function NotificationListItem({ notification, onNavigate }: Props) {
{notification.comment && (
<StyledCommentEditor
defaultValue={toJS(notification.comment.data)}
previewsDisabled
/>
)}
</Flex>

View File

@@ -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();

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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(() => {

View File

@@ -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}
/>
);
};
}

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

@@ -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 = "";

View File

@@ -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;
})}
/>
);
}

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;
})
),
];
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}),
];
}

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -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,
];
/**

View File

@@ -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;
}
})
);
});
}