chore: Editor refactor (#3286)

* cleanup

* add context

* EventEmitter allows removal of toolbar props from extensions

* Move to 'packages' of extensions
Remove EmojiTrigger extension

* types

* iteration

* fix render flashing

* fix: Missing nodes in collection descriptions
This commit is contained in:
Tom Moor
2022-03-30 19:10:34 -07:00
committed by GitHub
parent c5b9a742c0
commit 6f2a4488e8
30 changed files with 517 additions and 581 deletions

View File

@@ -3,7 +3,7 @@ import { Optional } from "utility-types";
import embeds from "@shared/editor/embeds";
import { isInternalUrl } from "@shared/utils/urls";
import ErrorBoundary from "~/components/ErrorBoundary";
import { Props as EditorProps } from "~/editor";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useToasts from "~/hooks/useToasts";
import { uploadFile } from "~/utils/files";
@@ -11,7 +11,7 @@ import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { isHash } from "~/utils/urls";
const SharedEditor = React.lazy(
const LazyLoadedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "shared-editor" */
@@ -27,6 +27,7 @@ export type Props = Optional<
| "embeds"
| "dictionary"
| "onShowToast"
| "extensions"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
@@ -35,7 +36,7 @@ export type Props = Optional<
onPublish?: (event: React.MouseEvent) => any;
};
function Editor(props: Props, ref: React.Ref<any>) {
function Editor(props: Props, ref: React.Ref<SharedEditor>) {
const { id, shareId } = props;
const { showToast } = useToasts();
const dictionary = useDictionary();
@@ -84,19 +85,12 @@ function Editor(props: Props, ref: React.Ref<any>) {
[shareId]
);
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
},
[showToast]
);
return (
<ErrorBoundary reloadOnChunkMissing>
<SharedEditor
<LazyLoadedEditor
ref={ref}
uploadFile={onUploadFile}
onShowToast={onShowToast}
onShowToast={showToast}
embeds={embeds}
dictionary={dictionary}
{...props}

View File

@@ -25,17 +25,15 @@ function BlockMenu(props: BlockMenuProps) {
{...props}
filterable={true}
onClearSearch={clearSearch}
renderMenuItem={(item, _index, options) => {
return (
<BlockMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
);
}}
renderMenuItem={(item, _index, options) => (
<BlockMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
)}
items={getMenuItems(props.dictionary)}
/>
);

View File

@@ -9,7 +9,7 @@ import styled from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles";
import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { EmbedDescriptor, MenuItem, ToastType } from "@shared/editor/types";
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
@@ -31,7 +31,7 @@ export type Props<T extends MenuItem = MenuItem> = {
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onShowToast: (message: string, id: string) => void;
onShowToast: (message: string) => void;
onLinkToolbarOpen?: () => void;
onClose: () => void;
onClearSearch: () => void;
@@ -216,10 +216,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const matches = this.state.insertItem.matcher(href);
if (!matches) {
this.props.onShowToast(
this.props.dictionary.embedInvalidLink,
ToastType.Error
);
this.props.onShowToast(this.props.dictionary.embedInvalidLink);
return;
}

View File

@@ -0,0 +1,8 @@
import * as React from "react";
import { Editor } from "../";
const EditorContext = React.createContext<Editor>({} as Editor);
export const useEditor = () => React.useContext(EditorContext);
export default EditorContext;

View File

@@ -64,23 +64,22 @@ class EmojiMenu extends React.Component<
};
render() {
const containerId = "emoji-menu-container";
return (
<CommandMenu
{...this.props}
id="emoji-menu-container"
id={containerId}
filterable={false}
onClearSearch={this.clearSearch}
renderMenuItem={(item, _index, options) => {
return (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
containerId="emoji-menu-container"
/>
);
}}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
containerId={containerId}
/>
)}
items={this.items}
/>
);

View File

@@ -15,6 +15,7 @@ import isUrl from "@shared/editor/lib/isUrl";
import { isInternalUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import { ToastOptions } from "~/types";
import Input from "./Input";
import LinkSearchResult from "./LinkSearchResult";
import ToolbarButton from "./ToolbarButton";
@@ -44,7 +45,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast: (message: string, code: string) => void;
onShowToast: (message: string, options: ToastOptions) => void;
view: EditorView;
};

View File

@@ -15,7 +15,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast: (msg: string, code: string) => void;
onShowToast: (message: string) => void;
onClose: () => void;
};

View File

@@ -36,7 +36,7 @@ type Props = {
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
onShowToast: (msg: string, code: string) => void;
onShowToast: (message: string) => void;
view: EditorView;
};

View File

