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 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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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() {
|
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
})
|
})
|
||||||
|
|||||||
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 * 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;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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> {
|
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) => {
|
||||||
|
|||||||
@@ -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
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