819 lines
24 KiB
TypeScript
819 lines
24 KiB
TypeScript
/* global File Promise */
|
|
import { PluginSimple } from "markdown-it";
|
|
import { baseKeymap } from "prosemirror-commands";
|
|
import { dropCursor } from "prosemirror-dropcursor";
|
|
import { gapCursor } from "prosemirror-gapcursor";
|
|
import { inputRules, InputRule } from "prosemirror-inputrules";
|
|
import { keymap } from "prosemirror-keymap";
|
|
import { MarkdownParser } from "prosemirror-markdown";
|
|
import { Schema, NodeSpec, MarkSpec, Node } from "prosemirror-model";
|
|
import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
|
|
import { selectColumn, selectRow, selectTable } from "prosemirror-utils";
|
|
import { Decoration, EditorView } from "prosemirror-view";
|
|
import * as React from "react";
|
|
import { DefaultTheme, ThemeProps } from "styled-components";
|
|
import Extension from "@shared/editor/lib/Extension";
|
|
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
|
import headingToSlug from "@shared/editor/lib/headingToSlug";
|
|
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
|
|
|
// marks
|
|
import Bold from "@shared/editor/marks/Bold";
|
|
import Code from "@shared/editor/marks/Code";
|
|
import Highlight from "@shared/editor/marks/Highlight";
|
|
import Italic from "@shared/editor/marks/Italic";
|
|
import Link from "@shared/editor/marks/Link";
|
|
import TemplatePlaceholder from "@shared/editor/marks/Placeholder";
|
|
import Strikethrough from "@shared/editor/marks/Strikethrough";
|
|
import Underline from "@shared/editor/marks/Underline";
|
|
|
|
// nodes
|
|
import 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 Table from "@shared/editor/nodes/Table";
|
|
import TableCell from "@shared/editor/nodes/TableCell";
|
|
import TableHeadCell from "@shared/editor/nodes/TableHeadCell";
|
|
import TableRow from "@shared/editor/nodes/TableRow";
|
|
import Text from "@shared/editor/nodes/Text";
|
|
|
|
// plugins
|
|
import BlockMenuTrigger from "@shared/editor/plugins/BlockMenuTrigger";
|
|
import EmojiTrigger from "@shared/editor/plugins/EmojiTrigger";
|
|
import Folding from "@shared/editor/plugins/Folding";
|
|
import History from "@shared/editor/plugins/History";
|
|
import Keys from "@shared/editor/plugins/Keys";
|
|
import MaxLength from "@shared/editor/plugins/MaxLength";
|
|
import PasteHandler from "@shared/editor/plugins/PasteHandler";
|
|
import Placeholder from "@shared/editor/plugins/Placeholder";
|
|
import SmartText from "@shared/editor/plugins/SmartText";
|
|
import TrailingNode from "@shared/editor/plugins/TrailingNode";
|
|
import { EmbedDescriptor, ToastType } from "@shared/editor/types";
|
|
import Flex from "~/components/Flex";
|
|
import { Dictionary } from "~/hooks/useDictionary";
|
|
import BlockMenu from "./components/BlockMenu";
|
|
import ComponentView from "./components/ComponentView";
|
|
import EmojiMenu from "./components/EmojiMenu";
|
|
import { SearchResult } from "./components/LinkEditor";
|
|
import LinkToolbar from "./components/LinkToolbar";
|
|
import SelectionToolbar from "./components/SelectionToolbar";
|
|
import EditorContainer from "./components/Styles";
|
|
import WithTheme from "./components/WithTheme";
|
|
|
|
export { default as Extension } from "@shared/editor/lib/Extension";
|
|
|
|
export type Props = {
|
|
/** An optional identifier for the editor context. It is used to persist local settings */
|
|
id?: string;
|
|
/** The editor content, should only be changed if you wish to reset the content */
|
|
value?: string;
|
|
/** The initial editor content */
|
|
defaultValue: string;
|
|
/** Placeholder displayed when the editor is empty */
|
|
placeholder: string;
|
|
/** Additional extensions to load into the editor */
|
|
extensions?: Extension[];
|
|
/** If the editor should be focused on mount */
|
|
autoFocus?: boolean;
|
|
/** If the editor should not allow editing */
|
|
readOnly?: boolean;
|
|
/** If the editor should still allow editing checkboxes when it is readOnly */
|
|
readOnlyWriteCheckboxes?: boolean;
|
|
/** A dictionary of translated strings used in the editor */
|
|
dictionary: Dictionary;
|
|
/** The reading direction of the text content, if known */
|
|
dir?: "rtl" | "ltr";
|
|
/** If the editor should vertically grow to fill available space */
|
|
grow?: boolean;
|
|
/** If the editor should display template options such as inserting placeholders */
|
|
template?: boolean;
|
|
/** An enforced maximum content length */
|
|
maxLength?: number;
|
|
/** Heading id to scroll to when the editor has loaded */
|
|
scrollTo?: string;
|
|
/** Callback for handling uploaded images, should return the url of uploaded file */
|
|
uploadImage?: (file: File) => Promise<string>;
|
|
/** Callback when editor is blurred, as native input */
|
|
onBlur?: () => void;
|
|
/** Callback when editor is focused, as native input */
|
|
onFocus?: () => void;
|
|
/** Callback when user uses save key combo */
|
|
onSave?: (options: { done: boolean }) => void;
|
|
/** Callback when user uses cancel key combo */
|
|
onCancel?: () => void;
|
|
/** Callback when user changes editor content */
|
|
onChange?: (value: () => string) => void;
|
|
/** Callback when a file upload begins */
|
|
onImageUploadStart?: () => void;
|
|
/** Callback when a file upload ends */
|
|
onImageUploadStop?: () => void;
|
|
/** Callback when a link is created, should return url to created document */
|
|
onCreateLink?: (title: string) => Promise<string>;
|
|
/** Callback when user searches for documents from link insert interface */
|
|
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
|
/** Callback when user clicks on any link in the document */
|
|
onClickLink: (
|
|
href: string,
|
|
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
|
) => void;
|
|
/** Callback when user hovers on any link in the document */
|
|
onHoverLink?: (event: MouseEvent) => boolean;
|
|
/** Callback when user clicks on any hashtag in the document */
|
|
onClickHashtag?: (tag: string, event: MouseEvent) => void;
|
|
/** Callback when user presses any key with document focused */
|
|
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
|
/** Collection of embed types to render in the document */
|
|
embeds: EmbedDescriptor[];
|
|
/** Callback when a toast message is triggered (eg "link copied") */
|
|
onShowToast?: (message: string, code: ToastType) => void;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
};
|
|
|
|
type State = {
|
|
/** If the document text has been detected as using RTL script */
|
|
isRTL: boolean;
|
|
/** If the editor is currently focused */
|
|
isEditorFocused: boolean;
|
|
/** If the toolbar for a text selection is visible */
|
|
selectionMenuOpen: boolean;
|
|
/** If the block insert menu is visible (triggered with /) */
|
|
blockMenuOpen: boolean;
|
|
/** If the insert link toolbar is visible */
|
|
linkMenuOpen: boolean;
|
|
/** The search term currently filtering the block menu */
|
|
blockMenuSearch: string;
|
|
/** If the emoji insert menu is visible */
|
|
emojiMenuOpen: boolean;
|
|
};
|
|
|
|
/**
|
|
* The shared editor at the root of all rich editable text in Outline. Do not
|
|
* use this component directly, it should by lazy loaded. Use
|
|
* ~/components/Editor instead.
|
|
*/
|
|
export class Editor extends React.PureComponent<
|
|
Props & ThemeProps<DefaultTheme>,
|
|
State
|
|
> {
|
|
static defaultProps = {
|
|
defaultValue: "",
|
|
dir: "auto",
|
|
placeholder: "Write something nice…",
|
|
onImageUploadStart: () => {
|
|
// no default behavior
|
|
},
|
|
onImageUploadStop: () => {
|
|
// no default behavior
|
|
},
|
|
embeds: [],
|
|
extensions: [],
|
|
};
|
|
|
|
state = {
|
|
isRTL: false,
|
|
isEditorFocused: false,
|
|
selectionMenuOpen: false,
|
|
blockMenuOpen: false,
|
|
linkMenuOpen: false,
|
|
blockMenuSearch: "",
|
|
emojiMenuOpen: false,
|
|
};
|
|
|
|
isBlurred: boolean;
|
|
extensions: ExtensionManager;
|
|
element?: HTMLElement | null;
|
|
view: EditorView;
|
|
schema: Schema;
|
|
serializer: MarkdownSerializer;
|
|
parser: MarkdownParser;
|
|
pasteParser: MarkdownParser;
|
|
plugins: Plugin[];
|
|
keymaps: Plugin[];
|
|
inputRules: InputRule[];
|
|
nodeViews: {
|
|
[name: string]: (
|
|
node: Node,
|
|
view: EditorView,
|
|
getPos: () => number,
|
|
decorations: Decoration<{
|
|
[key: string]: any;
|
|
}>[]
|
|
) => ComponentView;
|
|
};
|
|
|
|
nodes: { [name: string]: NodeSpec };
|
|
marks: { [name: string]: MarkSpec };
|
|
commands: Record<string, any>;
|
|
rulePlugins: PluginSimple[];
|
|
|
|
componentDidMount() {
|
|
this.init();
|
|
|
|
if (this.props.scrollTo) {
|
|
this.scrollToAnchor(this.props.scrollTo);
|
|
}
|
|
|
|
this.calculateDir();
|
|
|
|
if (this.props.readOnly) return;
|
|
|
|
if (this.props.autoFocus) {
|
|
this.focusAtEnd();
|
|
}
|
|
}
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
|
// Allow changes to the 'value' prop to update the editor from outside
|
|
if (this.props.value && prevProps.value !== this.props.value) {
|
|
const newState = this.createState(this.props.value);
|
|
this.view.updateState(newState);
|
|
}
|
|
|
|
// pass readOnly changes through to underlying editor instance
|
|
if (prevProps.readOnly !== this.props.readOnly) {
|
|
this.view.update({
|
|
...this.view.props,
|
|
editable: () => !this.props.readOnly,
|
|
});
|
|
}
|
|
|
|
if (this.props.scrollTo && this.props.scrollTo !== prevProps.scrollTo) {
|
|
this.scrollToAnchor(this.props.scrollTo);
|
|
}
|
|
|
|
// Focus at the end of the document if switching from readOnly and autoFocus
|
|
// is set to true
|
|
if (prevProps.readOnly && !this.props.readOnly && this.props.autoFocus) {
|
|
this.focusAtEnd();
|
|
}
|
|
|
|
if (prevProps.dir !== this.props.dir) {
|
|
this.calculateDir();
|
|
}
|
|
|
|
if (
|
|
!this.isBlurred &&
|
|
!this.state.isEditorFocused &&
|
|
!this.state.blockMenuOpen &&
|
|
!this.state.linkMenuOpen &&
|
|
!this.state.selectionMenuOpen
|
|
) {
|
|
this.isBlurred = true;
|
|
if (this.props.onBlur) {
|
|
this.props.onBlur();
|
|
}
|
|
}
|
|
|
|
if (
|
|
this.isBlurred &&
|
|
(this.state.isEditorFocused ||
|
|
this.state.blockMenuOpen ||
|
|
this.state.linkMenuOpen ||
|
|
this.state.selectionMenuOpen)
|
|
) {
|
|
this.isBlurred = false;
|
|
if (this.props.onFocus) {
|
|
this.props.onFocus();
|
|
}
|
|
}
|
|
}
|
|
|
|
init() {
|
|
this.extensions = this.createExtensions();
|
|
this.nodes = this.createNodes();
|
|
this.marks = this.createMarks();
|
|
this.schema = this.createSchema();
|
|
this.plugins = this.createPlugins();
|
|
this.rulePlugins = this.createRulePlugins();
|
|
this.keymaps = this.createKeymaps();
|
|
this.serializer = this.createSerializer();
|
|
this.parser = this.createParser();
|
|
this.pasteParser = this.createPasteParser();
|
|
this.inputRules = this.createInputRules();
|
|
this.nodeViews = this.createNodeViews();
|
|
this.view = this.createView();
|
|
this.commands = this.createCommands();
|
|
}
|
|
|
|
createExtensions() {
|
|
const { dictionary } = this.props;
|
|
|
|
// adding nodes here? Update schema.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 Notice({
|
|
dictionary,
|
|
}),
|
|
new Heading({
|
|
dictionary,
|
|
onShowToast: this.props.onShowToast,
|
|
}),
|
|
new HorizontalRule(),
|
|
new Image({
|
|
dictionary,
|
|
uploadImage: this.props.uploadImage,
|
|
onImageUploadStart: this.props.onImageUploadStart,
|
|
onImageUploadStop: this.props.onImageUploadStop,
|
|
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() {
|
|
return this.extensions.plugins;
|
|
}
|
|
|
|
createRulePlugins() {
|
|
return this.extensions.rulePlugins;
|
|
}
|
|
|
|
createKeymaps() {
|
|
return this.extensions.keymaps({
|
|
schema: this.schema,
|
|
});
|
|
}
|
|
|
|
createInputRules() {
|
|
return this.extensions.inputRules({
|
|
schema: this.schema,
|
|
});
|
|
}
|
|
|
|
createNodeViews() {
|
|
return this.extensions.extensions
|
|
.filter((extension: ReactNode) => extension.component)
|
|
.reduce((nodeViews, extension: ReactNode) => {
|
|
const nodeView = (
|
|
node: Node,
|
|
view: EditorView,
|
|
getPos: () => number,
|
|
decorations: Decoration<{
|
|
[key: string]: any;
|
|
}>[]
|
|
) => {
|
|
return new ComponentView(extension.component, {
|
|
editor: this,
|
|
extension,
|
|
node,
|
|
view,
|
|
getPos,
|
|
decorations,
|
|
});
|
|
};
|
|
|
|
return {
|
|
...nodeViews,
|
|
[extension.name]: nodeView,
|
|
};
|
|
}, {});
|
|
}
|
|
|
|
createCommands() {
|
|
return this.extensions.commands({
|
|
schema: this.schema,
|
|
view: this.view,
|
|
});
|
|
}
|
|
|
|
createNodes() {
|
|
return this.extensions.nodes;
|
|
}
|
|
|
|
createMarks() {
|
|
return this.extensions.marks;
|
|
}
|
|
|
|
createSchema() {
|
|
return new Schema({
|
|
nodes: this.nodes,
|
|
marks: this.marks,
|
|
});
|
|
}
|
|
|
|
createSerializer() {
|
|
return this.extensions.serializer();
|
|
}
|
|
|
|
createParser() {
|
|
return this.extensions.parser({
|
|
schema: this.schema,
|
|
plugins: this.rulePlugins,
|
|
});
|
|
}
|
|
|
|
createPasteParser() {
|
|
return this.extensions.parser({
|
|
schema: this.schema,
|
|
rules: { linkify: true },
|
|
plugins: this.rulePlugins,
|
|
});
|
|
}
|
|
|
|
createState(value?: string) {
|
|
const doc = this.createDocument(value || this.props.defaultValue);
|
|
|
|
return EditorState.create({
|
|
schema: this.schema,
|
|
doc,
|
|
plugins: [
|
|
...this.plugins,
|
|
...this.keymaps,
|
|
dropCursor({ color: this.props.theme.cursor }),
|
|
gapCursor(),
|
|
inputRules({
|
|
rules: this.inputRules,
|
|
}),
|
|
keymap(baseKeymap),
|
|
],
|
|
});
|
|
}
|
|
|
|
createDocument(content: string) {
|
|
return this.parser.parse(content);
|
|
}
|
|
|
|
createView() {
|
|
if (!this.element) {
|
|
throw new Error("createView called before ref available");
|
|
}
|
|
|
|
const isEditingCheckbox = (tr: Transaction) => {
|
|
return tr.steps.some(
|
|
(step: any) =>
|
|
step.slice?.content?.firstChild?.type.name ===
|
|
this.schema.nodes.checkbox_item.name
|
|
);
|
|
};
|
|
|
|
const self = this; // eslint-disable-line
|
|
const view = new EditorView(this.element, {
|
|
state: this.createState(this.props.value),
|
|
editable: () => !this.props.readOnly,
|
|
nodeViews: this.nodeViews,
|
|
dispatchTransaction: function (transaction) {
|
|
// callback is bound to have the view instance as its this binding
|
|
const { state, transactions } = this.state.applyTransaction(
|
|
transaction
|
|
);
|
|
|
|
this.updateState(state);
|
|
|
|
// If any of the transactions being dispatched resulted in the doc
|
|
// changing then call our own change handler to let the outside world
|
|
// know
|
|
if (
|
|
transactions.some((tr) => tr.docChanged) &&
|
|
(!self.props.readOnly ||
|
|
(self.props.readOnlyWriteCheckboxes &&
|
|
transactions.some(isEditingCheckbox)))
|
|
) {
|
|
self.handleChange();
|
|
}
|
|
|
|
self.calculateDir();
|
|
|
|
// Because Prosemirror and React are not linked we must tell React that
|
|
// a render is needed whenever the Prosemirror state changes.
|
|
self.forceUpdate();
|
|
},
|
|
});
|
|
|
|
// Tell third-party libraries and screen-readers that this is an input
|
|
view.dom.setAttribute("role", "textbox");
|
|
|
|
return view;
|
|
}
|
|
|
|
scrollToAnchor(hash: string) {
|
|
if (!hash) return;
|
|
|
|
try {
|
|
const element = document.querySelector(hash);
|
|
if (element) element.scrollIntoView({ behavior: "smooth" });
|
|
} catch (err) {
|
|
// querySelector will throw an error if the hash begins with a number
|
|
// or contains a period. This is protected against now by safeSlugify
|
|
// however previous links may be in the wild.
|
|
console.warn(`Attempted to scroll to invalid hash: ${hash}`, err);
|
|
}
|
|
}
|
|
|
|
calculateDir = () => {
|
|
if (!this.element) return;
|
|
|
|
const isRTL =
|
|
this.props.dir === "rtl" ||
|
|
getComputedStyle(this.element).direction === "rtl";
|
|
|
|
if (this.state.isRTL !== isRTL) {
|
|
this.setState({ isRTL });
|
|
}
|
|
};
|
|
|
|
value = (): string => {
|
|
return this.serializer.serialize(this.view.state.doc);
|
|
};
|
|
|
|
handleChange = () => {
|
|
if (!this.props.onChange) return;
|
|
|
|
this.props.onChange(() => {
|
|
return this.value();
|
|
});
|
|
};
|
|
|
|
handleSave = () => {
|
|
const { onSave } = this.props;
|
|
if (onSave) {
|
|
onSave({ done: false });
|
|
}
|
|
};
|
|
|
|
handleSaveAndExit = () => {
|
|
const { onSave } = this.props;
|
|
if (onSave) {
|
|
onSave({ done: true });
|
|
}
|
|
};
|
|
|
|
handleEditorBlur = () => {
|
|
this.setState({ isEditorFocused: false });
|
|
};
|
|
|
|
handleEditorFocus = () => {
|
|
this.setState({ isEditorFocused: true });
|
|
};
|
|
|
|
handleOpenSelectionMenu = () => {
|
|
this.setState({ blockMenuOpen: false, selectionMenuOpen: true });
|
|
};
|
|
|
|
handleCloseSelectionMenu = () => {
|
|
this.setState({ selectionMenuOpen: false });
|
|
};
|
|
|
|
handleOpenLinkMenu = () => {
|
|
this.setState({ blockMenuOpen: false, linkMenuOpen: true });
|
|
};
|
|
|
|
handleCloseLinkMenu = () => {
|
|
this.setState({ linkMenuOpen: false });
|
|
};
|
|
|
|
handleOpenBlockMenu = (search: string) => {
|
|
this.setState({ blockMenuOpen: true, blockMenuSearch: search });
|
|
};
|
|
|
|
handleCloseBlockMenu = () => {
|
|
if (!this.state.blockMenuOpen) return;
|
|
this.setState({ blockMenuOpen: false });
|
|
};
|
|
|
|
handleSelectRow = (index: number, state: EditorState) => {
|
|
this.view.dispatch(selectRow(index)(state.tr));
|
|
};
|
|
|
|
handleSelectColumn = (index: number, state: EditorState) => {
|
|
this.view.dispatch(selectColumn(index)(state.tr));
|
|
};
|
|
|
|
handleSelectTable = (state: EditorState) => {
|
|
this.view.dispatch(selectTable(state.tr));
|
|
};
|
|
|
|
// 'public' methods
|
|
focusAtStart = () => {
|
|
const selection = Selection.atStart(this.view.state.doc);
|
|
const transaction = this.view.state.tr.setSelection(selection);
|
|
this.view.dispatch(transaction);
|
|
this.view.focus();
|
|
};
|
|
|
|
focusAtEnd = () => {
|
|
const selection = Selection.atEnd(this.view.state.doc);
|
|
const transaction = this.view.state.tr.setSelection(selection);
|
|
this.view.dispatch(transaction);
|
|
this.view.focus();
|
|
};
|
|
|
|
getHeadings = () => {
|
|
const headings: { title: string; level: number; id: string }[] = [];
|
|
const previouslySeen = {};
|
|
|
|
this.view.state.doc.forEach((node) => {
|
|
if (node.type.name === "heading") {
|
|
// calculate the optimal slug
|
|
const slug = headingToSlug(node);
|
|
let id = slug;
|
|
|
|
// check if we've already used it, and if so how many times?
|
|
// Make the new id based on that number ensuring that we have
|
|
// unique ID's even when headings are identical
|
|
if (previouslySeen[slug] > 0) {
|
|
id = headingToSlug(node, previouslySeen[slug]);
|
|
}
|
|
|
|
// record that we've seen this slug for the next loop
|
|
previouslySeen[slug] =
|
|
previouslySeen[slug] !== undefined ? previouslySeen[slug] + 1 : 1;
|
|
|
|
headings.push({
|
|
title: node.textContent,
|
|
level: node.attrs.level,
|
|
id,
|
|
});
|
|
}
|
|
});
|
|
return headings;
|
|
};
|
|
|
|
render() {
|
|
const {
|
|
dir,
|
|
readOnly,
|
|
readOnlyWriteCheckboxes,
|
|
grow,
|
|
style,
|
|
className,
|
|
dictionary,
|
|
onKeyDown,
|
|
} = this.props;
|
|
const { isRTL } = this.state;
|
|
|
|
return (
|
|
<Flex
|
|
onKeyDown={onKeyDown}
|
|
style={style}
|
|
className={className}
|
|
align="flex-start"
|
|
justify="center"
|
|
dir={dir}
|
|
column
|
|
>
|
|
<EditorContainer
|
|
dir={dir}
|
|
rtl={isRTL}
|
|
grow={grow}
|
|
readOnly={readOnly}
|
|
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
|
|
ref={(ref) => (this.element = ref)}
|
|
/>
|
|
{!readOnly && this.view && (
|
|
<React.Fragment>
|
|
<SelectionToolbar
|
|
view={this.view}
|
|
dictionary={dictionary}
|
|
commands={this.commands}
|
|
rtl={isRTL}
|
|
isTemplate={this.props.template === true}
|
|
onOpen={this.handleOpenSelectionMenu}
|
|
onClose={this.handleCloseSelectionMenu}
|
|
onSearchLink={this.props.onSearchLink}
|
|
onClickLink={this.props.onClickLink}
|
|
onCreateLink={this.props.onCreateLink}
|
|
/>
|
|
<LinkToolbar
|
|
view={this.view}
|
|
dictionary={dictionary}
|
|
isActive={this.state.linkMenuOpen}
|
|
onCreateLink={this.props.onCreateLink}
|
|
onSearchLink={this.props.onSearchLink}
|
|
onClickLink={this.props.onClickLink}
|
|
onShowToast={this.props.onShowToast}
|
|
onClose={this.handleCloseLinkMenu}
|
|
/>
|
|
<EmojiMenu
|
|
view={this.view}
|
|
commands={this.commands}
|
|
dictionary={dictionary}
|
|
rtl={isRTL}
|
|
isActive={this.state.emojiMenuOpen}
|
|
search={this.state.blockMenuSearch}
|
|
onClose={() => this.setState({ emojiMenuOpen: false })}
|
|
/>
|
|
<BlockMenu
|
|
view={this.view}
|
|
commands={this.commands}
|
|
dictionary={dictionary}
|
|
rtl={isRTL}
|
|
isActive={this.state.blockMenuOpen}
|
|
search={this.state.blockMenuSearch}
|
|
onClose={this.handleCloseBlockMenu}
|
|
uploadImage={this.props.uploadImage}
|
|
onLinkToolbarOpen={this.handleOpenLinkMenu}
|
|
onImageUploadStart={this.props.onImageUploadStart}
|
|
onImageUploadStop={this.props.onImageUploadStop}
|
|
onShowToast={this.props.onShowToast}
|
|
embeds={this.props.embeds}
|
|
/>
|
|
</React.Fragment>
|
|
)}
|
|
</Flex>
|
|
);
|
|
}
|
|
}
|
|
|
|
const EditorWithTheme = React.forwardRef<Editor, Props>((props: Props, ref) => {
|
|
return (
|
|
<WithTheme>
|
|
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
|
|
</WithTheme>
|
|
);
|
|
});
|
|
|
|
export default EditorWithTheme;
|