/* 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, { CommandFactory } from "@shared/editor/lib/Extension"; import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import headingToSlug from "@shared/editor/lib/headingToSlug"; import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; // marks import Bold from "@shared/editor/marks/Bold"; import Code from "@shared/editor/marks/Code"; import Highlight from "@shared/editor/marks/Highlight"; import Italic from "@shared/editor/marks/Italic"; import Link from "@shared/editor/marks/Link"; import TemplatePlaceholder from "@shared/editor/marks/Placeholder"; import Strikethrough from "@shared/editor/marks/Strikethrough"; import Underline from "@shared/editor/marks/Underline"; // nodes import 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; /** 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; /** Callback when user searches for documents from link insert interface */ onSearchLink?: (term: string) => Promise; /** Callback when user clicks on any link in the document */ onClickLink: ( href: string, event: MouseEvent | React.MouseEvent ) => 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) => 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, 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; 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 ( (this.element = ref)} /> {!readOnly && this.view && ( this.setState({ emojiMenuOpen: false })} /> )} ); } } const EditorWithTheme = React.forwardRef((props: Props, ref) => { return ( {(theme) => } ); }); export default EditorWithTheme;