@@ -1,15 +1,12 @@
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import { CommandFactory } from "@shared/editor/lib/Extension";
import { MenuItem } from "@shared/editor/types";
import { useEditor } from "./EditorContext";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
import Tooltip from "./Tooltip";
type Props = {
commands: Record<string, CommandFactory>;
view: EditorView;
items: MenuItem[];
};
@@ -20,7 +17,8 @@ const FlexibleWrapper = styled.div`
`;
function ToolbarMenu(props: Props) {
const { view, items } = props;
const { commands, view } = useEditor();
const { items } = props;
const { state } = view;
return (
@@ -38,7 +36,7 @@ function ToolbarMenu(props: Props) {
return (
<Tooltip tooltip={item.tooltip} key={index}>
<ToolbarButton
onClick={() => item.name && props.commands[item.name](item.attrs)}
onClick={() => item.name && commands[item.name](item.attrs)}
active={isActive}
>
<Icon color="currentColor" />

View File

@@ -6,9 +6,13 @@ import { gapCursor } from "prosemirror-gapcursor";
import { inputRules, InputRule } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap";
import { MarkdownParser } from "prosemirror-markdown";
import { Schema, NodeSpec, MarkSpec, Node } from "prosemirror-model";
import {
Schema,
NodeSpec,
MarkSpec,
Node as ProsemirrorNode,
} from "prosemirror-model";
import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
import { selectColumn, selectRow, selectTable } from "prosemirror-utils";
import { Decoration, EditorView } from "prosemirror-view";
import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components";
@@ -16,59 +20,17 @@ import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import headingToSlug from "@shared/editor/lib/headingToSlug";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
// marks
import Bold from "@shared/editor/marks/Bold";
import Code from "@shared/editor/marks/Code";
import Highlight from "@shared/editor/marks/Highlight";
import Italic from "@shared/editor/marks/Italic";
import Link from "@shared/editor/marks/Link";
import TemplatePlaceholder from "@shared/editor/marks/Placeholder";
import Strikethrough from "@shared/editor/marks/Strikethrough";
import Underline from "@shared/editor/marks/Underline";
// nodes
import Attachment from "@shared/editor/nodes/Attachment";
import Blockquote from "@shared/editor/nodes/Blockquote";
import BulletList from "@shared/editor/nodes/BulletList";
import CheckboxItem from "@shared/editor/nodes/CheckboxItem";
import CheckboxList from "@shared/editor/nodes/CheckboxList";
import CodeBlock from "@shared/editor/nodes/CodeBlock";
import CodeFence from "@shared/editor/nodes/CodeFence";
import Doc from "@shared/editor/nodes/Doc";
import Embed from "@shared/editor/nodes/Embed";
import Emoji from "@shared/editor/nodes/Emoji";
import HardBreak from "@shared/editor/nodes/HardBreak";
import Heading from "@shared/editor/nodes/Heading";
import HorizontalRule from "@shared/editor/nodes/HorizontalRule";
import Image from "@shared/editor/nodes/Image";
import ListItem from "@shared/editor/nodes/ListItem";
import Notice from "@shared/editor/nodes/Notice";
import OrderedList from "@shared/editor/nodes/OrderedList";
import Paragraph from "@shared/editor/nodes/Paragraph";
import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import Table from "@shared/editor/nodes/Table";
import TableCell from "@shared/editor/nodes/TableCell";
import TableHeadCell from "@shared/editor/nodes/TableHeadCell";
import TableRow from "@shared/editor/nodes/TableRow";
import Text from "@shared/editor/nodes/Text";
// plugins
import BlockMenuTrigger from "@shared/editor/plugins/BlockMenuTrigger";
import EmojiTrigger from "@shared/editor/plugins/EmojiTrigger";
import Folding from "@shared/editor/plugins/Folding";
import History from "@shared/editor/plugins/History";
import Keys from "@shared/editor/plugins/Keys";
import MaxLength from "@shared/editor/plugins/MaxLength";
import PasteHandler from "@shared/editor/plugins/PasteHandler";
import Placeholder from "@shared/editor/plugins/Placeholder";
import SmartText from "@shared/editor/plugins/SmartText";
import TrailingNode from "@shared/editor/plugins/TrailingNode";
import { EmbedDescriptor, ToastType } from "@shared/editor/types";
import fullExtensionsPackage from "@shared/editor/packages/full";
import { EmbedDescriptor, EventType } from "@shared/editor/types";
import EventEmitter from "@shared/utils/events";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import BlockMenu from "./components/BlockMenu";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import EmojiMenu from "./components/EmojiMenu";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
@@ -87,8 +49,8 @@ export type Props = {
defaultValue: string;
/** Placeholder displayed when the editor is empty */
placeholder: string;
/** Additional extensions to load into the editor */
extensions?: Extension[];
/** Extensions to load into the editor */
extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[];
/** If the editor should be focused on mount */
autoFocus?: boolean;
/** If the editor should not allow editing */
@@ -143,7 +105,7 @@ export type Props = {
/** Whether embeds should be rendered without an iframe */
embedsDisabled?: boolean;
/** Callback when a toast message is triggered (eg "link copied") */
onShowToast: (message: string, code: ToastType) => void;
onShowToast: (message: string) => void;
className?: string;
style?: React.CSSProperties;
};
@@ -185,7 +147,7 @@ export class Editor extends React.PureComponent<
// no default behavior
},
embeds: [],
extensions: [],
extensions: fullExtensionsPackage,
};
state = {
@@ -200,7 +162,7 @@ export class Editor extends React.PureComponent<
isBlurred: boolean;
extensions: ExtensionManager;
element?: HTMLElement | null;
element = React.createRef<HTMLDivElement>();
view: EditorView;
schema: Schema;
serializer: MarkdownSerializer;
@@ -211,7 +173,7 @@ export class Editor extends React.PureComponent<
inputRules: InputRule[];
nodeViews: {
[name: string]: (
node: Node,
node: ProsemirrorNode,
view: EditorView,
getPos: () => number,
decorations: Decoration<{
@@ -224,8 +186,23 @@ export class Editor extends React.PureComponent<
marks: { [name: string]: MarkSpec };
commands: Record<string, CommandFactory>;
rulePlugins: PluginSimple[];
events = new EventEmitter();
componentDidMount() {
public constructor(props: Props & ThemeProps<DefaultTheme>) {
super(props);
this.events.on(EventType.linkMenuOpen, this.handleOpenLinkMenu);
this.events.on(EventType.linkMenuClose, this.handleCloseLinkMenu);
this.events.on(EventType.blockMenuOpen, this.handleOpenBlockMenu);
this.events.on(EventType.blockMenuClose, this.handleCloseBlockMenu);
this.events.on(EventType.emojiMenuOpen, this.handleOpenEmojiMenu);
this.events.on(EventType.emojiMenuClose, this.handleCloseEmojiMenu);
}
/**
* We use componentDidMount instead of constructor as the init method requires
* that the dom is already mounted.
*/
public componentDidMount() {
this.init();
if (this.props.scrollTo) {
@@ -243,7 +220,7 @@ export class Editor extends React.PureComponent<
}
}
componentDidUpdate(prevProps: Props) {
public componentDidUpdate(prevProps: Props) {
// Allow changes to the 'value' prop to update the editor from outside
if (this.props.value && prevProps.value !== this.props.value) {
const newState = this.createState(this.props.value);
@@ -280,9 +257,7 @@ export class Editor extends React.PureComponent<
!this.state.selectionMenuOpen
) {
this.isBlurred = true;
if (this.props.onBlur) {
this.props.onBlur();
}
this.props.onBlur?.();
}
if (
@@ -293,13 +268,11 @@ export class Editor extends React.PureComponent<
this.state.selectionMenuOpen)
) {
this.isBlurred = false;
if (this.props.onFocus) {
this.props.onFocus();
}
this.props.onFocus?.();
}
}
init() {
private init() {
this.extensions = this.createExtensions();
this.nodes = this.createNodes();
this.marks = this.createMarks();
@@ -316,138 +289,36 @@ export class Editor extends React.PureComponent<
this.commands = this.createCommands();
}
createExtensions() {
const { dictionary } = this.props;
// adding nodes here? Update server/editor/renderToHtml.ts for serialization
// on the server
return new ExtensionManager(
[
...[
new Doc(),
new HardBreak(),
new Paragraph(),
new Blockquote(),
new CodeBlock({
dictionary,
onShowToast: this.props.onShowToast,
}),
new CodeFence({
dictionary,
onShowToast: this.props.onShowToast,
}),
new Emoji(),
new Text(),
new CheckboxList(),
new CheckboxItem(),
new BulletList(),
new Embed({ embeds: this.props.embeds }),
new ListItem(),
new Attachment({
dictionary,
}),
new Notice({
dictionary,
}),
new Heading({
dictionary,
onShowToast: this.props.onShowToast,
}),
new HorizontalRule(),
new Image({
dictionary,
uploadFile: this.props.uploadFile,
onFileUploadStart: this.props.onFileUploadStart,
onFileUploadStop: this.props.onFileUploadStop,
onShowToast: this.props.onShowToast,
}),
new Table(),
new TableCell({
onSelectTable: this.handleSelectTable,
onSelectRow: this.handleSelectRow,
}),
new TableHeadCell({
onSelectColumn: this.handleSelectColumn,
}),
new TableRow(),
new Bold(),
new Code(),
new Highlight(),
new Italic(),
new TemplatePlaceholder(),
new Underline(),
new Link({
onKeyboardShortcut: this.handleOpenLinkMenu,
onClickLink: this.props.onClickLink,
onClickHashtag: this.props.onClickHashtag,
onHoverLink: this.props.onHoverLink,
}),
new Strikethrough(),
new OrderedList(),
new History(),
new Folding(),
new SmartText(),
new TrailingNode(),
new PasteHandler(),
new Keys({
onBlur: this.handleEditorBlur,
onFocus: this.handleEditorFocus,
onSave: this.handleSave,
onSaveAndExit: this.handleSaveAndExit,
onCancel: this.props.onCancel,
}),
new BlockMenuTrigger({
dictionary,
onOpen: this.handleOpenBlockMenu,
onClose: this.handleCloseBlockMenu,
}),
new EmojiTrigger({
onOpen: (search: string) => {
this.setState({ emojiMenuOpen: true, blockMenuSearch: search });
},
onClose: () => {
this.setState({ emojiMenuOpen: false });
},
}),
new Placeholder({
placeholder: this.props.placeholder,
}),
new MaxLength({
maxLength: this.props.maxLength,
}),
],
...(this.props.extensions || []),
],
this
);
private createExtensions() {
return new ExtensionManager(this.props.extensions, this);
}
createPlugins() {
private createPlugins() {
return this.extensions.plugins;
}
createRulePlugins() {
private createRulePlugins() {
return this.extensions.rulePlugins;
}
createKeymaps() {
private createKeymaps() {
return this.extensions.keymaps({
schema: this.schema,
});
}
createInputRules() {
private createInputRules() {
return this.extensions.inputRules({
schema: this.schema,
});
}
createNodeViews() {
private createNodeViews() {
return this.extensions.extensions
.filter((extension: ReactNode) => extension.component)
.reduce((nodeViews, extension: ReactNode) => {
const nodeView = (
node: Node,
node: ProsemirrorNode,
view: EditorView,
getPos: () => number,
decorations: Decoration<{
@@ -471,40 +342,40 @@ export class Editor extends React.PureComponent<
}, {});
}
createCommands() {
private createCommands() {
return this.extensions.commands({
schema: this.schema,
view: this.view,
});
}
createNodes() {
private createNodes() {
return this.extensions.nodes;
}
createMarks() {
private createMarks() {
return this.extensions.marks;
}
createSchema() {
private createSchema() {
return new Schema({
nodes: this.nodes,
marks: this.marks,
});
}
createSerializer() {
private createSerializer() {
return this.extensions.serializer();
}
createParser() {
private createParser() {
return this.extensions.parser({
schema: this.schema,
plugins: this.rulePlugins,
});
}
createPasteParser() {
private createPasteParser() {
return this.extensions.parser({
schema: this.schema,
rules: { linkify: true, emoji: false },
@@ -512,7 +383,7 @@ export class Editor extends React.PureComponent<
});
}
createState(value?: string) {
private createState(value?: string) {
const doc = this.createDocument(value || this.props.defaultValue);
return EditorState.create({
@@ -531,12 +402,12 @@ export class Editor extends React.PureComponent<
});
}
createDocument(content: string) {
private createDocument(content: string) {
return this.parser.parse(content);
}
createView() {
if (!this.element) {
private createView() {
if (!this.element.current) {
throw new Error("createView called before ref available");
}
@@ -549,7 +420,11 @@ export class Editor extends React.PureComponent<
};
const self = this; // eslint-disable-line
const view = new EditorView(this.element, {
const view = new EditorView(this.element.current, {
handleDOMEvents: {
blur: this.handleEditorBlur,
focus: this.handleEditorFocus,
},
state: this.createState(this.props.value),
editable: () => !this.props.readOnly,
nodeViews: this.nodeViews,
@@ -587,7 +462,7 @@ export class Editor extends React.PureComponent<
return view;
}
scrollToAnchor(hash: string) {
public scrollToAnchor(hash: string) {
if (!hash) {
return;
}
@@ -605,25 +480,25 @@ export class Editor extends React.PureComponent<
}
}
calculateDir = () => {
if (!this.element) {
private calculateDir = () => {
if (!this.element.current) {
return;
}
const isRTL =
this.props.dir === "rtl" ||
getComputedStyle(this.element).direction === "rtl";
getComputedStyle(this.element.current).direction === "rtl";
if (this.state.isRTL !== isRTL) {
this.setState({ isRTL });
}
};
value = (): string => {
public value = (): string => {
return this.serializer.serialize(this.view.state.doc);
};
handleChange = () => {
private handleChange = () => {
if (!this.props.onChange) {
return;
}
@@ -633,83 +508,72 @@ export class Editor extends React.PureComponent<
});
};
handleSave = () => {
const { onSave } = this.props;
if (onSave) {
onSave({ done: false });
}
};
handleSaveAndExit = () => {
const { onSave } = this.props;
if (onSave) {
onSave({ done: true });
}
};
handleEditorBlur = () => {
private handleEditorBlur = () => {
this.setState({ isEditorFocused: false });
return false;
};
handleEditorFocus = () => {
private handleEditorFocus = () => {
this.setState({ isEditorFocused: true });
return false;
};
handleOpenSelectionMenu = () => {
private handleOpenSelectionMenu = () => {
this.setState({ blockMenuOpen: false, selectionMenuOpen: true });
};
handleCloseSelectionMenu = () => {
private handleCloseSelectionMenu = () => {
if (!this.state.selectionMenuOpen) {
return;
}
this.setState({ selectionMenuOpen: false });
};
handleOpenLinkMenu = () => {
private handleOpenEmojiMenu = (search: string) => {
this.setState({ emojiMenuOpen: true, blockMenuSearch: search });
};
private handleCloseEmojiMenu = () => {
if (!this.state.emojiMenuOpen) {
return;
}
this.setState({ emojiMenuOpen: false });
};
private handleOpenLinkMenu = () => {
this.setState({ blockMenuOpen: false, linkMenuOpen: true });
};
handleCloseLinkMenu = () => {
private handleCloseLinkMenu = () => {
this.setState({ linkMenuOpen: false });
};
handleOpenBlockMenu = (search: string) => {
private handleOpenBlockMenu = (search: string) => {
this.setState({ blockMenuOpen: true, blockMenuSearch: search });
};
handleCloseBlockMenu = () => {
private handleCloseBlockMenu = () => {
if (!this.state.blockMenuOpen) {
return;
}
this.setState({ blockMenuOpen: false });
};
handleSelectRow = (index: number, state: EditorState) => {
this.view.dispatch(selectRow(index)(state.tr));
};
handleSelectColumn = (index: number, state: EditorState) => {
this.view.dispatch(selectColumn(index)(state.tr));
};
handleSelectTable = (state: EditorState) => {
this.view.dispatch(selectTable(state.tr));
};
// 'public' methods
focusAtStart = () => {
public focusAtStart = () => {
const selection = Selection.atStart(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction);
this.view.focus();
};
focusAtEnd = () => {
public focusAtEnd = () => {
const selection = Selection.atEnd(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction);
this.view.focus();
};
getHeadings = () => {
public getHeadings = () => {
const headings: { title: string; level: number; id: string }[] = [];
const previouslySeen = {};
@@ -740,7 +604,7 @@ export class Editor extends React.PureComponent<
return headings;
};
render() {
public render() {
const {
dir,
readOnly,
@@ -754,86 +618,90 @@ export class Editor extends React.PureComponent<
const { isRTL } = this.state;
return (
<Flex
onKeyDown={onKeyDown}
style={style}
className={className}
align="flex-start"
justify="center"
dir={dir}
column
>
<EditorContainer
<EditorContext.Provider value={this}>
<Flex
onKeyDown={onKeyDown}
style={style}
className={className}
align="flex-start"
justify="center"
dir={dir}
rtl={isRTL}
grow={grow}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
ref={(ref) => (this.element = ref)}
/>
{!readOnly && this.view && (
<React.Fragment>
<SelectionToolbar
view={this.view}
dictionary={dictionary}
commands={this.commands}
rtl={isRTL}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionMenu}
onClose={this.handleCloseSelectionMenu}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
onShowToast={this.props.onShowToast}
/>
<LinkToolbar
view={this.view}
dictionary={dictionary}
isActive={this.state.linkMenuOpen}
onCreateLink={this.props.onCreateLink}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onShowToast={this.props.onShowToast}
onClose={this.handleCloseLinkMenu}
/>
<EmojiMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
onShowToast={this.props.onShowToast}
isActive={this.state.emojiMenuOpen}
search={this.state.blockMenuSearch}
onClose={() => this.setState({ emojiMenuOpen: false })}
/>
<BlockMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
isActive={this.state.blockMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseBlockMenu}
uploadFile={this.props.uploadFile}
onLinkToolbarOpen={this.handleOpenLinkMenu}
onFileUploadStart={this.props.onFileUploadStart}
onFileUploadStop={this.props.onFileUploadStop}
onShowToast={this.props.onShowToast}
embeds={this.props.embeds}
/>
</React.Fragment>
)}
</Flex>
column
>
<EditorContainer
dir={dir}
rtl={isRTL}
grow={grow}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
ref={this.element}
/>
{!readOnly && this.view && (
<>
<SelectionToolbar
view={this.view}
dictionary={dictionary}
commands={this.commands}
rtl={isRTL}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionMenu}
onClose={this.handleCloseSelectionMenu}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
onShowToast={this.props.onShowToast}
/>
<LinkToolbar
view={this.view}
dictionary={dictionary}
isActive={this.state.linkMenuOpen}
onCreateLink={this.props.onCreateLink}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onShowToast={this.props.onShowToast}
onClose={this.handleCloseLinkMenu}
/>
<EmojiMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
onShowToast={this.props.onShowToast}
isActive={this.state.emojiMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseEmojiMenu}
/>
<BlockMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
isActive={this.state.blockMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseBlockMenu}
uploadFile={this.props.uploadFile}
onLinkToolbarOpen={this.handleOpenLinkMenu}
onFileUploadStart={this.props.onFileUploadStart}
onFileUploadStop={this.props.onFileUploadStop}
onShowToast={this.props.onShowToast}
embeds={this.props.embeds}
/>
</>
)}
</Flex>
</EditorContext.Provider>
);
}
}
const EditorWithTheme = React.forwardRef<Editor, Props>((props: Props, ref) => {
return (
<WithTheme>
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
</WithTheme>
);
});
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
(props: Props, ref) => {
return (
<WithTheme>
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
</WithTheme>
);
}
);
export default EditorWithTheme;
export default LazyLoadedEditor;

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useRouteMatch } from "react-router-dom";
import fullPackage from "@shared/editor/packages/full";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
@@ -16,7 +17,7 @@ import {
import MultiplayerEditor from "./AsyncMultiplayerEditor";
import EditableTitle from "./EditableTitle";
type Props = EditorProps & {
type Props = Omit<EditorProps, "extensions"> & {
onChangeTitle: (text: string) => void;
title: string;
id: string;
@@ -127,6 +128,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
scrollTo={window.location.hash}
readOnly={readOnly}
shareId={shareId}
extensions={fullPackage}
grow
{...rest}
/>

View File

@@ -188,17 +188,18 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const extensions = React.useMemo(() => {
if (!remoteProvider) {
return [];
return props.extensions;
}
return [
...(props.extensions || []),
new MultiplayerExtension({
user,
provider: remoteProvider,
document: ydoc,
}),
];
}, [remoteProvider, user, ydoc]);
}, [remoteProvider, user, ydoc, props.extensions]);
React.useEffect(() => {
if (isLocalSynced && isRemoteSynced) {
@@ -251,17 +252,23 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
return () => window.removeEventListener("error", onUnhandledError);
}, [showToast, t]);
if (!extensions.length) {
if (!remoteProvider) {
return null;
}
// while the collaborative document is loading, we render a version of the
// document from the last text cache in read-only mode if we have it.
const showCache = !isLocalSynced && !isRemoteSynced;
return (
<>
{showCache && (
<Editor defaultValue={props.defaultValue} readOnly ref={ref} />
<Editor
defaultValue={props.defaultValue}
extensions={props.extensions}
readOnly
ref={ref}
/>
)}
<Editor
{...props}

View File

@@ -1,75 +1,9 @@
import { Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
// marks
import Bold from "@shared/editor/marks/Bold";
import Code from "@shared/editor/marks/Code";
import Highlight from "@shared/editor/marks/Highlight";
import Italic from "@shared/editor/marks/Italic";
import Link from "@shared/editor/marks/Link";
import TemplatePlaceholder from "@shared/editor/marks/Placeholder";
import Strikethrough from "@shared/editor/marks/Strikethrough";
import Underline from "@shared/editor/marks/Underline";
// nodes
import Attachment from "@shared/editor/nodes/Attachment";
import Blockquote from "@shared/editor/nodes/Blockquote";
import BulletList from "@shared/editor/nodes/BulletList";
import CheckboxItem from "@shared/editor/nodes/CheckboxItem";
import CheckboxList from "@shared/editor/nodes/CheckboxList";
import CodeBlock from "@shared/editor/nodes/CodeBlock";
import CodeFence from "@shared/editor/nodes/CodeFence";
import Doc from "@shared/editor/nodes/Doc";
import Embed from "@shared/editor/nodes/Embed";
import Emoji from "@shared/editor/nodes/Emoji";
import HardBreak from "@shared/editor/nodes/HardBreak";
import Heading from "@shared/editor/nodes/Heading";
import HorizontalRule from "@shared/editor/nodes/HorizontalRule";
import Image from "@shared/editor/nodes/Image";
import ListItem from "@shared/editor/nodes/ListItem";
import Notice from "@shared/editor/nodes/Notice";
import OrderedList from "@shared/editor/nodes/OrderedList";
import Paragraph from "@shared/editor/nodes/Paragraph";
import Table from "@shared/editor/nodes/Table";
import TableCell from "@shared/editor/nodes/TableCell";
import TableHeadCell from "@shared/editor/nodes/TableHeadCell";
import TableRow from "@shared/editor/nodes/TableRow";
import Text from "@shared/editor/nodes/Text";
import fullPackage from "@shared/editor/packages/full";
import render from "./renderToHtml";
const extensions = new ExtensionManager([
new Doc(),
new Text(),
new HardBreak(),
new Paragraph(),
new Blockquote(),
new Emoji(),
new BulletList(),
new CodeBlock(),
new CodeFence(),
new CheckboxList(),
new CheckboxItem(),
new Embed(),
new ListItem(),
new Notice(),
new Attachment(),
new Heading(),
new HorizontalRule(),
new Image(),
new Table(),
new TableCell(),
new TableHeadCell(),
new TableRow(),
new Bold(),
new Code(),
new Highlight(),
new Italic(),
new Link(),
new Strikethrough(),
new TemplatePlaceholder(),
new Underline(),
new OrderedList(),
]);
const extensions = new ExtensionManager(fullPackage);
export const schema = new Schema({
nodes: extensions.nodes,

View File

@@ -1,6 +1,5 @@
import { Node } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { ToastType } from "../types";
function findPlaceholderLink(doc: Node, href: string) {
let result: { pos: number; node: Node } | undefined;
@@ -38,7 +37,7 @@ const createAndInsertLink = async function (
options: {
dictionary: any;
onCreateLink: (title: string) => Promise<string>;
onShowToast: (message: string, code: string) => void;
onShowToast: (message: string) => void;
}
) {
const { dispatch, state } = view;
@@ -79,10 +78,7 @@ const createAndInsertLink = async function (
)
);
// let the user know
if (onShowToast) {
onShowToast(options.dictionary.createLinkError, ToastType.Error);
}
onShowToast(options.dictionary.createLinkError);
}
};

View File

@@ -6,7 +6,6 @@ import uploadPlaceholderPlugin, {
findPlaceholder,
} from "../lib/uploadPlaceholder";
import findAttachmentById from "../queries/findAttachmentById";
import { ToastType } from "../types";
export type Options = {
dictionary: any;
@@ -17,7 +16,7 @@ export type Options = {
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onShowToast: (message: string, code: string) => void;
onShowToast: (message: string) => void;
};
const insertFiles = function (
@@ -187,10 +186,7 @@ const insertFiles = function (
view.dispatch(view.state.tr.deleteRange(from, to || from));
}
onShowToast(
error.message || dictionary.fileUploadError,
ToastType.Error
);
onShowToast(error.message || dictionary.fileUploadError);
})
.finally(() => {
complete++;

View File

@@ -3,6 +3,7 @@ import { keymap } from "prosemirror-keymap";
import { MarkdownParser, TokenConfig } from "prosemirror-markdown";
import { Schema } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { Editor } from "~/editor";
import Mark from "../marks/Mark";
import Node from "../nodes/Node";
import Extension, { CommandFactory } from "./Extension";
@@ -10,16 +11,32 @@ import makeRules from "./markdown/rules";
import { MarkdownSerializer } from "./markdown/serializer";
export default class ExtensionManager {
extensions: (Node | Mark | Extension)[];
extensions: (Node | Mark | Extension)[] = [];
constructor(extensions: (Node | Mark | Extension)[] = [], editor?: any) {
if (editor) {
extensions.forEach((extension) => {
constructor(
extensions: (
| Extension
| typeof Node
| typeof Mark
| typeof Extension
)[] = [],
editor?: Editor
) {
extensions.forEach((ext) => {
let extension;
if (typeof ext === "function") {
extension = new ext(editor?.props);
} else {
extension = ext;
}
if (editor) {
extension.bindEditor(editor);
});
}
}
this.extensions = extensions;
this.extensions.push(extension);
});
}
get nodes() {

View File

@@ -15,7 +15,7 @@ import * as React from "react";
import ReactDOM from "react-dom";
import { isInternalUrl } from "../../utils/urls";
import findLinkNodes from "../queries/findLinkNodes";
import { Dispatch } from "../types";
import { EventType, Dispatch } from "../types";
import Mark from "./Mark";
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
@@ -106,7 +106,7 @@ export default class Link extends Mark {
return {
"Mod-k": (state: EditorState, dispatch: Dispatch) => {
if (state.selection.empty) {
this.options.onKeyboardShortcut();
this.editor.events.emit(EventType.linkMenuOpen);
return true;
}

View File

@@ -33,12 +33,13 @@ import rust from "refractor/lang/rust";
import sql from "refractor/lang/sql";
import typescript from "refractor/lang/typescript";
import yaml from "refractor/lang/yaml";
import { Dictionary } from "~/hooks/useDictionary";
import toggleBlockType from "../commands/toggleBlockType";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Prism, { LANGUAGES } from "../plugins/Prism";
import isInCode from "../queries/isInCode";
import { Dispatch, ToastType } from "../types";
import { Dispatch } from "../types";
import Node from "./Node";
const PERSISTENCE_KEY = "rme-code-language";
@@ -67,6 +68,13 @@ const DEFAULT_LANGUAGE = "javascript";
].forEach(refractor.register);
export default class CodeFence extends Node {
constructor(options: {
dictionary: Dictionary;
onShowToast: (message: string) => void;
}) {
super(options);
}
get languageOptions() {
return Object.entries(LANGUAGES);
}
@@ -194,10 +202,7 @@ export default class CodeFence extends Node {
const node = view.state.doc.nodeAt(result.pos);
if (node) {
copy(node.textContent);
this.options.onShowToast(
this.options.dictionary.codeCopied,
ToastType.Info
);
this.options.onShowToast(this.options.dictionary.codeCopied);
}
}
};

View File

@@ -2,12 +2,17 @@ import nameToEmoji from "gemoji/name-to-emoji.json";
import Token from "markdown-it/lib/token";
import { InputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";
import { EditorState, TextSelection, Plugin } from "prosemirror-state";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { run } from "../plugins/BlockMenuTrigger";
import isInCode from "../queries/isInCode";
import emojiRule from "../rules/emoji";
import { Dispatch } from "../types";
import { Dispatch, EventType } from "../types";
import Node from "./Node";
const OPEN_REGEX = /(?:^|\s):([0-9a-zA-Z_+-]+)?$/;
const CLOSE_REGEX = /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/;
export default class Emoji extends Node {
get name() {
return "emoji";
@@ -61,6 +66,57 @@ export default class Emoji extends Node {
return [emojiRule];
}
get plugins() {
return [
new Plugin({
props: {
handleClick: () => {
this.editor.events.emit(EventType.emojiMenuClose);
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
// correctly. This additional handler adds inputrules-like handling.
if (event.key === "Backspace") {
// timeout ensures that the delete has been handled by prosemirror
// and any characters removed, before we evaluate the rule.
setTimeout(() => {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
if (match) {
this.editor.events.emit(EventType.emojiMenuOpen, match[1]);
} else {
this.editor.events.emit(EventType.emojiMenuClose);
}
return null;
});
});
}
// If the query is active and we're navigating the block menu then
// just ignore the key events in the editor itself until we're done
if (
event.key === "Enter" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown" ||
event.key === "Tab"
) {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
// just tell Prosemirror we handled it and not to do anything
return match ? true : null;
});
}
return false;
},
},
}),
];
}
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, string>) => (
state: EditorState,
@@ -100,6 +156,29 @@ export default class Emoji extends Node {
return tr;
}),
// main regex should match only:
// :word
new InputRule(OPEN_REGEX, (state, match) => {
if (
match &&
state.selection.$from.parent.type.name === "paragraph" &&
!isInCode(state)
) {
this.editor.events.emit(EventType.emojiMenuOpen, match[1]);
}
return null;
}),
// invert regex should match some of these scenarios:
// :<space>word
// :<space>
// :word<space>
// :)
new InputRule(CLOSE_REGEX, (state, match) => {
if (match) {
this.editor.events.emit(EventType.emojiMenuClose);
}
return null;
}),
];
}

View File

@@ -14,7 +14,6 @@ import toggleBlockType from "../commands/toggleBlockType";
import { Command } from "../lib/Extension";
import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { ToastType } from "../types";
import Node from "./Node";
export default class Heading extends Node {
@@ -180,10 +179,7 @@ export default class Heading extends Node {
const urlWithoutHash = window.location.href.split("#")[0];
copy(urlWithoutHash + hash);
this.options.onShowToast(
this.options.dictionary.linkCopied,
ToastType.Info
);
this.options.onShowToast(this.options.dictionary.linkCopied);
};
keys({ type, schema }: { type: NodeType; schema: Schema }) {

View File

@@ -5,6 +5,8 @@ import {
isTableSelected,
isRowSelected,
getCellsInColumn,
selectRow,
selectTable,
} from "prosemirror-utils";
import { DecorationSet, Decoration } from "prosemirror-view";
import Node from "./Node";
@@ -72,7 +74,7 @@ export default class TableCell extends Node {
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.options.onSelectTable(state);
this.editor.view.dispatch(selectTable(state.tr));
});
return grip;
})
@@ -97,7 +99,7 @@ export default class TableCell extends Node {
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.options.onSelectRow(index, state);
this.editor.view.dispatch(selectRow(index)(state.tr));
});
return grip;
})

View File

@@ -1,7 +1,11 @@
import Token from "markdown-it/lib/token";
import { NodeSpec } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { isColumnSelected, getCellsInRow } from "prosemirror-utils";
import {
isColumnSelected,
getCellsInRow,
selectColumn,
} from "prosemirror-utils";
import { DecorationSet, Decoration } from "prosemirror-view";
import Node from "./Node";
@@ -72,7 +76,7 @@ export default class TableHeadCell extends Node {
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.options.onSelectColumn(index, state);
this.editor.view.dispatch(selectColumn(index)(state.tr));
});
return grip;
})

View File

@@ -0,0 +1,2 @@
Packages are preselected collections of extensions that form the different types
of editors within Outline.

View File

@@ -0,0 +1,44 @@
import Extension from "../lib/Extension";
import Bold from "../marks/Bold";
import Code from "../marks/Code";
import Italic from "../marks/Italic";
import Link from "../marks/Link";
import Mark from "../marks/Mark";
import Strikethrough from "../marks/Strikethrough";
import Underline from "../marks/Underline";
import Doc from "../nodes/Doc";
import Emoji from "../nodes/Emoji";
import HardBreak from "../nodes/HardBreak";
import Image from "../nodes/Image";
import Node from "../nodes/Node";
import Paragraph from "../nodes/Paragraph";
import Text from "../nodes/Text";
import History from "../plugins/History";
import MaxLength from "../plugins/MaxLength";
import PasteHandler from "../plugins/PasteHandler";
import Placeholder from "../plugins/Placeholder";
import SmartText from "../plugins/SmartText";
import TrailingNode from "../plugins/TrailingNode";
const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Doc,
HardBreak,
Paragraph,
Emoji,
Text,
Image,
Bold,
Code,
Italic,
Underline,
Link,
Strikethrough,
History,
SmartText,
TrailingNode,
PasteHandler,
Placeholder,
MaxLength,
];
export default basicPackage;

View File

@@ -0,0 +1,52 @@
import Extension from "../lib/Extension";
import Highlight from "../marks/Highlight";
import Mark from "../marks/Mark";
import TemplatePlaceholder from "../marks/Placeholder";
import Attachment from "../nodes/Attachment";
import BulletList from "../nodes/BulletList";
import CheckboxItem from "../nodes/CheckboxItem";
import CheckboxList from "../nodes/CheckboxList";
import CodeBlock from "../nodes/CodeBlock";
import CodeFence from "../nodes/CodeFence";
import Embed from "../nodes/Embed";
import Heading from "../nodes/Heading";
import HorizontalRule from "../nodes/HorizontalRule";
import ListItem from "../nodes/ListItem";
import Node from "../nodes/Node";
import Notice from "../nodes/Notice";
import OrderedList from "../nodes/OrderedList";
import Table from "../nodes/Table";
import TableCell from "../nodes/TableCell";
import TableHeadCell from "../nodes/TableHeadCell";
import TableRow from "../nodes/TableRow";
import BlockMenuTrigger from "../plugins/BlockMenuTrigger";
import Folding from "../plugins/Folding";
import Keys from "../plugins/Keys";
import basicPackage from "./basic";
const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
...basicPackage,
CodeBlock,
CodeFence,
CheckboxList,
CheckboxItem,
BulletList,
OrderedList,
Embed,
ListItem,
Attachment,
Notice,
Heading,
HorizontalRule,
Table,
TableCell,
TableHeadCell,
TableRow,
Highlight,
TemplatePlaceholder,
Folding,
Keys,
BlockMenuTrigger,
];
export default fullPackage;

View File

@@ -7,6 +7,7 @@ import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import Extension from "../lib/Extension";
import { EventType } from "../types";
const MAX_MATCH = 500;
const OPEN_REGEX = /^\/(\w+)?$/;
@@ -65,7 +66,7 @@ export default class BlockMenuTrigger extends Extension {
new Plugin({
props: {
handleClick: () => {
this.options.onClose();
this.editor.events.emit(EventType.blockMenuClose);
return false;
},
handleKeyDown: (view, event) => {
@@ -79,9 +80,9 @@ export default class BlockMenuTrigger extends Extension {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
if (match) {
this.options.onOpen(match[1]);
this.editor.events.emit(EventType.blockMenuOpen, match[1]);
} else {
this.options.onClose();
this.editor.events.emit(EventType.blockMenuClose);
}
return null;
});
@@ -125,7 +126,7 @@ export default class BlockMenuTrigger extends Extension {
decorations.push(
Decoration.widget(parent.pos, () => {
button.addEventListener("click", () => {
this.options.onOpen("");
this.editor.events.emit(EventType.blockMenuOpen, "");
});
return button;
})
@@ -176,7 +177,7 @@ export default class BlockMenuTrigger extends Extension {
state.selection.$from.parent.type.name === "paragraph" &&
!isInTable(state)
) {
this.options.onOpen(match[1]);
this.editor.events.emit(EventType.blockMenuOpen, match[1]);
}
return null;
}),
@@ -186,7 +187,7 @@ export default class BlockMenuTrigger extends Extension {
// /word<space>
new InputRule(CLOSE_REGEX, (state, match) => {
if (match) {
this.options.onClose();
this.editor.events.emit(EventType.blockMenuClose);
}
return null;
}),

View File

@@ -1,93 +0,0 @@
import { InputRule } from "prosemirror-inputrules";
import { Plugin } from "prosemirror-state";
import Extension from "../lib/Extension";
import isInCode from "../queries/isInCode";
import { run } from "./BlockMenuTrigger";
const OPEN_REGEX = /(?:^|\s):([0-9a-zA-Z_+-]+)?$/;
const CLOSE_REGEX = /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/;
export default class EmojiTrigger extends Extension {
get name() {
return "emojimenu";
}
get plugins() {
return [
new Plugin({
props: {
handleClick: () => {
this.options.onClose();
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
// correctly. This additional handler adds inputrules-like handling.
if (event.key === "Backspace") {
// timeout ensures that the delete has been handled by prosemirror
// and any characters removed, before we evaluate the rule.
setTimeout(() => {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
if (match) {
this.options.onOpen(match[1]);
} else {
this.options.onClose();
}
return null;
});
});
}
// If the query is active and we're navigating the block menu then
// just ignore the key events in the editor itself until we're done
if (
event.key === "Enter" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown" ||
event.key === "Tab"
) {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
// just tell Prosemirror we handled it and not to do anything
return match ? true : null;
});
}
return false;
},
},
}),
];
}
inputRules() {
return [
// main regex should match only:
// :word
new InputRule(OPEN_REGEX, (state, match) => {
if (
match &&
state.selection.$from.parent.type.name === "paragraph" &&
!isInCode(state)
) {
this.options.onOpen(match[1]);
}
return null;
}),
// invert regex should match some of these scenarios:
// :<space>word
// :<space>
// :word<space>
// :)
new InputRule(CLOSE_REGEX, (state, match) => {
if (match) {
this.options.onClose();
}
return null;
}),
];
}
}

View File

@@ -16,8 +16,8 @@ export default class Keys extends Extension {
keys(): Record<string, Command> {
const onCancel = () => {
if (this.options.onCancel) {
this.options.onCancel();
if (this.editor.props.onCancel) {
this.editor.props.onCancel();
return true;
}
return false;
@@ -32,15 +32,15 @@ export default class Keys extends Extension {
"Mod-Escape": onCancel,
"Shift-Escape": onCancel,
"Mod-s": () => {
if (this.options.onSave) {
this.options.onSave();
if (this.editor.props.onSave) {
this.editor.props.onSave({ done: false });
return true;
}
return false;
},
"Mod-Enter": (state: EditorState) => {
if (!isInCode(state) && this.options.onSaveAndExit) {
this.options.onSaveAndExit();
if (!isInCode(state) && this.editor.props.onSave) {
this.editor.props.onSave({ done: true });
return true;
}
return false;
@@ -52,10 +52,6 @@ export default class Keys extends Extension {
return [
new Plugin({
props: {
handleDOMEvents: {
blur: this.options.onBlur,
focus: this.options.onFocus,
},
// we can't use the keys bindings for this as we want to preventDefault
// on the original keyboard event when handled
handleKeyDown: (view, event) => {

View File

@@ -3,9 +3,13 @@ import { EditorState, Transaction } from "prosemirror-state";
import * as React from "react";
import { DefaultTheme } from "styled-components";
export enum ToastType {
Error = "error",
Info = "info",
export enum EventType {
blockMenuOpen = "blockMenuOpen",
blockMenuClose = "blockMenuClose",
emojiMenuOpen = "emojiMenuOpen",
emojiMenuClose = "emojiMenuClose",
linkMenuOpen = "linkMenuOpen",
linkMenuClose = "linkMenuClose",
}
export type MenuItem = {

29
shared/utils/events.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* A tiny EventEmitter implementation for the browser.
*/
export default class EventEmitter {
private listeners: { [name: string]: ((data: any) => unknown)[] } = {};
public addListener(name: string, callback: (data: any) => unknown) {
if (!this.listeners[name]) {
this.listeners[name] = [];
}
this.listeners[name].push(callback);
}
public removeListener(name: string, callback: (data: any) => unknown) {
this.listeners[name] = this.listeners[name]?.filter(
(cb) => cb !== callback
);
}
public on = this.addListener;
public off = this.removeListener;
public emit(name: string, data?: any) {
this.listeners[name]?.forEach((callback) => {
callback(data);
});
}
}