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

View File

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

View File

@@ -9,7 +9,7 @@ import styled from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles"; import insertFiles from "@shared/editor/commands/insertFiles";
import { CommandFactory } from "@shared/editor/lib/Extension"; import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; 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 getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input"; import Input from "./Input";
@@ -31,7 +31,7 @@ export type Props<T extends MenuItem = MenuItem> = {
uploadFile?: (file: File) => Promise<string>; uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void; onFileUploadStart?: () => void;
onFileUploadStop?: () => void; onFileUploadStop?: () => void;
onShowToast: (message: string, id: string) => void; onShowToast: (message: string) => void;
onLinkToolbarOpen?: () => void; onLinkToolbarOpen?: () => void;
onClose: () => void; onClose: () => void;
onClearSearch: () => void; onClearSearch: () => void;
@@ -216,10 +216,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const matches = this.state.insertItem.matcher(href); const matches = this.state.insertItem.matcher(href);
if (!matches) { if (!matches) {
this.props.onShowToast( this.props.onShowToast(this.props.dictionary.embedInvalidLink);
this.props.dictionary.embedInvalidLink,
ToastType.Error
);
return; 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() { render() {
const containerId = "emoji-menu-container";
return ( return (
<CommandMenu <CommandMenu
{...this.props} {...this.props}
id="emoji-menu-container" id={containerId}
filterable={false} filterable={false}
onClearSearch={this.clearSearch} onClearSearch={this.clearSearch}
renderMenuItem={(item, _index, options) => { renderMenuItem={(item, _index, options) => (
return (
<EmojiMenuItem <EmojiMenuItem
onClick={options.onClick} onClick={options.onClick}
selected={options.selected} selected={options.selected}
title={item.description} title={item.description}
emoji={item.emoji} emoji={item.emoji}
containerId="emoji-menu-container" containerId={containerId}
/> />
); )}
}}
items={this.items} items={this.items}
/> />
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,13 @@ import { gapCursor } from "prosemirror-gapcursor";
import { inputRules, InputRule } from "prosemirror-inputrules"; import { inputRules, InputRule } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap"; import { keymap } from "prosemirror-keymap";
import { MarkdownParser } from "prosemirror-markdown"; 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 { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
import { selectColumn, selectRow, selectTable } from "prosemirror-utils";
import { Decoration, EditorView } from "prosemirror-view"; import { Decoration, EditorView } from "prosemirror-view";
import * as React from "react"; import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components"; 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 ExtensionManager from "@shared/editor/lib/ExtensionManager";
import headingToSlug from "@shared/editor/lib/headingToSlug"; import headingToSlug from "@shared/editor/lib/headingToSlug";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import Mark from "@shared/editor/marks/Mark";
// marks import Node from "@shared/editor/nodes/Node";
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 ReactNode from "@shared/editor/nodes/ReactNode"; import ReactNode from "@shared/editor/nodes/ReactNode";
import Table from "@shared/editor/nodes/Table"; import fullExtensionsPackage from "@shared/editor/packages/full";
import TableCell from "@shared/editor/nodes/TableCell"; import { EmbedDescriptor, EventType } from "@shared/editor/types";
import TableHeadCell from "@shared/editor/nodes/TableHeadCell"; import EventEmitter from "@shared/utils/events";
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 Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
import BlockMenu from "./components/BlockMenu"; import BlockMenu from "./components/BlockMenu";
import ComponentView from "./components/ComponentView"; import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import EmojiMenu from "./components/EmojiMenu"; import EmojiMenu from "./components/EmojiMenu";
import { SearchResult } from "./components/LinkEditor"; import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar"; import LinkToolbar from "./components/LinkToolbar";
@@ -87,8 +49,8 @@ export type Props = {
defaultValue: string; defaultValue: string;
/** Placeholder displayed when the editor is empty */ /** Placeholder displayed when the editor is empty */
placeholder: string; placeholder: string;
/** Additional extensions to load into the editor */ /** Extensions to load into the editor */
extensions?: Extension[]; extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[];
/** If the editor should be focused on mount */ /** If the editor should be focused on mount */
autoFocus?: boolean; autoFocus?: boolean;
/** If the editor should not allow editing */ /** If the editor should not allow editing */
@@ -143,7 +105,7 @@ export type Props = {
/** Whether embeds should be rendered without an iframe */ /** Whether embeds should be rendered without an iframe */
embedsDisabled?: boolean; embedsDisabled?: boolean;
/** Callback when a toast message is triggered (eg "link copied") */ /** Callback when a toast message is triggered (eg "link copied") */
onShowToast: (message: string, code: ToastType) => void; onShowToast: (message: string) => void;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
}; };
@@ -185,7 +147,7 @@ export class Editor extends React.PureComponent<
// no default behavior // no default behavior
}, },
embeds: [], embeds: [],
extensions: [], extensions: fullExtensionsPackage,
}; };
state = { state = {
@@ -200,7 +162,7 @@ export class Editor extends React.PureComponent<
isBlurred: boolean; isBlurred: boolean;
extensions: ExtensionManager; extensions: ExtensionManager;
element?: HTMLElement | null; element = React.createRef<HTMLDivElement>();
view: EditorView; view: EditorView;
schema: Schema; schema: Schema;
serializer: MarkdownSerializer; serializer: MarkdownSerializer;
@@ -211,7 +173,7 @@ export class Editor extends React.PureComponent<
inputRules: InputRule[]; inputRules: InputRule[];
nodeViews: { nodeViews: {
[name: string]: ( [name: string]: (
node: Node, node: ProsemirrorNode,
view: EditorView, view: EditorView,
getPos: () => number, getPos: () => number,
decorations: Decoration<{ decorations: Decoration<{
@@ -224,8 +186,23 @@ export class Editor extends React.PureComponent<
marks: { [name: string]: MarkSpec }; marks: { [name: string]: MarkSpec };
commands: Record<string, CommandFactory>; commands: Record<string, CommandFactory>;
rulePlugins: PluginSimple[]; 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(); this.init();
if (this.props.scrollTo) { 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 // Allow changes to the 'value' prop to update the editor from outside
if (this.props.value && prevProps.value !== this.props.value) { if (this.props.value && prevProps.value !== this.props.value) {
const newState = this.createState(this.props.value); const newState = this.createState(this.props.value);
@@ -280,9 +257,7 @@ export class Editor extends React.PureComponent<
!this.state.selectionMenuOpen !this.state.selectionMenuOpen
) { ) {
this.isBlurred = true; this.isBlurred = true;
if (this.props.onBlur) { this.props.onBlur?.();
this.props.onBlur();
}
} }
if ( if (
@@ -293,13 +268,11 @@ export class Editor extends React.PureComponent<
this.state.selectionMenuOpen) this.state.selectionMenuOpen)
) { ) {
this.isBlurred = false; this.isBlurred = false;
if (this.props.onFocus) { this.props.onFocus?.();
this.props.onFocus();
}
} }
} }
init() { private init() {
this.extensions = this.createExtensions(); this.extensions = this.createExtensions();
this.nodes = this.createNodes(); this.nodes = this.createNodes();
this.marks = this.createMarks(); this.marks = this.createMarks();
@@ -316,138 +289,36 @@ export class Editor extends React.PureComponent<
this.commands = this.createCommands(); this.commands = this.createCommands();
} }
createExtensions() { private createExtensions() {
const { dictionary } = this.props; return new ExtensionManager(this.props.extensions, this);
// 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
);
} }
createPlugins() { private createPlugins() {
return this.extensions.plugins; return this.extensions.plugins;
} }
createRulePlugins() { private createRulePlugins() {
return this.extensions.rulePlugins; return this.extensions.rulePlugins;
} }
createKeymaps() { private createKeymaps() {
return this.extensions.keymaps({ return this.extensions.keymaps({
schema: this.schema, schema: this.schema,
}); });
} }
createInputRules() { private createInputRules() {
return this.extensions.inputRules({ return this.extensions.inputRules({
schema: this.schema, schema: this.schema,
}); });
} }
createNodeViews() { private createNodeViews() {
return this.extensions.extensions return this.extensions.extensions
.filter((extension: ReactNode) => extension.component) .filter((extension: ReactNode) => extension.component)
.reduce((nodeViews, extension: ReactNode) => { .reduce((nodeViews, extension: ReactNode) => {
const nodeView = ( const nodeView = (
node: Node, node: ProsemirrorNode,
view: EditorView, view: EditorView,
getPos: () => number, getPos: () => number,
decorations: Decoration<{ decorations: Decoration<{
@@ -471,40 +342,40 @@ export class Editor extends React.PureComponent<
}, {}); }, {});
} }
createCommands() { private createCommands() {
return this.extensions.commands({ return this.extensions.commands({
schema: this.schema, schema: this.schema,
view: this.view, view: this.view,
}); });
} }
createNodes() { private createNodes() {
return this.extensions.nodes; return this.extensions.nodes;
} }
createMarks() { private createMarks() {
return this.extensions.marks; return this.extensions.marks;
} }
createSchema() { private createSchema() {
return new Schema({ return new Schema({
nodes: this.nodes, nodes: this.nodes,
marks: this.marks, marks: this.marks,
}); });
} }
createSerializer() { private createSerializer() {
return this.extensions.serializer(); return this.extensions.serializer();
} }
createParser() { private createParser() {
return this.extensions.parser({ return this.extensions.parser({
schema: this.schema, schema: this.schema,
plugins: this.rulePlugins, plugins: this.rulePlugins,
}); });
} }
createPasteParser() { private createPasteParser() {
return this.extensions.parser({ return this.extensions.parser({
schema: this.schema, schema: this.schema,
rules: { linkify: true, emoji: false }, 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); const doc = this.createDocument(value || this.props.defaultValue);
return EditorState.create({ 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); return this.parser.parse(content);
} }
createView() { private createView() {
if (!this.element) { if (!this.element.current) {
throw new Error("createView called before ref available"); 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 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), state: this.createState(this.props.value),
editable: () => !this.props.readOnly, editable: () => !this.props.readOnly,
nodeViews: this.nodeViews, nodeViews: this.nodeViews,
@@ -587,7 +462,7 @@ export class Editor extends React.PureComponent<
return view; return view;
} }
scrollToAnchor(hash: string) { public scrollToAnchor(hash: string) {
if (!hash) { if (!hash) {
return; return;
} }
@@ -605,25 +480,25 @@ export class Editor extends React.PureComponent<
} }
} }
calculateDir = () => { private calculateDir = () => {
if (!this.element) { if (!this.element.current) {
return; return;
} }
const isRTL = const isRTL =
this.props.dir === "rtl" || this.props.dir === "rtl" ||
getComputedStyle(this.element).direction === "rtl"; getComputedStyle(this.element.current).direction === "rtl";
if (this.state.isRTL !== isRTL) { if (this.state.isRTL !== isRTL) {
this.setState({ isRTL }); this.setState({ isRTL });
} }
}; };
value = (): string => { public value = (): string => {
return this.serializer.serialize(this.view.state.doc); return this.serializer.serialize(this.view.state.doc);
}; };
handleChange = () => { private handleChange = () => {
if (!this.props.onChange) { if (!this.props.onChange) {
return; return;
} }
@@ -633,83 +508,72 @@ export class Editor extends React.PureComponent<
}); });
}; };
handleSave = () => { private handleEditorBlur = () => {
const { onSave } = this.props;
if (onSave) {
onSave({ done: false });
}
};
handleSaveAndExit = () => {
const { onSave } = this.props;
if (onSave) {
onSave({ done: true });
}
};
handleEditorBlur = () => {
this.setState({ isEditorFocused: false }); this.setState({ isEditorFocused: false });
return false;
}; };
handleEditorFocus = () => { private handleEditorFocus = () => {
this.setState({ isEditorFocused: true }); this.setState({ isEditorFocused: true });
return false;
}; };
handleOpenSelectionMenu = () => { private handleOpenSelectionMenu = () => {
this.setState({ blockMenuOpen: false, selectionMenuOpen: true }); this.setState({ blockMenuOpen: false, selectionMenuOpen: true });
}; };
handleCloseSelectionMenu = () => { private handleCloseSelectionMenu = () => {
if (!this.state.selectionMenuOpen) {
return;
}
this.setState({ selectionMenuOpen: false }); 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 }); this.setState({ blockMenuOpen: false, linkMenuOpen: true });
}; };
handleCloseLinkMenu = () => { private handleCloseLinkMenu = () => {
this.setState({ linkMenuOpen: false }); this.setState({ linkMenuOpen: false });
}; };
handleOpenBlockMenu = (search: string) => { private handleOpenBlockMenu = (search: string) => {
this.setState({ blockMenuOpen: true, blockMenuSearch: search }); this.setState({ blockMenuOpen: true, blockMenuSearch: search });
}; };
handleCloseBlockMenu = () => { private handleCloseBlockMenu = () => {
if (!this.state.blockMenuOpen) { if (!this.state.blockMenuOpen) {
return; return;
} }
this.setState({ blockMenuOpen: false }); this.setState({ blockMenuOpen: false });
}; };
handleSelectRow = (index: number, state: EditorState) => { public focusAtStart = () => {
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 = () => {
const selection = Selection.atStart(this.view.state.doc); const selection = Selection.atStart(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection); const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction); this.view.dispatch(transaction);
this.view.focus(); this.view.focus();
}; };
focusAtEnd = () => { public focusAtEnd = () => {
const selection = Selection.atEnd(this.view.state.doc); const selection = Selection.atEnd(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection); const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction); this.view.dispatch(transaction);
this.view.focus(); this.view.focus();
}; };
getHeadings = () => { public getHeadings = () => {
const headings: { title: string; level: number; id: string }[] = []; const headings: { title: string; level: number; id: string }[] = [];
const previouslySeen = {}; const previouslySeen = {};
@@ -740,7 +604,7 @@ export class Editor extends React.PureComponent<
return headings; return headings;
}; };
render() { public render() {
const { const {
dir, dir,
readOnly, readOnly,
@@ -754,6 +618,7 @@ export class Editor extends React.PureComponent<
const { isRTL } = this.state; const { isRTL } = this.state;
return ( return (
<EditorContext.Provider value={this}>
<Flex <Flex
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
style={style} style={style}
@@ -769,10 +634,10 @@ export class Editor extends React.PureComponent<
grow={grow} grow={grow}
readOnly={readOnly} readOnly={readOnly}
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes} readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
ref={(ref) => (this.element = ref)} ref={this.element}
/> />
{!readOnly && this.view && ( {!readOnly && this.view && (
<React.Fragment> <>
<SelectionToolbar <SelectionToolbar
view={this.view} view={this.view}
dictionary={dictionary} dictionary={dictionary}
@@ -804,7 +669,7 @@ export class Editor extends React.PureComponent<
onShowToast={this.props.onShowToast} onShowToast={this.props.onShowToast}
isActive={this.state.emojiMenuOpen} isActive={this.state.emojiMenuOpen}
search={this.state.blockMenuSearch} search={this.state.blockMenuSearch}
onClose={() => this.setState({ emojiMenuOpen: false })} onClose={this.handleCloseEmojiMenu}
/> />
<BlockMenu <BlockMenu
view={this.view} view={this.view}
@@ -821,19 +686,22 @@ export class Editor extends React.PureComponent<
onShowToast={this.props.onShowToast} onShowToast={this.props.onShowToast}
embeds={this.props.embeds} embeds={this.props.embeds}
/> />
</React.Fragment> </>
)} )}
</Flex> </Flex>
</EditorContext.Provider>
); );
} }
} }
const EditorWithTheme = React.forwardRef<Editor, Props>((props: Props, ref) => { const LazyLoadedEditor = React.forwardRef<Editor, Props>(
(props: Props, ref) => {
return ( return (
<WithTheme> <WithTheme>
{(theme) => <Editor theme={theme} {...props} ref={ref} />} {(theme) => <Editor theme={theme} {...props} ref={ref} />}
</WithTheme> </WithTheme>
); );
}); }
);
export default EditorWithTheme; export default LazyLoadedEditor;

View File

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

View File

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

View File

@@ -1,75 +1,9 @@
import { Schema } from "prosemirror-model"; import { Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import fullPackage from "@shared/editor/packages/full";
// 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 render from "./renderToHtml"; import render from "./renderToHtml";
const extensions = new ExtensionManager([ const extensions = new ExtensionManager(fullPackage);
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(),
]);
export const schema = new Schema({ export const schema = new Schema({
nodes: extensions.nodes, nodes: extensions.nodes,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,17 @@ import nameToEmoji from "gemoji/name-to-emoji.json";
import Token from "markdown-it/lib/token"; import Token from "markdown-it/lib/token";
import { InputRule } from "prosemirror-inputrules"; import { InputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; 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 { MarkdownSerializerState } from "../lib/markdown/serializer";
import { run } from "../plugins/BlockMenuTrigger";
import isInCode from "../queries/isInCode";
import emojiRule from "../rules/emoji"; import emojiRule from "../rules/emoji";
import { Dispatch } from "../types"; import { Dispatch, EventType } from "../types";
import Node from "./Node"; 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 { export default class Emoji extends Node {
get name() { get name() {
return "emoji"; return "emoji";
@@ -61,6 +66,57 @@ export default class Emoji extends Node {
return [emojiRule]; 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 }) { commands({ type }: { type: NodeType }) {
return (attrs: Record<string, string>) => ( return (attrs: Record<string, string>) => (
state: EditorState, state: EditorState,
@@ -100,6 +156,29 @@ export default class Emoji extends Node {
return tr; 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 { Command } from "../lib/Extension";
import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug"; import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug";
import { MarkdownSerializerState } from "../lib/markdown/serializer"; import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { ToastType } from "../types";
import Node from "./Node"; import Node from "./Node";
export default class Heading extends Node { export default class Heading extends Node {
@@ -180,10 +179,7 @@ export default class Heading extends Node {
const urlWithoutHash = window.location.href.split("#")[0]; const urlWithoutHash = window.location.href.split("#")[0];
copy(urlWithoutHash + hash); copy(urlWithoutHash + hash);
this.options.onShowToast( this.options.onShowToast(this.options.dictionary.linkCopied);
this.options.dictionary.linkCopied,
ToastType.Info
);
}; };
keys({ type, schema }: { type: NodeType; schema: Schema }) { keys({ type, schema }: { type: NodeType; schema: Schema }) {

View File

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

View File

@@ -1,7 +1,11 @@
import Token from "markdown-it/lib/token"; import Token from "markdown-it/lib/token";
import { NodeSpec } from "prosemirror-model"; import { NodeSpec } from "prosemirror-model";
import { Plugin } from "prosemirror-state"; 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 { DecorationSet, Decoration } from "prosemirror-view";
import Node from "./Node"; import Node from "./Node";
@@ -72,7 +76,7 @@ export default class TableHeadCell extends Node {
grip.addEventListener("mousedown", (event) => { grip.addEventListener("mousedown", (event) => {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
this.options.onSelectColumn(index, state); this.editor.view.dispatch(selectColumn(index)(state.tr));
}); });
return grip; 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 * as React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import Extension from "../lib/Extension"; import Extension from "../lib/Extension";
import { EventType } from "../types";
const MAX_MATCH = 500; const MAX_MATCH = 500;
const OPEN_REGEX = /^\/(\w+)?$/; const OPEN_REGEX = /^\/(\w+)?$/;
@@ -65,7 +66,7 @@ export default class BlockMenuTrigger extends Extension {
new Plugin({ new Plugin({
props: { props: {
handleClick: () => { handleClick: () => {
this.options.onClose(); this.editor.events.emit(EventType.blockMenuClose);
return false; return false;
}, },
handleKeyDown: (view, event) => { handleKeyDown: (view, event) => {
@@ -79,9 +80,9 @@ export default class BlockMenuTrigger extends Extension {
const { pos } = view.state.selection.$from; const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => { return run(view, pos, pos, OPEN_REGEX, (state, match) => {
if (match) { if (match) {
this.options.onOpen(match[1]); this.editor.events.emit(EventType.blockMenuOpen, match[1]);
} else { } else {
this.options.onClose(); this.editor.events.emit(EventType.blockMenuClose);
} }
return null; return null;
}); });
@@ -125,7 +126,7 @@ export default class BlockMenuTrigger extends Extension {
decorations.push( decorations.push(
Decoration.widget(parent.pos, () => { Decoration.widget(parent.pos, () => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
this.options.onOpen(""); this.editor.events.emit(EventType.blockMenuOpen, "");
}); });
return button; return button;
}) })
@@ -176,7 +177,7 @@ export default class BlockMenuTrigger extends Extension {
state.selection.$from.parent.type.name === "paragraph" && state.selection.$from.parent.type.name === "paragraph" &&
!isInTable(state) !isInTable(state)
) { ) {
this.options.onOpen(match[1]); this.editor.events.emit(EventType.blockMenuOpen, match[1]);
} }
return null; return null;
}), }),
@@ -186,7 +187,7 @@ export default class BlockMenuTrigger extends Extension {
// /word<space> // /word<space>
new InputRule(CLOSE_REGEX, (state, match) => { new InputRule(CLOSE_REGEX, (state, match) => {
if (match) { if (match) {
this.options.onClose(); this.editor.events.emit(EventType.blockMenuClose);
} }
return null; 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> { keys(): Record<string, Command> {
const onCancel = () => { const onCancel = () => {
if (this.options.onCancel) { if (this.editor.props.onCancel) {
this.options.onCancel(); this.editor.props.onCancel();
return true; return true;
} }
return false; return false;
@@ -32,15 +32,15 @@ export default class Keys extends Extension {
"Mod-Escape": onCancel, "Mod-Escape": onCancel,
"Shift-Escape": onCancel, "Shift-Escape": onCancel,
"Mod-s": () => { "Mod-s": () => {
if (this.options.onSave) { if (this.editor.props.onSave) {
this.options.onSave(); this.editor.props.onSave({ done: false });
return true; return true;
} }
return false; return false;
}, },
"Mod-Enter": (state: EditorState) => { "Mod-Enter": (state: EditorState) => {
if (!isInCode(state) && this.options.onSaveAndExit) { if (!isInCode(state) && this.editor.props.onSave) {
this.options.onSaveAndExit(); this.editor.props.onSave({ done: true });
return true; return true;
} }
return false; return false;
@@ -52,10 +52,6 @@ export default class Keys extends Extension {
return [ return [
new Plugin({ new Plugin({
props: { props: {
handleDOMEvents: {
blur: this.options.onBlur,
focus: this.options.onFocus,
},
// we can't use the keys bindings for this as we want to preventDefault // we can't use the keys bindings for this as we want to preventDefault
// on the original keyboard event when handled // on the original keyboard event when handled
handleKeyDown: (view, event) => { handleKeyDown: (view, event) => {

View File

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