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:
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
8
app/editor/components/EditorContext.tsx
Normal file
8
app/editor/components/EditorContext.tsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
2
shared/editor/packages/README.md
Normal file
2
shared/editor/packages/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
Packages are preselected collections of extensions that form the different types
|
||||
of editors within Outline.
|
||||
44
shared/editor/packages/basic.ts
Normal file
44
shared/editor/packages/basic.ts
Normal 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;
|
||||
52
shared/editor/packages/full.ts
Normal file
52
shared/editor/packages/full.ts
Normal 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;
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
29
shared/utils/events.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user