diff --git a/.eslintrc b/.eslintrc index 362f0025a..b3edf6125 100644 --- a/.eslintrc +++ b/.eslintrc @@ -26,6 +26,7 @@ "rules": { "eqeqeq": 2, "no-mixed-operators": "off", + "no-useless-escape": "off", "@typescript-eslint/no-unused-vars": [ "error", { @@ -109,8 +110,5 @@ "import/resolver": { "typescript": {} } - }, - "globals": { - "EDITOR_VERSION": true } } \ No newline at end of file diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index e1253800f..c69f26ebf 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -12,10 +12,10 @@ import { PinIcon, } from "outline-icons"; import * as React from "react"; +import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; import DocumentTemplatize from "~/scenes/DocumentTemplatize"; import { createAction } from "~/actions"; import { DocumentSection } from "~/actions/sections"; -import getDataTransferFiles from "~/utils/getDataTransferFiles"; import history from "~/utils/history"; import { homePath, newDocumentPath } from "~/utils/routeHelpers"; diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index c2b2cb769..e40b5a203 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -1,26 +1,21 @@ -import { lighten } from "polished"; import * as React from "react"; -import { Props as EditorProps } from "rich-markdown-editor"; -import { EmbedDescriptor } from "rich-markdown-editor/dist/types"; -import styled, { useTheme } from "styled-components"; import { Optional } from "utility-types"; -import embeds from "@shared/embeds"; -import { light } from "@shared/theme"; +import embeds from "@shared/editor/embeds"; +import { EmbedDescriptor } from "@shared/editor/types"; import ErrorBoundary from "~/components/ErrorBoundary"; -import Tooltip from "~/components/Tooltip"; +import { Props as EditorProps } from "~/editor"; import useDictionary from "~/hooks/useDictionary"; -import useMediaQuery from "~/hooks/useMediaQuery"; import useToasts from "~/hooks/useToasts"; import history from "~/utils/history"; import { isModKey } from "~/utils/keyboard"; import { uploadFile } from "~/utils/uploadFile"; import { isInternalUrl, isHash } from "~/utils/urls"; -const RichMarkdownEditor = React.lazy( +const SharedEditor = React.lazy( () => import( - /* webpackChunkName: "rich-markdown-editor" */ - "rich-markdown-editor" + /* webpackChunkName: "shared-editor" */ + "~/editor" ) ); @@ -28,7 +23,7 @@ const EMPTY_ARRAY: EmbedDescriptor[] = []; export type Props = Optional< EditorProps, - "placeholder" | "defaultValue" | "tooltip" | "onClickLink" | "embeds" + "placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary" > & { shareId?: string | undefined; disableEmbeds?: boolean; @@ -39,9 +34,7 @@ export type Props = Optional< function Editor(props: Props, ref: React.Ref) { const { id, shareId } = props; - const theme = useTheme(); const { showToast } = useToasts(); - const isPrinting = useMediaQuery("print"); const dictionary = useDictionary(); const onUploadImage = React.useCallback( @@ -97,144 +90,19 @@ function Editor(props: Props, ref: React.Ref) { return ( - ); } -const StyledEditor = styled(RichMarkdownEditor)<{ grow?: boolean }>` - flex-grow: ${(props) => (props.grow ? 1 : 0)}; - justify-content: start; - - > div { - background: transparent; - } - - & * { - box-sizing: content-box; - } - - .notice-block.tip, - .notice-block.warning { - font-weight: 500; - } - - .heading-anchor { - box-sizing: border-box; - } - - .heading-name { - pointer-events: none; - display: block; - position: relative; - top: -60px; - visibility: hidden; - } - - .heading-name:first-child, - .heading-name:first-child + .ProseMirror-yjs-cursor { - & + h1, - & + h2, - & + h3, - & + h4 { - margin-top: 0; - } - } - - p { - a { - color: ${(props) => props.theme.text}; - border-bottom: 1px solid ${(props) => lighten(0.5, props.theme.text)}; - text-decoration: none !important; - font-weight: 500; - - &:hover { - border-bottom: 1px solid ${(props) => props.theme.text}; - text-decoration: none; - } - } - } - - .ProseMirror { - & > .ProseMirror-yjs-cursor { - display: none; - } - - .ProseMirror-yjs-cursor { - position: relative; - margin-left: -1px; - margin-right: -1px; - border-left: 1px solid black; - border-right: 1px solid black; - height: 1em; - word-break: normal; - - &:after { - content: ""; - display: block; - position: absolute; - left: -8px; - right: -8px; - top: 0; - bottom: 0; - } - > div { - opacity: 0; - transition: opacity 100ms ease-in-out; - position: absolute; - top: -1.8em; - font-size: 13px; - background-color: rgb(250, 129, 0); - font-style: normal; - line-height: normal; - user-select: none; - white-space: nowrap; - color: white; - padding: 2px 6px; - font-weight: 500; - border-radius: 4px; - pointer-events: none; - left: -1px; - } - - &:hover { - > div { - opacity: 1; - } - } - } - } - - &.show-cursor-names .ProseMirror-yjs-cursor > div { - opacity: 1; - } -`; - -type TooltipProps = { - children: React.ReactNode; - tooltip: string; -}; - -const EditorTooltip = ({ children, tooltip, ...props }: TooltipProps) => ( - - {children} - -); - -const TooltipContent = styled.span` - outline: none; -`; - export default React.forwardRef(Editor); diff --git a/app/components/Theme.tsx b/app/components/Theme.tsx index d6a3bc4e4..424d099a7 100644 --- a/app/components/Theme.tsx +++ b/app/components/Theme.tsx @@ -15,9 +15,10 @@ function Theme({ children }: Props) { const theme = ui.resolvedTheme === "dark" ? dark : light; const mobileTheme = ui.resolvedTheme === "dark" ? darkMobile : lightMobile; const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.tablet}px)`); + const isPrinting = useMediaQuery("print"); return ( - + <> {children} diff --git a/app/editor/components/BlockMenu.tsx b/app/editor/components/BlockMenu.tsx new file mode 100644 index 000000000..c4ec0b20b --- /dev/null +++ b/app/editor/components/BlockMenu.tsx @@ -0,0 +1,50 @@ +import { findParentNode } from "prosemirror-utils"; +import React from "react"; +import getMenuItems from "../menus/block"; +import BlockMenuItem from "./BlockMenuItem"; +import CommandMenu, { Props } from "./CommandMenu"; + +type BlockMenuProps = Omit< + Props, + "renderMenuItem" | "items" | "onClearSearch" +> & + Required>; + +class BlockMenu extends React.Component { + get items() { + return getMenuItems(this.props.dictionary); + } + + clearSearch = () => { + const { state, dispatch } = this.props.view; + const parent = findParentNode((node) => !!node)(state.selection); + + if (parent) { + dispatch(state.tr.insertText("", parent.pos, state.selection.to)); + } + }; + + render() { + return ( + { + return ( + + ); + }} + items={this.items} + /> + ); + } +} + +export default BlockMenu; diff --git a/app/editor/components/BlockMenuItem.tsx b/app/editor/components/BlockMenuItem.tsx new file mode 100644 index 000000000..951bd6aaf --- /dev/null +++ b/app/editor/components/BlockMenuItem.tsx @@ -0,0 +1,110 @@ +import * as React from "react"; +import scrollIntoView from "smooth-scroll-into-view-if-needed"; +import styled, { useTheme } from "styled-components"; + +export type Props = { + selected: boolean; + disabled?: boolean; + onClick: () => void; + icon?: typeof React.Component | React.FC; + title: React.ReactNode; + shortcut?: string; + containerId?: string; +}; + +function BlockMenuItem({ + selected, + disabled, + onClick, + title, + shortcut, + icon, + containerId = "block-menu-container", +}: Props) { + const Icon = icon; + const theme = useTheme(); + + const ref = React.useCallback( + (node) => { + if (selected && node) { + scrollIntoView(node, { + scrollMode: "if-needed", + block: "center", + boundary: (parent) => { + // All the parent elements of your target are checked until they + // reach the #block-menu-container. Prevents body and other parent + // elements from being scrolled + return parent.id !== containerId; + }, + }); + } + }, + [selected, containerId] + ); + + return ( + + {Icon && ( + <> + +    + + )} + {title} + {shortcut && {shortcut}} + + ); +} + +const MenuItem = styled.button<{ + selected: boolean; +}>` + display: flex; + align-items: center; + justify-content: flex-start; + font-weight: 500; + font-size: 14px; + line-height: 1; + width: 100%; + height: 36px; + cursor: pointer; + border: none; + opacity: ${(props) => (props.disabled ? ".5" : "1")}; + color: ${(props) => + props.selected + ? props.theme.blockToolbarTextSelected + : props.theme.blockToolbarText}; + background: ${(props) => + props.selected + ? props.theme.blockToolbarSelectedBackground || + props.theme.blockToolbarTrigger + : "none"}; + padding: 0 16px; + outline: none; + + &:hover, + &:active { + color: ${(props) => props.theme.blockToolbarTextSelected}; + background: ${(props) => + props.selected + ? props.theme.blockToolbarSelectedBackground || + props.theme.blockToolbarTrigger + : props.theme.blockToolbarHoverBackground}; + } +`; + +const Shortcut = styled.span` + color: ${(props) => props.theme.textSecondary}; + flex-grow: 1; + text-align: right; +`; + +export default BlockMenuItem; diff --git a/app/editor/components/CommandMenu.tsx b/app/editor/components/CommandMenu.tsx new file mode 100644 index 000000000..49bc5d7ee --- /dev/null +++ b/app/editor/components/CommandMenu.tsx @@ -0,0 +1,614 @@ +import { capitalize } from "lodash"; +import { findDomRefAtPos, findParentNode } from "prosemirror-utils"; +import { EditorView } from "prosemirror-view"; +import * as React from "react"; +import { Portal } from "react-portal"; +import { VisuallyHidden } from "reakit/VisuallyHidden"; +import styled from "styled-components"; +import insertFiles from "@shared/editor/commands/insertFiles"; +import { CommandFactory } from "@shared/editor/lib/Extension"; +import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; +import { EmbedDescriptor, MenuItem, ToastType } from "@shared/editor/types"; +import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; +import { Dictionary } from "~/hooks/useDictionary"; +import Input from "./Input"; + +const defaultPosition = { + left: -1000, + top: 0, + bottom: undefined, + isAbove: false, +}; + +export type Props = { + rtl: boolean; + isActive: boolean; + commands: Record; + dictionary: Dictionary; + view: EditorView; + search: string; + uploadImage?: (file: File) => Promise; + onImageUploadStart?: () => void; + onImageUploadStop?: () => void; + onShowToast?: (message: string, id: string) => void; + onLinkToolbarOpen?: () => void; + onClose: () => void; + onClearSearch: () => void; + embeds?: EmbedDescriptor[]; + renderMenuItem: ( + item: T, + index: number, + options: { + selected: boolean; + onClick: () => void; + } + ) => React.ReactNode; + filterable?: boolean; + items: T[]; + id?: string; +}; + +type State = { + insertItem?: EmbedDescriptor; + left?: number; + top?: number; + bottom?: number; + isAbove: boolean; + selectedIndex: number; +}; + +class CommandMenu extends React.Component, State> { + menuRef = React.createRef(); + inputRef = React.createRef(); + + state: State = { + left: -1000, + top: 0, + bottom: undefined, + isAbove: false, + selectedIndex: 0, + insertItem: undefined, + }; + + componentDidMount() { + window.addEventListener("keydown", this.handleKeyDown); + } + + shouldComponentUpdate(nextProps: Props, nextState: State) { + return ( + nextProps.search !== this.props.search || + nextProps.isActive !== this.props.isActive || + nextState !== this.state + ); + } + + componentDidUpdate(prevProps: Props) { + if (!prevProps.isActive && this.props.isActive) { + const position = this.calculatePosition(this.props); + + this.setState({ + insertItem: undefined, + selectedIndex: 0, + ...position, + }); + } else if (prevProps.search !== this.props.search) { + this.setState({ selectedIndex: 0 }); + } + } + + componentWillUnmount() { + window.removeEventListener("keydown", this.handleKeyDown); + } + + handleKeyDown = (event: KeyboardEvent) => { + if (!this.props.isActive) return; + + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + + const item = this.filtered[this.state.selectedIndex]; + + if (item) { + this.insertItem(item); + } else { + this.props.onClose(); + } + } + + if ( + event.key === "ArrowUp" || + (event.key === "Tab" && event.shiftKey) || + (event.ctrlKey && event.key === "p") + ) { + event.preventDefault(); + event.stopPropagation(); + + if (this.filtered.length) { + const prevIndex = this.state.selectedIndex - 1; + const prev = this.filtered[prevIndex]; + + this.setState({ + selectedIndex: Math.max( + 0, + prev && prev.name === "separator" ? prevIndex - 1 : prevIndex + ), + }); + } else { + this.close(); + } + } + + if ( + event.key === "ArrowDown" || + (event.key === "Tab" && !event.shiftKey) || + (event.ctrlKey && event.key === "n") + ) { + event.preventDefault(); + event.stopPropagation(); + + if (this.filtered.length) { + const total = this.filtered.length - 1; + const nextIndex = this.state.selectedIndex + 1; + const next = this.filtered[nextIndex]; + + this.setState({ + selectedIndex: Math.min( + next && next.name === "separator" ? nextIndex + 1 : nextIndex, + total + ), + }); + } else { + this.close(); + } + } + + if (event.key === "Escape") { + this.close(); + } + }; + + insertItem = (item: any) => { + switch (item.name) { + case "image": + return this.triggerImagePick(); + case "embed": + return this.triggerLinkInput(item); + case "link": { + this.clearSearch(); + this.props.onClose(); + this.props.onLinkToolbarOpen?.(); + return; + } + default: + this.insertBlock(item); + } + }; + + close = () => { + this.props.onClose(); + this.props.view.focus(); + }; + + handleLinkInputKeydown = (event: React.KeyboardEvent) => { + if (!this.props.isActive) return; + if (!this.state.insertItem) return; + + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + + const href = event.currentTarget.value; + const matches = this.state.insertItem.matcher(href); + + if (!matches && this.props.onShowToast) { + this.props.onShowToast( + this.props.dictionary.embedInvalidLink, + ToastType.Error + ); + return; + } + + this.insertBlock({ + name: "embed", + attrs: { + href, + }, + }); + } + + if (event.key === "Escape") { + this.props.onClose(); + this.props.view.focus(); + } + }; + + handleLinkInputPaste = (event: React.ClipboardEvent) => { + if (!this.props.isActive) return; + if (!this.state.insertItem) return; + + const href = event.clipboardData.getData("text/plain"); + const matches = this.state.insertItem.matcher(href); + + if (matches) { + event.preventDefault(); + event.stopPropagation(); + + this.insertBlock({ + name: "embed", + attrs: { + href, + }, + }); + } + }; + + triggerImagePick = () => { + if (this.inputRef.current) { + this.inputRef.current.click(); + } + }; + + triggerLinkInput = (item: EmbedDescriptor) => { + this.setState({ insertItem: item }); + }; + + handleImagePicked = (event: React.ChangeEvent) => { + const files = getDataTransferFiles(event); + + const { + view, + uploadImage, + onImageUploadStart, + onImageUploadStop, + onShowToast, + } = this.props; + const { state } = view; + const parent = findParentNode((node) => !!node)(state.selection); + + this.clearSearch(); + + if (!uploadImage) { + throw new Error("uploadImage prop is required to replace images"); + } + + if (parent) { + insertFiles(view, event, parent.pos, files, { + uploadImage, + onImageUploadStart, + onImageUploadStop, + onShowToast, + dictionary: this.props.dictionary, + }); + } + + if (this.inputRef.current) { + this.inputRef.current.value = ""; + } + + this.props.onClose(); + }; + + clearSearch = () => { + this.props.onClearSearch(); + }; + + insertBlock(item: MenuItem) { + this.clearSearch(); + + const command = item.name ? this.props.commands[item.name] : undefined; + + if (command) { + command(item.attrs); + } else { + this.props.commands[`create${capitalize(item.name)}`](item.attrs); + } + + this.props.onClose(); + } + + get caretPosition(): { top: number; left: number } { + const selection = window.document.getSelection(); + if (!selection || !selection.anchorNode || !selection.focusNode) { + return { + top: 0, + left: 0, + }; + } + + const range = window.document.createRange(); + range.setStart(selection.anchorNode, selection.anchorOffset); + range.setEnd(selection.focusNode, selection.focusOffset); + + // This is a workaround for an edgecase where getBoundingClientRect will + // return zero values if the selection is collapsed at the start of a newline + // see reference here: https://stackoverflow.com/a/59780954 + const rects = range.getClientRects(); + if (rects.length === 0) { + // probably buggy newline behavior, explicitly select the node contents + if (range.startContainer && range.collapsed) { + range.selectNodeContents(range.startContainer); + } + } + + const rect = range.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + }; + } + + calculatePosition(props: Props) { + const { view } = props; + const { selection } = view.state; + let startPos; + try { + startPos = view.coordsAtPos(selection.from); + } catch (err) { + console.warn(err); + return defaultPosition; + } + + const domAtPos = view.domAtPos.bind(view); + + const ref = this.menuRef.current; + const offsetHeight = ref ? ref.offsetHeight : 0; + const node = findDomRefAtPos(selection.from, domAtPos); + const paragraph: any = { node }; + + if ( + !props.isActive || + !paragraph.node || + !paragraph.node.getBoundingClientRect + ) { + return defaultPosition; + } + + const { left } = this.caretPosition; + const { top, bottom, right } = paragraph.node.getBoundingClientRect(); + const margin = 24; + + let leftPos = left + window.scrollX; + if (props.rtl && ref) { + leftPos = right - ref.scrollWidth; + } + + if (startPos.top - offsetHeight > margin) { + return { + left: leftPos, + top: undefined, + bottom: window.innerHeight - top - window.scrollY, + isAbove: false, + }; + } else { + return { + left: leftPos, + top: bottom + window.scrollY, + bottom: undefined, + isAbove: true, + }; + } + } + + get filtered() { + const { + embeds = [], + search = "", + uploadImage, + commands, + filterable = true, + } = this.props; + let items: (EmbedDescriptor | MenuItem)[] = this.props.items; + const embedItems: EmbedDescriptor[] = []; + + for (const embed of embeds) { + if (embed.title && embed.icon) { + embedItems.push({ + ...embed, + name: "embed", + }); + } + } + + if (embedItems.length) { + items.push({ + name: "separator", + }); + items = items.concat(embedItems); + } + + const filtered = items.filter((item) => { + if (item.name === "separator") return true; + + // Some extensions may be disabled, remove corresponding menu items + if ( + item.name && + !commands[item.name] && + !commands[`create${capitalize(item.name)}`] + ) { + return false; + } + + // If no image upload callback has been passed, filter the image block out + if (!uploadImage && item.name === "image") return false; + + // some items (defaultHidden) are not visible until a search query exists + if (!search) return !item.defaultHidden; + + const n = search.toLowerCase(); + if (!filterable) { + return item; + } + return ( + (item.title || "").toLowerCase().includes(n) || + (item.keywords || "").toLowerCase().includes(n) + ); + }); + + return filterExcessSeparators(filtered); + } + + render() { + const { dictionary, isActive, uploadImage } = this.props; + const items = this.filtered; + const { insertItem, ...positioning } = this.state; + + return ( + + + {insertItem ? ( + + + + ) : ( + + {items.map((item, index) => { + if (item.name === "separator") { + return ( + +
+
+ ); + } + const selected = index === this.state.selectedIndex && isActive; + + if (!item.title) { + return null; + } + + return ( + + {this.props.renderMenuItem(item as any, index, { + selected, + onClick: () => this.insertItem(item), + })} + + ); + })} + {items.length === 0 && ( + + {dictionary.noResults} + + )} +
+ )} + {uploadImage && ( + + + + )} +
+
+ ); + } +} + +const LinkInputWrapper = styled.div` + margin: 8px; +`; + +const LinkInput = styled(Input)` + height: 36px; + width: 100%; + color: ${(props) => props.theme.blockToolbarText}; +`; + +const List = styled.ol` + list-style: none; + text-align: left; + height: 100%; + padding: 8px 0; + margin: 0; +`; + +const ListItem = styled.li` + padding: 0; + margin: 0; +`; + +const Empty = styled.div` + display: flex; + align-items: center; + color: ${(props) => props.theme.textSecondary}; + font-weight: 500; + font-size: 14px; + height: 36px; + padding: 0 16px; +`; + +export const Wrapper = styled.div<{ + active: boolean; + top?: number; + bottom?: number; + left?: number; + isAbove: boolean; +}>` + color: ${(props) => props.theme.text}; + font-family: ${(props) => props.theme.fontFamily}; + position: absolute; + z-index: ${(props) => props.theme.zIndex + 100}; + ${(props) => props.top !== undefined && `top: ${props.top}px`}; + ${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`}; + left: ${(props) => props.left}px; + background-color: ${(props) => props.theme.blockToolbarBackground}; + border-radius: 4px; + box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px, + rgba(0, 0, 0, 0.08) 0px 4px 8px, rgba(0, 0, 0, 0.08) 0px 2px 4px; + opacity: 0; + transform: scale(0.95); + transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275), + transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275); + transition-delay: 150ms; + line-height: 0; + box-sizing: border-box; + pointer-events: none; + white-space: nowrap; + width: 300px; + max-height: 224px; + overflow: hidden; + overflow-y: auto; + + * { + box-sizing: border-box; + } + + hr { + border: 0; + height: 0; + border-top: 1px solid ${(props) => props.theme.blockToolbarDivider}; + } + + ${({ active, isAbove }) => + active && + ` + transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1); + pointer-events: all; + opacity: 1; + `}; + + @media print { + display: none; + } +`; + +export default CommandMenu; diff --git a/app/editor/components/ComponentView.tsx b/app/editor/components/ComponentView.tsx new file mode 100644 index 000000000..052e51cb3 --- /dev/null +++ b/app/editor/components/ComponentView.tsx @@ -0,0 +1,116 @@ +import { Node as ProsemirrorNode } from "prosemirror-model"; +import { EditorView, Decoration } from "prosemirror-view"; +import * as React from "react"; +import ReactDOM from "react-dom"; +import { ThemeProvider } from "styled-components"; +import Extension from "@shared/editor/lib/Extension"; +import { ComponentProps } from "@shared/editor/types"; +import { Editor } from "~/editor"; + +type Component = (props: ComponentProps) => React.ReactElement; + +export default class ComponentView { + component: Component; + editor: Editor; + extension: Extension; + node: ProsemirrorNode; + view: EditorView; + getPos: () => number; + decorations: Decoration<{ + [key: string]: any; + }>[]; + + isSelected = false; + dom: HTMLElement | null; + + // See https://prosemirror.net/docs/ref/#view.NodeView + constructor( + component: Component, + { + editor, + extension, + node, + view, + getPos, + decorations, + }: { + editor: Editor; + extension: Extension; + node: ProsemirrorNode; + view: EditorView; + getPos: () => number; + decorations: Decoration<{ + [key: string]: any; + }>[]; + } + ) { + this.component = component; + this.editor = editor; + this.extension = extension; + this.getPos = getPos; + this.decorations = decorations; + this.node = node; + this.view = view; + this.dom = node.type.spec.inline + ? document.createElement("span") + : document.createElement("div"); + + this.renderElement(); + } + + renderElement() { + const { theme } = this.editor.props; + + const children = this.component({ + theme, + node: this.node, + isSelected: this.isSelected, + isEditable: this.view.editable, + getPos: this.getPos, + }); + + ReactDOM.render( + {children}, + this.dom + ); + } + + update(node: ProsemirrorNode) { + if (node.type !== this.node.type) { + return false; + } + + this.node = node; + this.renderElement(); + return true; + } + + selectNode() { + if (this.view.editable) { + this.isSelected = true; + this.renderElement(); + } + } + + deselectNode() { + if (this.view.editable) { + this.isSelected = false; + this.renderElement(); + } + } + + stopEvent() { + return true; + } + + destroy() { + if (this.dom) { + ReactDOM.unmountComponentAtNode(this.dom); + } + this.dom = null; + } + + ignoreMutation() { + return true; + } +} diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx new file mode 100644 index 000000000..5e58c5ca6 --- /dev/null +++ b/app/editor/components/EmojiMenu.tsx @@ -0,0 +1,90 @@ +import FuzzySearch from "fuzzy-search"; +import gemojies from "gemoji"; +import React from "react"; +import CommandMenu, { Props } from "./CommandMenu"; +import EmojiMenuItem from "./EmojiMenuItem"; + +type Emoji = { + name: string; + title: string; + emoji: string; + description: string; + attrs: { markup: string; "data-name": string }; +}; + +const searcher = new FuzzySearch<{ + names: string[]; + description: string; + emoji: string; +}>(gemojies, ["names"], { + caseSensitive: true, + sort: true, +}); + +class EmojiMenu extends React.Component< + Omit< + Props, + | "renderMenuItem" + | "items" + | "onLinkToolbarOpen" + | "embeds" + | "onClearSearch" + > +> { + get items(): Emoji[] { + const { search = "" } = this.props; + + const n = search.toLowerCase(); + const result = searcher.search(n).map((item) => { + const description = item.description; + const name = item.names[0]; + return { + ...item, + name: "emoji", + title: name, + description, + attrs: { markup: name, "data-name": name }, + }; + }); + + return result.slice(0, 10); + } + + clearSearch = () => { + const { state, dispatch } = this.props.view; + + // clear search input + dispatch( + state.tr.insertText( + "", + state.selection.$from.pos - (this.props.search ?? "").length - 1, + state.selection.to + ) + ); + }; + + render() { + return ( + { + return ( + + ); + }} + items={this.items} + /> + ); + } +} + +export default EmojiMenu; diff --git a/app/editor/components/EmojiMenuItem.tsx b/app/editor/components/EmojiMenuItem.tsx new file mode 100644 index 000000000..e86c0dddc --- /dev/null +++ b/app/editor/components/EmojiMenuItem.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import styled from "styled-components"; +import BlockMenuItem, { Props as BlockMenuItemProps } from "./BlockMenuItem"; + +const Emoji = styled.span` + font-size: 16px; +`; + +type Props = { + emoji: React.ReactNode; + title: React.ReactNode; +}; + +const EmojiTitle = ({ emoji, title }: Props) => { + return ( +

+ {emoji} +    + {title} +

+ ); +}; + +type EmojiMenuItemProps = Omit & { + emoji: string; +}; + +export default function EmojiMenuItem(props: EmojiMenuItemProps) { + return ( + } + /> + ); +} diff --git a/app/editor/components/FloatingToolbar.tsx b/app/editor/components/FloatingToolbar.tsx new file mode 100644 index 000000000..7778393bc --- /dev/null +++ b/app/editor/components/FloatingToolbar.tsx @@ -0,0 +1,267 @@ +import { NodeSelection } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; +import { EditorView } from "prosemirror-view"; +import * as React from "react"; +import { Portal } from "react-portal"; +import styled from "styled-components"; +import useComponentSize from "~/hooks/useComponentSize"; +import useMediaQuery from "~/hooks/useMediaQuery"; +import useViewportHeight from "~/hooks/useViewportHeight"; + +type Props = { + active?: boolean; + view: EditorView; + children: React.ReactNode; + forwardedRef?: React.RefObject | null; +}; + +const defaultPosition = { + left: -1000, + top: 0, + offset: 0, + visible: false, +}; + +function usePosition({ + menuRef, + isSelectingText, + props, +}: { + menuRef: React.RefObject; + isSelectingText: boolean; + props: Props; +}) { + const { view, active } = props; + const { selection } = view.state; + const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef); + const viewportHeight = useViewportHeight(); + const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)"); + + if (!active || !menuWidth || !menuHeight || isSelectingText) { + return defaultPosition; + } + + // If we're on a mobile device then stick the floating toolbar to the bottom + // of the screen above the virtual keyboard. + if (isTouchDevice && viewportHeight) { + return { + left: 0, + right: 0, + top: viewportHeight - menuHeight, + offset: 0, + visible: true, + }; + } + + // based on the start and end of the selection calculate the position at + // the center top + let fromPos; + let toPos; + try { + fromPos = view.coordsAtPos(selection.from); + toPos = view.coordsAtPos(selection.to, -1); + } catch (err) { + console.warn(err); + return defaultPosition; + } + + // ensure that start < end for the menu to be positioned correctly + const selectionBounds = { + top: Math.min(fromPos.top, toPos.top), + bottom: Math.max(fromPos.bottom, toPos.bottom), + left: Math.min(fromPos.left, toPos.left), + right: Math.max(fromPos.right, toPos.right), + }; + + // tables are an oddity, and need their own positioning logic + const isColSelection = + selection instanceof CellSelection && + selection.isColSelection && + selection.isColSelection(); + const isRowSelection = + selection instanceof CellSelection && + selection.isRowSelection && + selection.isRowSelection(); + + if (isColSelection) { + const { node: element } = view.domAtPos(selection.from); + const { width } = (element as HTMLElement).getBoundingClientRect(); + selectionBounds.top -= 20; + selectionBounds.right = selectionBounds.left + width; + } + + if (isRowSelection) { + selectionBounds.right = selectionBounds.left = selectionBounds.left - 18; + } + + const isImageSelection = + selection instanceof NodeSelection && selection.node?.type.name === "image"; + + // Images need their own positioning to get the toolbar in the center + if (isImageSelection) { + const element = view.nodeDOM(selection.from); + + // Images are wrapped which impacts positioning - need to traverse through + // p > span > div.image + const imageElement = (element as HTMLElement).getElementsByTagName( + "img" + )[0]; + const { left, top, width } = imageElement.getBoundingClientRect(); + + return { + left: Math.round(left + width / 2 + window.scrollX - menuWidth / 2), + top: Math.round(top + window.scrollY - menuHeight), + offset: 0, + visible: true, + }; + } else { + // calcluate the horizontal center of the selection + const halfSelection = + Math.abs(selectionBounds.right - selectionBounds.left) / 2; + const centerOfSelection = selectionBounds.left + halfSelection; + + // position the menu so that it is centered over the selection except in + // the cases where it would extend off the edge of the screen. In these + // instances leave a margin + const margin = 12; + const left = Math.min( + window.innerWidth - menuWidth - margin, + Math.max(margin, centerOfSelection - menuWidth / 2) + ); + const top = Math.min( + window.innerHeight - menuHeight - margin, + Math.max(margin, selectionBounds.top - menuHeight) + ); + + // if the menu has been offset to not extend offscreen then we should adjust + // the position of the triangle underneath to correctly point to the center + // of the selection still + const offset = left - (centerOfSelection - menuWidth / 2); + return { + left: Math.round(left + window.scrollX), + top: Math.round(top + window.scrollY), + offset: Math.round(offset), + visible: true, + }; + } +} + +function FloatingToolbar(props: Props) { + const menuRef = props.forwardedRef || React.createRef(); + const [isSelectingText, setSelectingText] = React.useState(false); + + const position = usePosition({ + menuRef, + isSelectingText, + props, + }); + + React.useEffect(() => { + const handleMouseDown = () => { + if (!props.active) { + setSelectingText(true); + } + }; + + const handleMouseUp = () => { + setSelectingText(false); + }; + + window.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [props.active]); + + // only render children when state is updated to visible + // to prevent gaining input focus before calculatePosition runs + return ( + + + {position.visible && props.children} + + + ); +} + +const Wrapper = styled.div<{ + active?: boolean; + offset: number; +}>` + will-change: opacity, transform; + padding: 8px 16px; + position: absolute; + z-index: ${(props) => props.theme.zIndex + 100}; + opacity: 0; + background-color: ${(props) => props.theme.toolbarBackground}; + border-radius: 4px; + transform: scale(0.95); + transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275), + transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275); + transition-delay: 150ms; + line-height: 0; + height: 40px; + box-sizing: border-box; + pointer-events: none; + white-space: nowrap; + + &::before { + content: ""; + display: block; + width: 24px; + height: 24px; + transform: translateX(-50%) rotate(45deg); + background: ${(props) => props.theme.toolbarBackground}; + border-radius: 3px; + z-index: -1; + position: absolute; + bottom: -2px; + left: calc(50% - ${(props) => props.offset || 0}px); + pointer-events: none; + } + + * { + box-sizing: border-box; + } + + ${({ active }) => + active && + ` + transform: translateY(-6px) scale(1); + opacity: 1; + `}; + + @media print { + display: none; + } + + @media (hover: none) and (pointer: coarse) { + &:before { + display: none; + } + + transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275); + transform: scale(1); + border-radius: 0; + width: 100vw; + position: fixed; + } +`; + +export default React.forwardRef(function FloatingToolbarWithForwardedRef( + props: Props, + ref: React.RefObject +) { + return ; +}); diff --git a/app/editor/components/Input.tsx b/app/editor/components/Input.tsx new file mode 100644 index 000000000..b6acfe94f --- /dev/null +++ b/app/editor/components/Input.tsx @@ -0,0 +1,19 @@ +import styled from "styled-components"; + +const Input = styled.input` + font-size: 15px; + background: ${(props) => props.theme.toolbarInput}; + color: ${(props) => props.theme.toolbarItem}; + border-radius: 2px; + padding: 3px 8px; + border: 0; + margin: 0; + outline: none; + flex-grow: 1; + + @media (hover: none) and (pointer: coarse) { + font-size: 16px; + } +`; + +export default Input; diff --git a/app/editor/components/LinkEditor.tsx b/app/editor/components/LinkEditor.tsx new file mode 100644 index 000000000..d2b767b13 --- /dev/null +++ b/app/editor/components/LinkEditor.tsx @@ -0,0 +1,403 @@ +import { + DocumentIcon, + CloseIcon, + PlusIcon, + TrashIcon, + OpenIcon, +} from "outline-icons"; +import { Mark } from "prosemirror-model"; +import { setTextSelection } from "prosemirror-utils"; +import { EditorView } from "prosemirror-view"; +import * as React from "react"; +import styled from "styled-components"; +import isUrl from "@shared/editor/lib/isUrl"; +import Flex from "~/components/Flex"; +import { Dictionary } from "~/hooks/useDictionary"; +import Input from "./Input"; +import LinkSearchResult from "./LinkSearchResult"; +import ToolbarButton from "./ToolbarButton"; +import Tooltip from "./Tooltip"; + +export type SearchResult = { + title: string; + subtitle?: string; + url: string; +}; + +type Props = { + mark?: Mark; + from: number; + to: number; + dictionary: Dictionary; + onRemoveLink?: () => void; + onCreateLink?: (title: string) => Promise; + onSearchLink?: (term: string) => Promise; + onSelectLink: (options: { + href: string; + title?: string; + from: number; + to: number; + }) => void; + onClickLink: ( + href: string, + event: React.MouseEvent + ) => void; + onShowToast?: (message: string, code: string) => void; + view: EditorView; +}; + +type State = { + results: { + [keyword: string]: SearchResult[]; + }; + value: string; + previousValue: string; + selectedIndex: number; +}; + +class LinkEditor extends React.Component { + discardInputValue = false; + initialValue = this.href; + initialSelectionLength = this.props.to - this.props.from; + + state: State = { + selectedIndex: -1, + value: this.href, + previousValue: "", + results: {}, + }; + + get href(): string { + return this.props.mark ? this.props.mark.attrs.href : ""; + } + + get suggestedLinkTitle(): string { + const { state } = this.props.view; + const { value } = this.state; + const selectionText = state.doc.cut( + state.selection.from, + state.selection.to + ).textContent; + + return value.trim() || selectionText.trim(); + } + + componentWillUnmount = () => { + // If we discarded the changes then nothing to do + if (this.discardInputValue) { + return; + } + + // If the link is the same as it was when the editor opened, nothing to do + if (this.state.value === this.initialValue) { + return; + } + + // If the link is totally empty or only spaces then remove the mark + const href = (this.state.value || "").trim(); + if (!href) { + return this.handleRemoveLink(); + } + + this.save(href, href); + }; + + save = (href: string, title?: string): void => { + href = href.trim(); + + if (href.length === 0) return; + + this.discardInputValue = true; + const { from, to } = this.props; + + // Make sure a protocol is added to the beginning of the input if it's + // likely an absolute URL that was entered without one. + if ( + !isUrl(href) && + !href.startsWith("/") && + !href.startsWith("#") && + !href.startsWith("mailto:") + ) { + href = `https://${href}`; + } + + this.props.onSelectLink({ href, title, from, to }); + }; + + handleKeyDown = (event: React.KeyboardEvent): void => { + switch (event.key) { + case "Enter": { + event.preventDefault(); + const { selectedIndex, value } = this.state; + const results = this.state.results[value] || []; + const { onCreateLink } = this.props; + + if (selectedIndex >= 0) { + const result = results[selectedIndex]; + if (result) { + this.save(result.url, result.title); + } else if (onCreateLink && selectedIndex === results.length) { + this.handleCreateLink(this.suggestedLinkTitle); + } + } else { + // saves the raw input as href + this.save(value, value); + } + + if (this.initialSelectionLength) { + this.moveSelectionToEnd(); + } + + return; + } + + case "Escape": { + event.preventDefault(); + + if (this.initialValue) { + this.setState({ value: this.initialValue }, this.moveSelectionToEnd); + } else { + this.handleRemoveLink(); + } + return; + } + + case "ArrowUp": { + if (event.shiftKey) return; + event.preventDefault(); + event.stopPropagation(); + const prevIndex = this.state.selectedIndex - 1; + + this.setState({ + selectedIndex: Math.max(-1, prevIndex), + }); + return; + } + + case "ArrowDown": + case "Tab": { + if (event.shiftKey) return; + + event.preventDefault(); + event.stopPropagation(); + const { selectedIndex, value } = this.state; + const results = this.state.results[value] || []; + const total = results.length; + const nextIndex = selectedIndex + 1; + + this.setState({ + selectedIndex: Math.min(nextIndex, total), + }); + return; + } + } + }; + + handleFocusLink = (selectedIndex: number) => { + this.setState({ selectedIndex }); + }; + + handleChange = async ( + event: React.ChangeEvent + ): Promise => { + const value = event.target.value; + + this.setState({ + value, + selectedIndex: -1, + }); + + const trimmedValue = value.trim(); + + if (trimmedValue && this.props.onSearchLink) { + try { + const results = await this.props.onSearchLink(trimmedValue); + this.setState((state) => ({ + results: { + ...state.results, + [trimmedValue]: results, + }, + previousValue: trimmedValue, + })); + } catch (error) { + console.error(error); + } + } + }; + + handlePaste = (): void => { + setTimeout(() => this.save(this.state.value, this.state.value), 0); + }; + + handleOpenLink = (event: React.MouseEvent): void => { + event.preventDefault(); + this.props.onClickLink(this.href, event); + }; + + handleCreateLink = async (value: string) => { + this.discardInputValue = true; + const { onCreateLink } = this.props; + + value = value.trim(); + if (value.length === 0) return; + + if (onCreateLink) return onCreateLink(value); + }; + + handleRemoveLink = (): void => { + this.discardInputValue = true; + + const { from, to, mark, view, onRemoveLink } = this.props; + const { state, dispatch } = this.props.view; + + if (mark) { + dispatch(state.tr.removeMark(from, to, mark)); + } + + if (onRemoveLink) { + onRemoveLink(); + } + + view.focus(); + }; + + handleSelectLink = (url: string, title: string) => ( + event: React.MouseEvent + ) => { + event.preventDefault(); + this.save(url, title); + + if (this.initialSelectionLength) { + this.moveSelectionToEnd(); + } + }; + + moveSelectionToEnd = () => { + const { to, view } = this.props; + const { state, dispatch } = view; + dispatch(setTextSelection(to)(state.tr)); + view.focus(); + }; + + render() { + const { dictionary } = this.props; + const { value, selectedIndex } = this.state; + const results = + this.state.results[value.trim()] || + this.state.results[this.state.previousValue] || + []; + + const looksLikeUrl = value.match(/^https?:\/\//i); + const suggestedLinkTitle = this.suggestedLinkTitle; + + const showCreateLink = + !!this.props.onCreateLink && + !(suggestedLinkTitle === this.initialValue) && + suggestedLinkTitle.length > 0 && + !looksLikeUrl; + + const showResults = + !!suggestedLinkTitle && (showCreateLink || results.length > 0); + + return ( + + + + + + + + + + + {this.initialValue ? ( + + ) : ( + + )} + + + + {showResults && ( + + {results.map((result, index) => ( + } + onMouseOver={() => this.handleFocusLink(index)} + onClick={this.handleSelectLink(result.url, result.title)} + selected={index === selectedIndex} + /> + ))} + + {showCreateLink && ( + } + onMouseOver={() => this.handleFocusLink(results.length)} + onClick={() => { + this.handleCreateLink(suggestedLinkTitle); + + if (this.initialSelectionLength) { + this.moveSelectionToEnd(); + } + }} + selected={results.length === selectedIndex} + /> + )} + + )} + + ); + } +} + +const Wrapper = styled(Flex)` + margin-left: -8px; + margin-right: -8px; + min-width: 336px; + pointer-events: all; + gap: 8px; +`; + +const SearchResults = styled.ol` + background: ${(props) => props.theme.toolbarBackground}; + position: absolute; + top: 100%; + width: 100%; + height: auto; + left: 0; + padding: 4px 8px 8px; + margin: 0; + margin-top: -3px; + margin-bottom: 0; + border-radius: 0 0 4px 4px; + overflow-y: auto; + max-height: 25vh; + + @media (hover: none) and (pointer: coarse) { + position: fixed; + top: auto; + bottom: 40px; + border-radius: 0; + max-height: 50vh; + padding: 8px 8px 4px; + } +`; + +export default LinkEditor; diff --git a/app/editor/components/LinkSearchResult.tsx b/app/editor/components/LinkSearchResult.tsx new file mode 100644 index 000000000..d1ee20074 --- /dev/null +++ b/app/editor/components/LinkSearchResult.tsx @@ -0,0 +1,84 @@ +import * as React from "react"; +import scrollIntoView from "smooth-scroll-into-view-if-needed"; +import styled from "styled-components"; + +type Props = { + onClick: (event: React.MouseEvent) => void; + onMouseOver: (event: React.MouseEvent) => void; + icon: React.ReactNode; + selected: boolean; + title: string; + subtitle?: string; +}; + +function LinkSearchResult({ title, subtitle, selected, icon, ...rest }: Props) { + const ref = React.useCallback( + (node: HTMLElement | null) => { + if (selected && node) { + scrollIntoView(node, { + scrollMode: "if-needed", + block: "center", + boundary: (parent) => { + // All the parent elements of your target are checked until they + // reach the #link-search-results. Prevents body and other parent + // elements from being scrolled + return parent.id !== "link-search-results"; + }, + }); + } + }, + [selected] + ); + + return ( + + {icon} +
+ {title} + {subtitle ? {subtitle} : null} +
+
+ ); +} + +const IconWrapper = styled.span` + flex-shrink: 0; + margin-right: 4px; + opacity: 0.8; + color: ${(props) => props.theme.toolbarItem}; +`; + +const ListItem = styled.li<{ + selected: boolean; + compact: boolean; +}>` + display: flex; + align-items: center; + padding: 8px; + border-radius: 2px; + color: ${(props) => props.theme.toolbarItem}; + background: ${(props) => + props.selected ? props.theme.toolbarHoverBackground : "transparent"}; + font-family: ${(props) => props.theme.fontFamily}; + text-decoration: none; + overflow: hidden; + white-space: nowrap; + cursor: pointer; + user-select: none; + line-height: ${(props) => (props.compact ? "inherit" : "1.2")}; + height: ${(props) => (props.compact ? "28px" : "auto")}; +`; + +const Title = styled.div` + font-size: 14px; + font-weight: 500; +`; + +const Subtitle = styled.div<{ + selected: boolean; +}>` + font-size: 13px; + opacity: ${(props) => (props.selected ? 0.75 : 0.5)}; +`; + +export default LinkSearchResult; diff --git a/app/editor/components/LinkToolbar.tsx b/app/editor/components/LinkToolbar.tsx new file mode 100644 index 000000000..20f1fdf88 --- /dev/null +++ b/app/editor/components/LinkToolbar.tsx @@ -0,0 +1,151 @@ +import { EditorView } from "prosemirror-view"; +import * as React from "react"; +import createAndInsertLink from "@shared/editor/commands/createAndInsertLink"; +import { Dictionary } from "~/hooks/useDictionary"; +import FloatingToolbar from "./FloatingToolbar"; +import LinkEditor, { SearchResult } from "./LinkEditor"; + +type Props = { + isActive: boolean; + view: EditorView; + dictionary: Dictionary; + onCreateLink?: (title: string) => Promise; + onSearchLink?: (term: string) => Promise; + onClickLink: ( + href: string, + event: React.MouseEvent + ) => void; + onShowToast?: (msg: string, code: string) => void; + onClose: () => void; +}; + +function isActive(props: Props) { + const { view } = props; + const { selection } = view.state; + + try { + const paragraph = view.domAtPos(selection.from); + return props.isActive && !!paragraph.node; + } catch (err) { + return false; + } +} + +export default class LinkToolbar extends React.Component { + menuRef = React.createRef(); + + state = { + left: -1000, + top: undefined, + }; + + componentDidMount() { + window.addEventListener("mousedown", this.handleClickOutside); + } + + componentWillUnmount() { + window.removeEventListener("mousedown", this.handleClickOutside); + } + + handleClickOutside = (event: Event) => { + if ( + event.target instanceof HTMLElement && + this.menuRef.current && + this.menuRef.current.contains(event.target) + ) { + return; + } + + this.props.onClose(); + }; + + handleOnCreateLink = async (title: string) => { + const { dictionary, onCreateLink, view, onClose, onShowToast } = this.props; + + onClose(); + this.props.view.focus(); + + if (!onCreateLink) { + return; + } + + const { dispatch, state } = view; + const { from, to } = state.selection; + if (from !== to) { + // selection must be collapsed + return; + } + + const href = `creating#${title}…`; + + // Insert a placeholder link + dispatch( + view.state.tr + .insertText(title, from, to) + .addMark( + from, + to + title.length, + state.schema.marks.link.create({ href }) + ) + ); + + createAndInsertLink(view, title, href, { + onCreateLink, + onShowToast, + dictionary, + }); + }; + + handleOnSelectLink = ({ + href, + title, + }: { + href: string; + title: string; + from: number; + to: number; + }) => { + const { view, onClose } = this.props; + + onClose(); + this.props.view.focus(); + + const { dispatch, state } = view; + const { from, to } = state.selection; + if (from !== to) { + // selection must be collapsed + return; + } + + dispatch( + view.state.tr + .insertText(title, from, to) + .addMark( + from, + to + title.length, + state.schema.marks.link.create({ href }) + ) + ); + }; + + render() { + const { onCreateLink, onClose, ...rest } = this.props; + const { selection } = this.props.view.state; + const active = isActive(this.props); + + return ( + + {active && ( + + )} + + ); + } +} diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx new file mode 100644 index 000000000..676e45da6 --- /dev/null +++ b/app/editor/components/SelectionToolbar.tsx @@ -0,0 +1,255 @@ +import { some } from "lodash"; +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; +import { EditorView } from "prosemirror-view"; +import * as React from "react"; +import { Portal } from "react-portal"; +import createAndInsertLink from "@shared/editor/commands/createAndInsertLink"; +import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; +import getColumnIndex from "@shared/editor/queries/getColumnIndex"; +import getMarkRange from "@shared/editor/queries/getMarkRange"; +import getRowIndex from "@shared/editor/queries/getRowIndex"; +import isMarkActive from "@shared/editor/queries/isMarkActive"; +import isNodeActive from "@shared/editor/queries/isNodeActive"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; +import getDividerMenuItems from "../menus/divider"; +import getFormattingMenuItems from "../menus/formatting"; +import getImageMenuItems from "../menus/image"; +import getTableMenuItems from "../menus/table"; +import getTableColMenuItems from "../menus/tableCol"; +import getTableRowMenuItems from "../menus/tableRow"; +import FloatingToolbar from "./FloatingToolbar"; +import LinkEditor, { SearchResult } from "./LinkEditor"; +import ToolbarMenu from "./ToolbarMenu"; + +type Props = { + dictionary: Dictionary; + rtl: boolean; + isTemplate: boolean; + commands: Record; + onOpen: () => void; + onClose: () => void; + onSearchLink?: (term: string) => Promise; + onClickLink: ( + href: string, + event: MouseEvent | React.MouseEvent + ) => void; + onCreateLink?: (title: string) => Promise; + onShowToast?: (msg: string, code: string) => void; + view: EditorView; +}; + +function isVisible(props: Props) { + const { view } = props; + const { selection } = view.state; + + if (!selection) return false; + if (selection.empty) return false; + if (selection instanceof NodeSelection && selection.node.type.name === "hr") { + return true; + } + if ( + selection instanceof NodeSelection && + selection.node.type.name === "image" + ) { + return true; + } + if (selection instanceof NodeSelection) { + return false; + } + + const slice = selection.content(); + const fragment = slice.content; + const nodes = (fragment as any).content; + + return some(nodes, (n) => n.content.size); +} + +export default class SelectionToolbar extends React.Component { + isActive = false; + menuRef = React.createRef(); + + componentDidUpdate(): void { + const visible = isVisible(this.props); + if (this.isActive && !visible) { + this.isActive = false; + this.props.onClose(); + } + if (!this.isActive && visible) { + this.isActive = true; + this.props.onOpen(); + } + } + + componentDidMount(): void { + window.addEventListener("mouseup", this.handleClickOutside); + } + + componentWillUnmount(): void { + window.removeEventListener("mouseup", this.handleClickOutside); + } + + handleClickOutside = (ev: MouseEvent): void => { + if ( + ev.target instanceof HTMLElement && + this.menuRef.current && + this.menuRef.current.contains(ev.target) + ) { + return; + } + + if (!this.isActive) { + return; + } + + const { view } = this.props; + if (view.hasFocus()) { + return; + } + + const { dispatch } = view; + + dispatch( + view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0))) + ); + }; + + handleOnCreateLink = async (title: string): Promise => { + const { dictionary, onCreateLink, view, onShowToast } = this.props; + + if (!onCreateLink) { + return; + } + + const { dispatch, state } = view; + const { from, to } = state.selection; + if (from === to) { + // selection cannot be collapsed + return; + } + + const href = `creating#${title}…`; + const markType = state.schema.marks.link; + + // Insert a placeholder link + dispatch( + view.state.tr + .removeMark(from, to, markType) + .addMark(from, to, markType.create({ href })) + ); + + createAndInsertLink(view, title, href, { + onCreateLink, + onShowToast, + dictionary, + }); + }; + + handleOnSelectLink = ({ + href, + from, + to, + }: { + href: string; + from: number; + to: number; + }): void => { + const { view } = this.props; + const { state, dispatch } = view; + + const markType = state.schema.marks.link; + + dispatch( + state.tr + .removeMark(from, to, markType) + .addMark(from, to, markType.create({ href })) + ); + }; + + render() { + const { dictionary, onCreateLink, isTemplate, rtl, ...rest } = this.props; + const { view } = rest; + const { state } = view; + const { selection }: { selection: any } = state; + const isCodeSelection = isNodeActive(state.schema.nodes.code_block)(state); + const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state); + + // toolbar is disabled in code blocks, no bold / italic etc + if (isCodeSelection) { + return null; + } + + const colIndex = getColumnIndex( + (state.selection as unknown) as CellSelection + ); + const rowIndex = getRowIndex((state.selection as unknown) as CellSelection); + const isTableSelection = colIndex !== undefined && rowIndex !== undefined; + const link = isMarkActive(state.schema.marks.link)(state); + const range = getMarkRange(selection.$from, state.schema.marks.link); + const isImageSelection = + selection.node && selection.node.type.name === "image"; + let isTextSelection = false; + + let items: MenuItem[] = []; + if (isTableSelection) { + items = getTableMenuItems(dictionary); + } else if (colIndex !== undefined) { + items = getTableColMenuItems(state, colIndex, rtl, dictionary); + } else if (rowIndex !== undefined) { + items = getTableRowMenuItems(state, rowIndex, dictionary); + } else if (isImageSelection) { + items = getImageMenuItems(state, dictionary); + } else if (isDividerSelection) { + items = getDividerMenuItems(state, dictionary); + } else { + items = getFormattingMenuItems(state, isTemplate, dictionary); + isTextSelection = true; + } + + // Some extensions may be disabled, remove corresponding items + items = items.filter((item) => { + if (item.name === "separator") return true; + if (item.name && !this.props.commands[item.name]) return false; + return true; + }); + + items = filterExcessSeparators(items); + if (!items.length) { + return null; + } + + const selectionText = state.doc.cut( + state.selection.from, + state.selection.to + ).textContent; + + if (isTextSelection && !selectionText) { + return null; + } + + return ( + + + {link && range ? ( + + ) : ( + + )} + + + ); + } +} diff --git a/app/editor/components/Styles.ts b/app/editor/components/Styles.ts new file mode 100644 index 000000000..01774ec75 --- /dev/null +++ b/app/editor/components/Styles.ts @@ -0,0 +1,1144 @@ +/* eslint-disable no-irregular-whitespace */ +import { lighten } from "polished"; +import styled from "styled-components"; + +const EditorStyles = styled.div<{ + rtl: boolean; + readOnly?: boolean; + readOnlyWriteCheckboxes?: boolean; + grow?: boolean; +}>` + flex-grow: ${(props) => (props.grow ? 1 : 0)}; + justify-content: start; + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.background}; + font-family: ${(props) => props.theme.fontFamily}; + font-weight: ${(props) => props.theme.fontWeight}; + font-size: 1em; + line-height: 1.7em; + width: 100%; + + > div { + background: transparent; + } + + & * { + box-sizing: content-box; + } + + .ProseMirror { + position: relative; + outline: none; + word-wrap: break-word; + white-space: pre-wrap; + white-space: break-spaces; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ + + & > .ProseMirror-yjs-cursor { + display: none; + } + + .ProseMirror-yjs-cursor { + position: relative; + margin-left: -1px; + margin-right: -1px; + border-left: 1px solid black; + border-right: 1px solid black; + height: 1em; + word-break: normal; + + &:after { + content: ""; + display: block; + position: absolute; + left: -8px; + right: -8px; + top: 0; + bottom: 0; + } + > div { + opacity: 0; + transition: opacity 100ms ease-in-out; + position: absolute; + top: -1.8em; + font-size: 13px; + background-color: rgb(250, 129, 0); + font-style: normal; + line-height: normal; + user-select: none; + white-space: nowrap; + color: white; + padding: 2px 6px; + font-weight: 500; + border-radius: 4px; + pointer-events: none; + left: -1px; + } + + &:hover { + > div { + opacity: 1; + } + } + } + } + + &.show-cursor-names .ProseMirror-yjs-cursor > div { + opacity: 1; + } + + pre { + white-space: pre-wrap; + } + + li { + position: relative; + } + + .image { + text-align: center; + max-width: 100%; + clear: both; + + img { + pointer-events: ${(props) => (props.readOnly ? "initial" : "none")}; + display: inline-block; + max-width: 100%; + max-height: 75vh; + } + + .ProseMirror-selectednode img { + pointer-events: initial; + } + } + + .image.placeholder { + position: relative; + background: ${(props) => props.theme.background}; + margin-bottom: calc(28px + 1.2em); + + img { + opacity: 0.5; + } + } + + .image-replacement-uploading { + img { + opacity: 0.5; + } + } + + .image-right-50 { + float: right; + width: 50%; + margin-left: 2em; + margin-bottom: 1em; + clear: initial; + } + + .image-left-50 { + float: left; + width: 50%; + margin-right: 2em; + margin-bottom: 1em; + clear: initial; + } + + .ProseMirror-hideselection *::selection { + background: transparent; + } + .ProseMirror-hideselection *::-moz-selection { + background: transparent; + } + .ProseMirror-hideselection { + caret-color: transparent; + } + + .ProseMirror-selectednode { + outline: 2px solid + ${(props) => (props.readOnly ? "transparent" : props.theme.selected)}; + } + + /* Make sure li selections wrap around markers */ + + li.ProseMirror-selectednode { + outline: none; + } + + li.ProseMirror-selectednode:after { + content: ""; + position: absolute; + left: ${(props) => (props.rtl ? "-2px" : "-32px")}; + right: ${(props) => (props.rtl ? "-32px" : "-2px")}; + top: -2px; + bottom: -2px; + border: 2px solid ${(props) => props.theme.selected}; + pointer-events: none; + } + + .ProseMirror[contenteditable="false"] { + .caption { + pointer-events: none; + } + .caption:empty { + visibility: hidden; + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 1em 0 0.5em; + font-weight: 500; + cursor: text; + + &:not(.placeholder):before { + display: ${(props) => (props.readOnly ? "none" : "inline-block")}; + font-family: ${(props) => props.theme.fontFamilyMono}; + color: ${(props) => props.theme.textSecondary}; + font-size: 13px; + line-height: 0; + margin-${(props) => (props.rtl ? "right" : "left")}: -24px; + transition: opacity 150ms ease-in-out; + opacity: 0; + width: 24px; + } + + &:hover, + &:focus-within { + .heading-actions { + opacity: 1; + } + } + } + + .heading-content { + &:before { + content: "​"; + display: inline; + } + } + + .heading-name { + color: ${(props) => props.theme.text}; + pointer-events: none; + display: block; + position: relative; + top: -60px; + visibility: hidden; + + &:hover { + text-decoration: none; + } + } + + .heading-name:first-child, + .heading-name:first-child + .ProseMirror-yjs-cursor { + & + h1, + & + h2, + & + h3, + & + h4 { + margin-top: 0; + } + } + + a:first-child { + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 0; + } + } + + h1:not(.placeholder):before { + content: "H1"; + } + h2:not(.placeholder):before { + content: "H2"; + } + h3:not(.placeholder):before { + content: "H3"; + } + h4:not(.placeholder):before { + content: "H4"; + } + h5:not(.placeholder):before { + content: "H5"; + } + h6:not(.placeholder):before { + content: "H6"; + } + + .ProseMirror-focused { + h1, + h2, + h3, + h4, + h5, + h6 { + &:not(.placeholder):before { + opacity: 1; + } + } + } + + .with-emoji { + margin-${(props) => (props.rtl ? "right" : "left")}: -1em; + } + + .heading-anchor, + .heading-fold { + display: inline-block; + color: ${(props) => props.theme.text}; + opacity: .75; + cursor: pointer; + background: none; + outline: none; + border: 0; + margin: 0; + padding: 0; + text-align: left; + font-family: ${(props) => props.theme.fontFamilyMono}; + font-size: 14px; + line-height: 0; + width: 12px; + height: 24px; + + &:focus, + &:hover { + opacity: 1; + } + } + + .heading-anchor { + box-sizing: border-box; + } + + .heading-actions { + opacity: 0; + background: ${(props) => props.theme.background}; + margin-${(props) => (props.rtl ? "right" : "left")}: -26px; + flex-direction: ${(props) => (props.rtl ? "row-reverse" : "row")}; + display: inline-flex; + position: relative; + top: -2px; + width: 26px; + height: 24px; + + &.collapsed { + opacity: 1; + } + + &.collapsed .heading-anchor { + opacity: 0; + } + + &.collapsed .heading-fold { + opacity: 1; + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + &:hover { + .heading-anchor { + opacity: 0.75 !important; + } + .heading-anchor:hover { + opacity: 1 !important; + } + } + } + + .heading-fold { + display: inline-block; + transform-origin: center; + padding: 0; + + &.collapsed { + transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")}); + transition-delay: 0.1s; + opacity: 1; + } + } + + .placeholder:before { + display: block; + opacity: 0; + transition: opacity 150ms ease-in-out; + content: ${(props) => (props.readOnly ? "" : "attr(data-empty-text)")}; + pointer-events: none; + height: 0; + color: ${(props) => props.theme.placeholder}; + } + + /** Show the placeholder if focused or the first visible item nth(2) accounts for block insert trigger */ + .ProseMirror-focused .placeholder:before, + .placeholder:nth-child(1):before, + .placeholder:nth-child(2):before { + opacity: 1; + } + + .notice-block { + display: flex; + align-items: center; + background: ${(props) => props.theme.noticeInfoBackground}; + color: ${(props) => props.theme.noticeInfoText}; + border-radius: 4px; + padding: 8px 16px; + margin: 8px 0; + + a { + color: ${(props) => props.theme.noticeInfoText}; + } + + a:not(.heading-name) { + text-decoration: underline; + } + } + + .notice-block.tip, + .notice-block.warning { + font-weight: 500; + } + + .notice-block .content { + flex-grow: 1; + min-width: 0; + } + + .notice-block .icon { + width: 24px; + height: 24px; + align-self: flex-start; + margin-${(props) => (props.rtl ? "left" : "right")}: 4px; + position: relative; + top: 1px; + } + + .notice-block.tip { + background: ${(props) => props.theme.noticeTipBackground}; + color: ${(props) => props.theme.noticeTipText}; + + a { + color: ${(props) => props.theme.noticeTipText}; + } + } + + .notice-block.warning { + background: ${(props) => props.theme.noticeWarningBackground}; + color: ${(props) => props.theme.noticeWarningText}; + + a { + color: ${(props) => props.theme.noticeWarningText}; + } + } + + blockquote { + margin: 0; + padding-left: 1.5em; + font-style: italic; + overflow: hidden; + position: relative; + + &:before { + content: ""; + display: inline-block; + width: 2px; + border-radius: 1px; + position: absolute; + margin-${(props) => (props.rtl ? "right" : "left")}: -1.5em; + top: 0; + bottom: 0; + background: ${(props) => props.theme.quote}; + } + } + + b, + strong { + font-weight: 600; + } + + .template-placeholder { + color: ${(props) => props.theme.placeholder}; + border-bottom: 1px dotted ${(props) => props.theme.placeholder}; + border-radius: 2px; + cursor: text; + + &:hover { + border-bottom: 1px dotted + ${(props) => + props.readOnly ? props.theme.placeholder : props.theme.textSecondary}; + } + } + + p { + margin: 0; + + span:first-child + br:last-child { + display: none; + } + + a { + color: ${(props) => props.theme.text}; + border-bottom: 1px solid ${(props) => lighten(0.5, props.theme.text)}; + text-decoration: none !important; + font-weight: 500; + + &:hover { + border-bottom: 1px solid ${(props) => props.theme.text}; + text-decoration: none; + } + } + } + + a { + color: ${(props) => props.theme.link}; + cursor: pointer; + } + + a:hover { + text-decoration: ${(props) => (props.readOnly ? "underline" : "none")}; + } + + ul, + ol { + margin: ${(props) => (props.rtl ? "0 -26px 0 0.1em" : "0 0.1em 0 -26px")}; + padding: ${(props) => (props.rtl ? "0 44px 0 0" : "0 0 0 44px")}; + } + + ol ol { + list-style: lower-alpha; + } + + ol ol ol { + list-style: lower-roman; + } + + ul.checkbox_list { + list-style: none; + padding: 0; + margin: ${(props) => (props.rtl ? "0 -24px 0 0" : "0 0 0 -24px")}; + } + + ul li, + ol li { + position: relative; + white-space: initial; + + p { + white-space: pre-wrap; + } + + > div { + width: 100%; + } + } + + ul.checkbox_list li { + display: flex; + padding-${(props) => (props.rtl ? "right" : "left")}: 24px; + } + + ul.checkbox_list li.checked > div > p { + color: ${(props) => props.theme.textSecondary}; + text-decoration: line-through; + } + + ul li::before, + ol li::before { + background: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iOCIgeT0iNyIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIGZpbGw9IiM0RTVDNkUiLz4KPHJlY3QgeD0iOCIgeT0iMTEiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiBmaWxsPSIjNEU1QzZFIi8+CjxyZWN0IHg9IjgiIHk9IjE1IiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRFNUM2RSIvPgo8cmVjdCB4PSIxMyIgeT0iNyIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIGZpbGw9IiM0RTVDNkUiLz4KPHJlY3QgeD0iMTMiIHk9IjExIiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRFNUM2RSIvPgo8cmVjdCB4PSIxMyIgeT0iMTUiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiBmaWxsPSIjNEU1QzZFIi8+Cjwvc3ZnPgo=") no-repeat; + background-position: 0 2px; + content: ""; + display: ${(props) => (props.readOnly ? "none" : "inline-block")}; + cursor: grab; + width: 24px; + height: 24px; + position: absolute; + ${(props) => (props.rtl ? "right" : "left")}: -40px; + opacity: 0; + transition: opacity 200ms ease-in-out; + } + + ul li[draggable=true]::before, + ol li[draggable=true]::before { + cursor: grabbing; + } + + ul > li.counter-2::before, + ol li.counter-2::before { + ${(props) => (props.rtl ? "right" : "left")}: -50px; + } + + ul > li.hovering::before, + ol li.hovering::before { + opacity: 0.5; + } + + ul li.ProseMirror-selectednode::after, + ol li.ProseMirror-selectednode::after { + display: none; + } + + ul.checkbox_list li::before { + ${(props) => (props.rtl ? "right" : "left")}: 0; + } + + ul.checkbox_list li .checkbox { + display: inline-block; + cursor: pointer; + pointer-events: ${(props) => + props.readOnly && !props.readOnlyWriteCheckboxes ? "none" : "initial"}; + opacity: ${(props) => + props.readOnly && !props.readOnlyWriteCheckboxes ? 0.75 : 1}; + margin: ${(props) => (props.rtl ? "0 0 0 0.5em" : "0 0.5em 0 0")}; + width: 14px; + height: 14px; + position: relative; + top: 1px; + transition: transform 100ms ease-in-out; + + background-image: ${(props) => + `url("data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M3 0C1.34315 0 0 1.34315 0 3V11C0 12.6569 1.34315 14 3 14H11C12.6569 14 14 12.6569 14 11V3C14 1.34315 12.6569 0 11 0H3ZM3 2C2.44772 2 2 2.44772 2 3V11C2 11.5523 2.44772 12 3 12H11C11.5523 12 12 11.5523 12 11V3C12 2.44772 11.5523 2 11 2H3Z' fill='${props.theme.text.replace( + "#", + "%23" + )}' /%3E%3C/svg%3E%0A");`} + + &[aria-checked=true] { + background-image: ${(props) => + `url( + "data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M3 0C1.34315 0 0 1.34315 0 3V11C0 12.6569 1.34315 14 3 14H11C12.6569 14 14 12.6569 14 11V3C14 1.34315 12.6569 0 11 0H3ZM4.26825 5.85982L5.95873 7.88839L9.70003 2.9C10.0314 2.45817 10.6582 2.36863 11.1 2.7C11.5419 3.03137 11.6314 3.65817 11.3 4.1L6.80002 10.1C6.41275 10.6164 5.64501 10.636 5.2318 10.1402L2.7318 7.14018C2.37824 6.71591 2.43556 6.08534 2.85984 5.73178C3.28412 5.37821 3.91468 5.43554 4.26825 5.85982Z' fill='${props.theme.primary.replace( + "#", + "%23" + )}' /%3E%3C/svg%3E%0A" + )`}; + } + + &:active { + transform: scale(0.9); + } + } + + li p:first-child { + margin: 0; + word-break: break-word; + } + + hr { + position: relative; + height: 1em; + border: 0; + } + + hr:before { + content: ""; + display: block; + position: absolute; + border-top: 1px solid ${(props) => props.theme.horizontalRule}; + top: 0.5em; + left: 0; + right: 0; + } + + hr.page-break { + page-break-after: always; + } + + hr.page-break:before { + border-top: 1px dashed ${(props) => props.theme.horizontalRule}; + } + + code { + border-radius: 4px; + border: 1px solid ${(props) => props.theme.codeBorder}; + background: ${(props) => props.theme.codeBackground}; + padding: 3px 4px; + font-family: ${(props) => props.theme.fontFamilyMono}; + font-size: 80%; + } + + mark { + border-radius: 1px; + color: ${(props) => props.theme.textHighlightForeground}; + background: ${(props) => props.theme.textHighlight}; + + a { + color: ${(props) => props.theme.textHighlightForeground}; + } + } + + .code-block, + .notice-block { + position: relative; + + select, + button { + background: ${(props) => props.theme.blockToolbarBackground}; + color: ${(props) => props.theme.blockToolbarItem}; + border-width: 1px; + font-size: 13px; + display: none; + position: absolute; + border-radius: 4px; + padding: 2px; + z-index: 1; + top: 4px; + } + + &.code-block { + select, + button { + right: 4px; + } + } + + &.notice-block { + select, + button { + ${(props) => (props.rtl ? "left" : "right")}: 4px; + } + } + + button { + padding: 2px 4px; + } + + &:hover { + select { + display: ${(props) => (props.readOnly ? "none" : "inline")}; + } + + button { + display: ${(props) => (props.readOnly ? "inline" : "none")}; + } + } + + select:focus, + select:active { + display: inline; + } + } + + pre { + display: block; + overflow-x: auto; + padding: 0.75em 1em; + line-height: 1.4em; + position: relative; + background: ${(props) => props.theme.codeBackground}; + border-radius: 4px; + border: 1px solid ${(props) => props.theme.codeBorder}; + + -webkit-font-smoothing: initial; + font-family: ${(props) => props.theme.fontFamilyMono}; + font-size: 13px; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; + color: ${(props) => props.theme.code}; + margin: 0; + + code { + font-size: 13px; + background: none; + padding: 0; + border: 0; + } + } + + .token.comment, + .token.prolog, + .token.doctype, + .token.cdata { + color: ${(props) => props.theme.codeComment}; + } + + .token.punctuation { + color: ${(props) => props.theme.codePunctuation}; + } + + .token.namespace { + opacity: 0.7; + } + + .token.operator, + .token.boolean, + .token.number { + color: ${(props) => props.theme.codeNumber}; + } + + .token.property { + color: ${(props) => props.theme.codeProperty}; + } + + .token.tag { + color: ${(props) => props.theme.codeTag}; + } + + .token.string { + color: ${(props) => props.theme.codeString}; + } + + .token.selector { + color: ${(props) => props.theme.codeSelector}; + } + + .token.attr-name { + color: ${(props) => props.theme.codeAttr}; + } + + .token.entity, + .token.url, + .language-css .token.string, + .style .token.string { + color: ${(props) => props.theme.codeEntity}; + } + + .token.attr-value, + .token.keyword, + .token.control, + .token.directive, + .token.unit { + color: ${(props) => props.theme.codeKeyword}; + } + + .token.function { + color: ${(props) => props.theme.codeFunction}; + } + + .token.statement, + .token.regex, + .token.atrule { + color: ${(props) => props.theme.codeStatement}; + } + + .token.placeholder, + .token.variable { + color: ${(props) => props.theme.codePlaceholder}; + } + + .token.deleted { + text-decoration: line-through; + } + + .token.inserted { + border-bottom: 1px dotted ${(props) => props.theme.codeInserted}; + text-decoration: none; + } + + .token.italic { + font-style: italic; + } + + .token.important, + .token.bold { + font-weight: bold; + } + + .token.important { + color: ${(props) => props.theme.codeImportant}; + } + + .token.entity { + cursor: help; + } + + table { + width: 100%; + border-collapse: collapse; + border-radius: 4px; + margin-top: 1em; + box-sizing: border-box; + + * { + box-sizing: border-box; + } + + tr { + position: relative; + border-bottom: 1px solid ${(props) => props.theme.tableDivider}; + } + + th { + background: ${(props) => props.theme.tableHeaderBackground}; + } + + td, + th { + position: relative; + vertical-align: top; + border: 1px solid ${(props) => props.theme.tableDivider}; + position: relative; + padding: 4px 8px; + text-align: ${(props) => (props.rtl ? "right" : "left")}; + min-width: 100px; + } + + .selectedCell { + background: ${(props) => + props.readOnly ? "inherit" : props.theme.tableSelectedBackground}; + + /* fixes Firefox background color painting over border: + * https://bugzilla.mozilla.org/show_bug.cgi?id=688556 */ + background-clip: padding-box; + } + + .grip-column { + /* usage of ::after for all of the table grips works around a bug in + * prosemirror-tables that causes Safari to hang when selecting a cell + * in an empty table: + * https://github.com/ProseMirror/prosemirror/issues/947 */ + &::after { + content: ""; + cursor: pointer; + position: absolute; + top: -16px; + ${(props) => (props.rtl ? "right" : "left")}: 0; + width: 100%; + height: 12px; + background: ${(props) => props.theme.tableDivider}; + border-bottom: 3px solid ${(props) => props.theme.background}; + display: ${(props) => (props.readOnly ? "none" : "block")}; + } + + &:hover::after { + background: ${(props) => props.theme.text}; + } + &.first::after { + border-top-${(props) => (props.rtl ? "right" : "left")}-radius: 3px; + } + &.last::after { + border-top-${(props) => (props.rtl ? "left" : "right")}-radius: 3px; + } + &.selected::after { + background: ${(props) => props.theme.tableSelected}; + } + } + + .grip-row { + &::after { + content: ""; + cursor: pointer; + position: absolute; + ${(props) => (props.rtl ? "right" : "left")}: -16px; + top: 0; + height: 100%; + width: 12px; + background: ${(props) => props.theme.tableDivider}; + border-${(props) => (props.rtl ? "left" : "right")}: 3px solid; + border-color: ${(props) => props.theme.background}; + display: ${(props) => (props.readOnly ? "none" : "block")}; + } + + &:hover::after { + background: ${(props) => props.theme.text}; + } + &.first::after { + border-top-${(props) => (props.rtl ? "right" : "left")}-radius: 3px; + } + &.last::after { + border-bottom-${(props) => (props.rtl ? "right" : "left")}-radius: 3px; + } + &.selected::after { + background: ${(props) => props.theme.tableSelected}; + } + } + + .grip-table { + &::after { + content: ""; + cursor: pointer; + background: ${(props) => props.theme.tableDivider}; + width: 13px; + height: 13px; + border-radius: 13px; + border: 2px solid ${(props) => props.theme.background}; + position: absolute; + top: -18px; + ${(props) => (props.rtl ? "right" : "left")}: -18px; + display: ${(props) => (props.readOnly ? "none" : "block")}; + } + + &:hover::after { + background: ${(props) => props.theme.text}; + } + &.selected::after { + background: ${(props) => props.theme.tableSelected}; + } + } + } + + .scrollable-wrapper { + position: relative; + margin: 0.5em 0px; + scrollbar-width: thin; + scrollbar-color: transparent transparent; + + &:hover { + scrollbar-color: ${(props) => props.theme.scrollbarThumb} ${(props) => + props.theme.scrollbarBackground}; + } + + & ::-webkit-scrollbar { + height: 14px; + background-color: transparent; + } + + &:hover ::-webkit-scrollbar { + background-color: ${(props) => props.theme.scrollbarBackground}; + } + + & ::-webkit-scrollbar-thumb { + background-color: transparent; + border: 3px solid transparent; + border-radius: 7px; + } + + &:hover ::-webkit-scrollbar-thumb { + background-color: ${(props) => props.theme.scrollbarThumb}; + border-color: ${(props) => props.theme.scrollbarBackground}; + } + } + + .scrollable { + overflow-y: hidden; + overflow-x: auto; + padding-${(props) => (props.rtl ? "right" : "left")}: 1em; + margin-${(props) => (props.rtl ? "right" : "left")}: -1em; + border-${(props) => (props.rtl ? "right" : "left")}: 1px solid transparent; + border-${(props) => (props.rtl ? "left" : "right")}: 1px solid transparent; + transition: border 250ms ease-in-out 0s; + } + + .scrollable-shadow { + position: absolute; + top: 0; + bottom: 0; + ${(props) => (props.rtl ? "right" : "left")}: -1em; + width: 16px; + transition: box-shadow 250ms ease-in-out; + border: 0px solid transparent; + border-${(props) => (props.rtl ? "right" : "left")}-width: 1em; + pointer-events: none; + + &.left { + box-shadow: 16px 0 16px -16px inset rgba(0, 0, 0, 0.25); + border-left: 1em solid ${(props) => props.theme.background}; + } + + &.right { + right: 0; + left: auto; + box-shadow: -16px 0 16px -16px inset rgba(0, 0, 0, 0.25); + } + } + + .block-menu-trigger { + opacity: 0; + pointer-events: none; + display: ${(props) => (props.readOnly ? "none" : "inline")}; + width: 24px; + height: 24px; + color: ${(props) => props.theme.textSecondary}; + background: none; + position: absolute; + transition: color 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275), + transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275), + opacity 150ms ease-in-out; + outline: none; + border: 0; + padding: 0; + margin-top: 1px; + margin-${(props) => (props.rtl ? "right" : "left")}: -24px; + + &:hover, + &:focus { + cursor: pointer; + transform: scale(1.2); + color: ${(props) => props.theme.text}; + } + } + + .ProseMirror-focused .block-menu-trigger, + .block-menu-trigger:active, + .block-menu-trigger:focus { + opacity: 1; + pointer-events: initial; + } + + .ProseMirror-gapcursor { + display: none; + pointer-events: none; + position: absolute; + } + + .ProseMirror-gapcursor:after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid ${(props) => props.theme.cursor}; + animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; + } + + .folded-content { + display: none; + } + + @keyframes ProseMirror-cursor-blink { + to { + visibility: hidden; + } + } + + .ProseMirror-focused .ProseMirror-gapcursor { + display: block; + } + + @media print { + .placeholder:before, + .block-menu-trigger, + .heading-actions, + h1:not(.placeholder):before, + h2:not(.placeholder):before, + h3:not(.placeholder):before, + h4:not(.placeholder):before, + h5:not(.placeholder):before, + h6:not(.placeholder):before { + display: none; + } + + .page-break { + opacity: 0; + } + + em, + blockquote { + font-family: "SF Pro Text", ${(props) => props.theme.fontFamily}; + } + } +`; + +export default EditorStyles; diff --git a/app/editor/components/ToolbarButton.tsx b/app/editor/components/ToolbarButton.tsx new file mode 100644 index 000000000..e1f084c4c --- /dev/null +++ b/app/editor/components/ToolbarButton.tsx @@ -0,0 +1,40 @@ +import styled from "styled-components"; + +type Props = { active?: boolean; disabled?: boolean }; + +export default styled.button` + display: inline-block; + flex: 0; + width: 24px; + height: 24px; + cursor: pointer; + border: none; + background: none; + transition: opacity 100ms ease-in-out; + padding: 0; + opacity: 0.7; + outline: none; + pointer-events: all; + position: relative; + color: ${(props) => props.theme.toolbarItem}; + + &:hover { + opacity: 1; + } + + &:disabled { + opacity: 0.3; + cursor: default; + } + + &:before { + position: absolute; + content: ""; + top: -4px; + right: -4px; + left: -4px; + bottom: -4px; + } + + ${(props) => props.active && "opacity: 1;"}; +`; diff --git a/app/editor/components/ToolbarMenu.tsx b/app/editor/components/ToolbarMenu.tsx new file mode 100644 index 000000000..6320b7c40 --- /dev/null +++ b/app/editor/components/ToolbarMenu.tsx @@ -0,0 +1,54 @@ +import { EditorView } from "prosemirror-view"; +import * as React from "react"; +import styled, { useTheme } from "styled-components"; +import { CommandFactory } from "@shared/editor/lib/Extension"; +import { MenuItem } from "@shared/editor/types"; +import ToolbarButton from "./ToolbarButton"; +import ToolbarSeparator from "./ToolbarSeparator"; +import Tooltip from "./Tooltip"; + +type Props = { + commands: Record; + view: EditorView; + items: MenuItem[]; +}; + +const FlexibleWrapper = styled.div` + display: flex; + gap: 8px; +`; + +function ToolbarMenu(props: Props) { + const theme = useTheme(); + const { view, items } = props; + const { state } = view; + + return ( + + {items.map((item, index) => { + if (item.name === "separator" && item.visible !== false) { + return ; + } + if (item.visible === false || !item.icon) { + return null; + } + const Icon = item.icon; + const isActive = item.active ? item.active(state) : false; + + return ( + + item.name && props.commands[item.name](item.attrs)} + active={isActive} + > + + + + ); + })} + + ); +} + +export default ToolbarMenu; diff --git a/app/editor/components/ToolbarSeparator.tsx b/app/editor/components/ToolbarSeparator.tsx new file mode 100644 index 000000000..fbd1272aa --- /dev/null +++ b/app/editor/components/ToolbarSeparator.tsx @@ -0,0 +1,12 @@ +import styled from "styled-components"; + +const Separator = styled.div` + height: 24px; + width: 2px; + background: ${(props) => props.theme.toolbarItem}; + opacity: 0.3; + display: inline-block; + margin-left: 8px; +`; + +export default Separator; diff --git a/app/editor/components/Tooltip.tsx b/app/editor/components/Tooltip.tsx new file mode 100644 index 000000000..6dc9fc308 --- /dev/null +++ b/app/editor/components/Tooltip.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import styled from "styled-components"; +import Tooltip from "~/components/Tooltip"; + +type Props = { + children: React.ReactNode; + tooltip?: string; +}; + +const WrappedTooltip = ({ children, tooltip }: Props) => ( + + {children} + +); + +const TooltipContent = styled.span` + outline: none; +`; + +export default WrappedTooltip; diff --git a/app/editor/components/WithTheme.tsx b/app/editor/components/WithTheme.tsx new file mode 100644 index 000000000..a72e746c2 --- /dev/null +++ b/app/editor/components/WithTheme.tsx @@ -0,0 +1,11 @@ +import * as React from "react"; +import { DefaultTheme, useTheme } from "styled-components"; + +type Props = { + children: (theme: DefaultTheme) => React.ReactElement; +}; + +export default function WithTheme({ children }: Props) { + const theme = useTheme(); + return children(theme); +} diff --git a/app/editor/index.tsx b/app/editor/index.tsx new file mode 100644 index 000000000..e3015024f --- /dev/null +++ b/app/editor/index.tsx @@ -0,0 +1,818 @@ +/* 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; + /** 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; diff --git a/app/editor/menus/block.ts b/app/editor/menus/block.ts new file mode 100644 index 000000000..e38d3dc55 --- /dev/null +++ b/app/editor/menus/block.ts @@ -0,0 +1,148 @@ +import { + BlockQuoteIcon, + BulletedListIcon, + CodeIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + HorizontalRuleIcon, + OrderedListIcon, + PageBreakIcon, + TableIcon, + TodoListIcon, + ImageIcon, + StarredIcon, + WarningIcon, + InfoIcon, + LinkIcon, +} from "outline-icons"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; + +const SSR = typeof window === "undefined"; +const isMac = !SSR && window.navigator.platform === "MacIntel"; +const mod = isMac ? "⌘" : "ctrl"; + +export default function blockMenuItems(dictionary: Dictionary): MenuItem[] { + return [ + { + name: "heading", + title: dictionary.h1, + keywords: "h1 heading1 title", + icon: Heading1Icon, + shortcut: "^ ⇧ 1", + attrs: { level: 1 }, + }, + { + name: "heading", + title: dictionary.h2, + keywords: "h2 heading2", + icon: Heading2Icon, + shortcut: "^ ⇧ 2", + attrs: { level: 2 }, + }, + { + name: "heading", + title: dictionary.h3, + keywords: "h3 heading3", + icon: Heading3Icon, + shortcut: "^ ⇧ 3", + attrs: { level: 3 }, + }, + { + name: "separator", + }, + { + name: "checkbox_list", + title: dictionary.checkboxList, + icon: TodoListIcon, + keywords: "checklist checkbox task", + shortcut: "^ ⇧ 7", + }, + { + name: "bullet_list", + title: dictionary.bulletList, + icon: BulletedListIcon, + shortcut: "^ ⇧ 8", + }, + { + name: "ordered_list", + title: dictionary.orderedList, + icon: OrderedListIcon, + shortcut: "^ ⇧ 9", + }, + { + name: "separator", + }, + { + name: "table", + title: dictionary.table, + icon: TableIcon, + attrs: { rowsCount: 3, colsCount: 3 }, + }, + { + name: "blockquote", + title: dictionary.quote, + icon: BlockQuoteIcon, + shortcut: `${mod} ]`, + }, + { + name: "code_block", + title: dictionary.codeBlock, + icon: CodeIcon, + shortcut: "^ ⇧ \\", + keywords: "script", + }, + { + name: "hr", + title: dictionary.hr, + icon: HorizontalRuleIcon, + shortcut: `${mod} _`, + keywords: "horizontal rule break line", + }, + { + name: "hr", + title: dictionary.pageBreak, + icon: PageBreakIcon, + keywords: "page print break line", + attrs: { markup: "***" }, + }, + { + name: "image", + title: dictionary.image, + icon: ImageIcon, + keywords: "picture photo", + }, + { + name: "link", + title: dictionary.link, + icon: LinkIcon, + shortcut: `${mod} k`, + keywords: "link url uri href", + }, + { + name: "separator", + }, + { + name: "container_notice", + title: dictionary.infoNotice, + icon: InfoIcon, + keywords: "container_notice card information", + attrs: { style: "info" }, + }, + { + name: "container_notice", + title: dictionary.warningNotice, + icon: WarningIcon, + keywords: "container_notice card error", + attrs: { style: "warning" }, + }, + { + name: "container_notice", + title: dictionary.tipNotice, + icon: StarredIcon, + keywords: "container_notice card suggestion", + attrs: { style: "tip" }, + }, + ]; +} diff --git a/app/editor/menus/divider.tsx b/app/editor/menus/divider.tsx new file mode 100644 index 000000000..5747300e8 --- /dev/null +++ b/app/editor/menus/divider.tsx @@ -0,0 +1,29 @@ +import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons"; +import { EditorState } from "prosemirror-state"; +import isNodeActive from "@shared/editor/queries/isNodeActive"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; + +export default function dividerMenuItems( + state: EditorState, + dictionary: Dictionary +): MenuItem[] { + const { schema } = state; + + return [ + { + name: "hr", + tooltip: dictionary.pageBreak, + attrs: { markup: "***" }, + active: isNodeActive(schema.nodes.hr, { markup: "***" }), + icon: PageBreakIcon, + }, + { + name: "hr", + tooltip: dictionary.hr, + attrs: { markup: "---" }, + active: isNodeActive(schema.nodes.hr, { markup: "---" }), + icon: HorizontalRuleIcon, + }, + ]; +} diff --git a/app/editor/menus/formatting.ts b/app/editor/menus/formatting.ts new file mode 100644 index 000000000..4787c23be --- /dev/null +++ b/app/editor/menus/formatting.ts @@ -0,0 +1,135 @@ +import { + BoldIcon, + CodeIcon, + Heading1Icon, + Heading2Icon, + BlockQuoteIcon, + LinkIcon, + StrikethroughIcon, + OrderedListIcon, + BulletedListIcon, + TodoListIcon, + InputIcon, + HighlightIcon, +} from "outline-icons"; +import { EditorState } from "prosemirror-state"; +import { isInTable } from "prosemirror-tables"; +import isInList from "@shared/editor/queries/isInList"; +import isMarkActive from "@shared/editor/queries/isMarkActive"; +import isNodeActive from "@shared/editor/queries/isNodeActive"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; + +export default function formattingMenuItems( + state: EditorState, + isTemplate: boolean, + dictionary: Dictionary +): MenuItem[] { + const { schema } = state; + const isTable = isInTable(state); + const isList = isInList(state); + const allowBlocks = !isTable && !isList; + + return [ + { + name: "placeholder", + tooltip: dictionary.placeholder, + icon: InputIcon, + active: isMarkActive(schema.marks.placeholder), + visible: isTemplate, + }, + { + name: "separator", + visible: isTemplate, + }, + { + name: "strong", + tooltip: dictionary.strong, + icon: BoldIcon, + active: isMarkActive(schema.marks.strong), + }, + { + name: "strikethrough", + tooltip: dictionary.strikethrough, + icon: StrikethroughIcon, + active: isMarkActive(schema.marks.strikethrough), + }, + { + name: "highlight", + tooltip: dictionary.mark, + icon: HighlightIcon, + active: isMarkActive(schema.marks.highlight), + visible: !isTemplate, + }, + { + name: "code_inline", + tooltip: dictionary.codeInline, + icon: CodeIcon, + active: isMarkActive(schema.marks.code_inline), + }, + { + name: "separator", + visible: allowBlocks, + }, + { + name: "heading", + tooltip: dictionary.heading, + icon: Heading1Icon, + active: isNodeActive(schema.nodes.heading, { level: 1 }), + attrs: { level: 1 }, + visible: allowBlocks, + }, + { + name: "heading", + tooltip: dictionary.subheading, + icon: Heading2Icon, + active: isNodeActive(schema.nodes.heading, { level: 2 }), + attrs: { level: 2 }, + visible: allowBlocks, + }, + { + name: "blockquote", + tooltip: dictionary.quote, + icon: BlockQuoteIcon, + active: isNodeActive(schema.nodes.blockquote), + attrs: { level: 2 }, + visible: allowBlocks, + }, + { + name: "separator", + visible: allowBlocks || isList, + }, + { + name: "checkbox_list", + tooltip: dictionary.checkboxList, + icon: TodoListIcon, + keywords: "checklist checkbox task", + active: isNodeActive(schema.nodes.checkbox_list), + visible: allowBlocks || isList, + }, + { + name: "bullet_list", + tooltip: dictionary.bulletList, + icon: BulletedListIcon, + active: isNodeActive(schema.nodes.bullet_list), + visible: allowBlocks || isList, + }, + { + name: "ordered_list", + tooltip: dictionary.orderedList, + icon: OrderedListIcon, + active: isNodeActive(schema.nodes.ordered_list), + visible: allowBlocks || isList, + }, + { + name: "separator", + }, + { + name: "link", + tooltip: dictionary.createLink, + icon: LinkIcon, + active: isMarkActive(schema.marks.link), + attrs: { href: "" }, + }, + ]; +} diff --git a/app/editor/menus/image.tsx b/app/editor/menus/image.tsx new file mode 100644 index 000000000..a7c9947d8 --- /dev/null +++ b/app/editor/menus/image.tsx @@ -0,0 +1,77 @@ +import { + TrashIcon, + DownloadIcon, + ReplaceIcon, + AlignImageLeftIcon, + AlignImageRightIcon, + AlignImageCenterIcon, +} from "outline-icons"; +import { EditorState } from "prosemirror-state"; +import isNodeActive from "@shared/editor/queries/isNodeActive"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; + +export default function imageMenuItems( + state: EditorState, + dictionary: Dictionary +): MenuItem[] { + const { schema } = state; + const isLeftAligned = isNodeActive(schema.nodes.image, { + layoutClass: "left-50", + }); + const isRightAligned = isNodeActive(schema.nodes.image, { + layoutClass: "right-50", + }); + + return [ + { + name: "alignLeft", + tooltip: dictionary.alignLeft, + icon: AlignImageLeftIcon, + visible: true, + active: isLeftAligned, + }, + { + name: "alignCenter", + tooltip: dictionary.alignCenter, + icon: AlignImageCenterIcon, + visible: true, + active: (state) => + isNodeActive(schema.nodes.image)(state) && + !isLeftAligned(state) && + !isRightAligned(state), + }, + { + name: "alignRight", + tooltip: dictionary.alignRight, + icon: AlignImageRightIcon, + visible: true, + active: isRightAligned, + }, + { + name: "separator", + visible: true, + }, + { + name: "downloadImage", + tooltip: dictionary.downloadImage, + icon: DownloadIcon, + visible: !!fetch, + active: () => false, + }, + { + name: "replaceImage", + tooltip: dictionary.replaceImage, + icon: ReplaceIcon, + visible: true, + active: () => false, + }, + { + name: "deleteImage", + tooltip: dictionary.deleteImage, + icon: TrashIcon, + visible: true, + active: () => false, + }, + ]; +} diff --git a/app/editor/menus/table.tsx b/app/editor/menus/table.tsx new file mode 100644 index 000000000..0e38a8239 --- /dev/null +++ b/app/editor/menus/table.tsx @@ -0,0 +1,14 @@ +import { TrashIcon } from "outline-icons"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; + +export default function tableMenuItems(dictionary: Dictionary): MenuItem[] { + return [ + { + name: "deleteTable", + tooltip: dictionary.deleteTable, + icon: TrashIcon, + active: () => false, + }, + ]; +} diff --git a/app/editor/menus/tableCol.tsx b/app/editor/menus/tableCol.tsx new file mode 100644 index 000000000..3fa0774f4 --- /dev/null +++ b/app/editor/menus/tableCol.tsx @@ -0,0 +1,81 @@ +import { + TrashIcon, + AlignLeftIcon, + AlignRightIcon, + AlignCenterIcon, + InsertLeftIcon, + InsertRightIcon, +} from "outline-icons"; +import { EditorState } from "prosemirror-state"; +import isNodeActive from "@shared/editor/queries/isNodeActive"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; + +export default function tableColMenuItems( + state: EditorState, + index: number, + rtl: boolean, + dictionary: Dictionary +): MenuItem[] { + const { schema } = state; + + return [ + { + name: "setColumnAttr", + tooltip: dictionary.alignLeft, + icon: AlignLeftIcon, + attrs: { index, alignment: "left" }, + active: isNodeActive(schema.nodes.th, { + colspan: 1, + rowspan: 1, + alignment: "left", + }), + }, + { + name: "setColumnAttr", + tooltip: dictionary.alignCenter, + icon: AlignCenterIcon, + attrs: { index, alignment: "center" }, + active: isNodeActive(schema.nodes.th, { + colspan: 1, + rowspan: 1, + alignment: "center", + }), + }, + { + name: "setColumnAttr", + tooltip: dictionary.alignRight, + icon: AlignRightIcon, + attrs: { index, alignment: "right" }, + active: isNodeActive(schema.nodes.th, { + colspan: 1, + rowspan: 1, + alignment: "right", + }), + }, + { + name: "separator", + }, + { + name: rtl ? "addColumnAfter" : "addColumnBefore", + tooltip: rtl ? dictionary.addColumnAfter : dictionary.addColumnBefore, + icon: InsertLeftIcon, + active: () => false, + }, + { + name: rtl ? "addColumnBefore" : "addColumnAfter", + tooltip: rtl ? dictionary.addColumnBefore : dictionary.addColumnAfter, + icon: InsertRightIcon, + active: () => false, + }, + { + name: "separator", + }, + { + name: "deleteColumn", + tooltip: dictionary.deleteColumn, + icon: TrashIcon, + active: () => false, + }, + ]; +} diff --git a/app/editor/menus/tableRow.tsx b/app/editor/menus/tableRow.tsx new file mode 100644 index 000000000..cc2cb3df7 --- /dev/null +++ b/app/editor/menus/tableRow.tsx @@ -0,0 +1,37 @@ +import { TrashIcon, InsertAboveIcon, InsertBelowIcon } from "outline-icons"; +import { EditorState } from "prosemirror-state"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; + +export default function tableRowMenuItems( + state: EditorState, + index: number, + dictionary: Dictionary +): MenuItem[] { + return [ + { + name: "addRowAfter", + tooltip: dictionary.addRowBefore, + icon: InsertAboveIcon, + attrs: { index: index - 1 }, + active: () => false, + visible: index !== 0, + }, + { + name: "addRowAfter", + tooltip: dictionary.addRowAfter, + icon: InsertBelowIcon, + attrs: { index }, + active: () => false, + }, + { + name: "separator", + }, + { + name: "deleteRow", + tooltip: dictionary.deleteRow, + icon: TrashIcon, + active: () => false, + }, + ]; +} diff --git a/app/hooks/useComponentSize.ts b/app/hooks/useComponentSize.ts new file mode 100644 index 000000000..77194caed --- /dev/null +++ b/app/hooks/useComponentSize.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from "react"; + +export default function useComponentSize( + ref: React.RefObject +): { width: number; height: number } { + const [size, setSize] = useState({ + width: 0, + height: 0, + }); + + useEffect(() => { + const sizeObserver = new ResizeObserver((entries) => { + entries.forEach(({ target }) => { + if ( + size.width !== target.clientWidth || + size.height !== target.clientHeight + ) { + setSize({ width: target.clientWidth, height: target.clientHeight }); + } + }); + }); + + if (ref.current) { + sizeObserver.observe(ref.current); + } + + return () => sizeObserver.disconnect(); + }, [ref]); + + return size; +} diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts index b18015f17..7b394da20 100644 --- a/app/hooks/useDictionary.ts +++ b/app/hooks/useDictionary.ts @@ -72,3 +72,5 @@ export default function useDictionary() { }; }, [t]); } + +export type Dictionary = ReturnType; diff --git a/app/hooks/useMediaQuery.ts b/app/hooks/useMediaQuery.ts index 71313713a..ccf8d9059 100644 --- a/app/hooks/useMediaQuery.ts +++ b/app/hooks/useMediaQuery.ts @@ -6,15 +6,12 @@ export default function useMediaQuery(query: string): boolean { useEffect(() => { if (window.matchMedia) { const media = window.matchMedia(query); - if (media.matches !== matches) { setMatches(media.matches); } - const listener = () => { setMatches(media.matches); }; - media.addListener(listener); return () => media.removeListener(listener); } diff --git a/app/hooks/useViewportHeight.ts b/app/hooks/useViewportHeight.ts new file mode 100644 index 000000000..6175f314c --- /dev/null +++ b/app/hooks/useViewportHeight.ts @@ -0,0 +1,24 @@ +import { useLayoutEffect, useState } from "react"; + +export default function useViewportHeight(): number | void { + // https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility + // Note: No support in Firefox at time of writing, however this mainly exists + // for virtual keyboards on mobile devices, so that's okay. + const [height, setHeight] = useState( + () => window.visualViewport?.height || window.innerHeight + ); + + useLayoutEffect(() => { + const handleResize = () => { + setHeight(() => window.visualViewport?.height || window.innerHeight); + }; + + window.visualViewport?.addEventListener("resize", handleResize); + + return () => { + window.visualViewport?.removeEventListener("resize", handleResize); + }; + }, []); + + return height; +} diff --git a/app/menus/CollectionMenu.tsx b/app/menus/CollectionMenu.tsx index 8a99b8fb6..92ec314bf 100644 --- a/app/menus/CollectionMenu.tsx +++ b/app/menus/CollectionMenu.tsx @@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import { useMenuState, MenuButton } from "reakit/Menu"; import { VisuallyHidden } from "reakit/VisuallyHidden"; +import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; import Collection from "~/models/Collection"; import CollectionDelete from "~/scenes/CollectionDelete"; import CollectionEdit from "~/scenes/CollectionEdit"; @@ -25,7 +26,6 @@ import useCurrentTeam from "~/hooks/useCurrentTeam"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import { MenuItem } from "~/types"; -import getDataTransferFiles from "~/utils/getDataTransferFiles"; import { newDocumentPath } from "~/utils/routeHelpers"; type Props = { diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index 08f9693d0..158aff8dc 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -25,6 +25,7 @@ import { useMenuState, MenuButton } from "reakit/Menu"; import { VisuallyHidden } from "reakit/VisuallyHidden"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; +import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; import Document from "~/models/Document"; import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentMove from "~/scenes/DocumentMove"; @@ -48,7 +49,6 @@ import useCurrentTeam from "~/hooks/useCurrentTeam"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import { MenuItem } from "~/types"; -import getDataTransferFiles from "~/utils/getDataTransferFiles"; import { documentHistoryUrl, documentUrl, diff --git a/app/multiplayer/MultiplayerExtension.ts b/app/multiplayer/MultiplayerExtension.ts index 69c31e99c..dda332ec6 100644 --- a/app/multiplayer/MultiplayerExtension.ts +++ b/app/multiplayer/MultiplayerExtension.ts @@ -1,5 +1,4 @@ import { keymap } from "prosemirror-keymap"; -import { Extension } from "rich-markdown-editor"; import { ySyncPlugin, yCursorPlugin, @@ -8,6 +7,7 @@ import { redo, } from "y-prosemirror"; import * as Y from "yjs"; +import { Extension } from "~/editor"; export default class MultiplayerExtension extends Extension { get name() { diff --git a/app/scenes/Document/components/AsyncMultiplayerEditor.ts b/app/scenes/Document/components/AsyncMultiplayerEditor.ts new file mode 100644 index 000000000..bcaa354f7 --- /dev/null +++ b/app/scenes/Document/components/AsyncMultiplayerEditor.ts @@ -0,0 +1,11 @@ +import * as React from "react"; + +const MultiplayerEditor = React.lazy( + () => + import( + /* webpackChunkName: "multiplayer-editor" */ + "./MultiplayerEditor" + ) +); + +export default MultiplayerEditor; diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 93e2390cc..591b97ea7 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -9,8 +9,8 @@ import Editor, { Props as EditorProps } from "~/components/Editor"; import Flex from "~/components/Flex"; import HoverPreview from "~/components/HoverPreview"; import { documentHistoryUrl } from "~/utils/routeHelpers"; +import MultiplayerEditor from "./AsyncMultiplayerEditor"; import EditableTitle from "./EditableTitle"; -import MultiplayerEditor from "./MultiplayerEditor"; type Props = EditorProps & WithTranslation & { diff --git a/app/scenes/DocumentMove.tsx b/app/scenes/DocumentMove.tsx index b99e5d0c1..8f13e48d5 100644 --- a/app/scenes/DocumentMove.tsx +++ b/app/scenes/DocumentMove.tsx @@ -1,4 +1,4 @@ -import { Search } from "js-search"; +import FuzzySearch from "fuzzy-search"; import { last } from "lodash"; import { observer } from "mobx-react"; import { useMemo, useState } from "react"; @@ -29,8 +29,6 @@ function DocumentMove({ document, onRequestClose }: Props) { const searchIndex = useMemo(() => { const paths = collections.pathsToDocuments; - const index = new Search("id"); - index.addIndex("title"); // Build index const indexeableDocuments: DocumentPath[] = []; @@ -43,8 +41,10 @@ function DocumentMove({ document, onRequestClose }: Props) { } }); - index.addDocuments(indexeableDocuments); - return index; + return new FuzzySearch(indexeableDocuments, ["title"], { + caseSensitive: false, + sort: true, + }); }, [documents, collections.pathsToDocuments]); const results = useMemo(() => { @@ -53,10 +53,9 @@ function DocumentMove({ document, onRequestClose }: Props) { if (collections.isLoaded) { if (searchTerm) { - results = searchIndex.search(searchTerm) as DocumentPath[]; + results = searchIndex.search(searchTerm); } else { - // @ts-expect-error it's there, but it's not in typings - results = searchIndex._documents; + results = searchIndex.haystack; } } @@ -107,8 +106,15 @@ function DocumentMove({ document, onRequestClose }: Props) { return null; }; - // @ts-expect-error ts-migrate(7031) FIXME: Binding element 'index' implicitly has an 'any' ty... Remove this comment to see the full error message - const row = ({ index, data, style }) => { + const row = ({ + index, + data, + style, + }: { + index: number; + data: DocumentPath[]; + style: React.CSSProperties; + }) => { const result = data[index]; return ( Important Note: The Outline editor is built on [Prosemirror](https://github.com/prosemirror) and managed in a separate open source repository to encourage re-use: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor). - ``` app ├── components - React components reusable across scenes @@ -61,6 +59,7 @@ small utilities. ``` shared +├── editor - The text editor, based on Prosemirror ├── i18n - Internationalization confiuration │ └── locales - Language specific translation files ├── styles - Styles, colors and other global aesthetics diff --git a/package.json b/package.json index 4f1921c4c..657444819 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,13 @@ "main": "index.js", "scripts": { "clean": "rimraf build", - "build:i18n": "i18next --silent 'app/**/*.tsx' 'app/**/*.ts' 'server/**/*.ts' 'server/**/*.tsx' && mkdir -p ./build/shared/i18n && cp -R ./shared/i18n/locales ./build/shared/i18n", + "build:i18n": "i18next --silent 'shared/**/*.tsx' 'shared/**/*.ts' 'app/**/*.tsx' 'app/**/*.ts' 'server/**/*.ts' 'server/**/*.tsx' && mkdir -p ./build/shared/i18n && cp -R ./shared/i18n/locales ./build/shared/i18n", "build:server": "babel --extensions .ts,.tsx --quiet -d ./build/server ./server && babel --quiet --extensions .ts,.tsx -d./build/shared ./shared && cp ./server/collaboration/Procfile ./build/server/collaboration/Procfile && cp package.json ./build && ln -sf \"$(pwd)/webpack.config.dev.js\" ./build", "build:webpack": "webpack --config webpack.config.prod.js", "build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server", "start": "node ./build/server/index.js", "dev": "NODE_ENV=development yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=collaboration,websockets,admin,web,worker\"", - "dev:watch": "nodemon --exec \"yarn build:server && yarn build:i18n && yarn dev\" -e js,ts --ignore build/ --ignore app/", + "dev:watch": "nodemon --exec \"yarn build:server && yarn build:i18n && yarn dev\" -e js,ts --ignore build/ --ignore app/ --ignore shared/editor", "lint": "eslint app server shared", "deploy": "git push heroku master", "prepare": "yarn yarn-deduplicate yarn.lock", @@ -86,6 +86,8 @@ "fractional-index": "^1.0.0", "framer-motion": "^4.1.17", "fs-extra": "^4.0.2", + "fuzzy-search": "^3.2.1", + "gemoji": "6.x", "http-errors": "1.4.0", "i18next": "^20.6.1", "i18next-http-backend": "^1.3.1", @@ -94,7 +96,6 @@ "ioredis": "^4.28.0", "is-printable-key-event": "^1.0.0", "joplin-turndown-plugin-gfm": "^1.0.12", - "js-search": "^1.4.2", "json-loader": "0.5.4", "jsonwebtoken": "^8.5.0", "jszip": "^3.7.1", @@ -114,6 +115,9 @@ "koa-static": "^4.0.1", "lodash": "^4.17.21", "mammoth": "^1.4.19", + "markdown-it": "^12.3.2", + "markdown-it-container": "^3.0.0", + "markdown-it-emoji": "^2.0.0", "mobx": "^4.15.4", "mobx-react": "^6.3.1", "natural-sort": "^1.0.0", @@ -127,6 +131,20 @@ "pg": "^8.5.1", "pg-hstore": "^2.3.4", "polished": "^3.7.2", + "prosemirror-commands": "^1.1.6", + "prosemirror-dropcursor": "^1.3.3", + "prosemirror-gapcursor": "^1.1.5", + "prosemirror-history": "^1.1.3", + "prosemirror-inputrules": "^1.1.3", + "prosemirror-keymap": "^1.1.4", + "prosemirror-markdown": "^1.5.2", + "prosemirror-model": "^1.13.3", + "prosemirror-schema-list": "^1.1.2", + "prosemirror-state": "^1.3.4", + "prosemirror-tables": "^1.1.1", + "prosemirror-transform": "1.2.5", + "prosemirror-utils": "^0.9.6", + "prosemirror-view": "1.22.0", "query-string": "^7.0.1", "quoted-printable": "^1.0.1", "randomstring": "1.1.5", @@ -140,6 +158,7 @@ "react-dropzone": "^11.3.2", "react-helmet": "^6.1.0", "react-i18next": "^11.13.0", + "react-medium-image-zoom": "^3.1.3", "react-portal": "^4.2.0", "react-router-dom": "^5.2.0", "react-table": "^7.7.0", @@ -148,8 +167,8 @@ "react-window": "^1.8.6", "reakit": "^1.3.10", "reflect-metadata": "^0.1.13", + "refractor": "^3.3.1", "regenerator-runtime": "^0.13.7", - "rich-markdown-editor": "^11.21.3", "semver": "^7.3.2", "sequelize": "^6.9.0", "sequelize-cli": "^6.3.0", @@ -158,7 +177,8 @@ "slate": "0.45.0", "slate-md-serializer": "5.5.4", "slug": "^4.0.4", - "smooth-scroll-into-view-if-needed": "^1.1.29", + "slugify": "^1.6.5", + "smooth-scroll-into-view-if-needed": "^1.1.32", "socket.io": "^2.4.0", "socket.io-redis": "^5.4.0", "socketio-auth": "^0.1.1", @@ -193,11 +213,11 @@ "@types/enzyme-adapter-react-16": "^1.0.6", "@types/formidable": "^2.0.0", "@types/fs-extra": "^9.0.13", + "@types/fuzzy-search": "^2.1.2", "@types/google.analytics": "^0.0.42", "@types/invariant": "^2.2.35", "@types/ioredis": "^4.28.1", "@types/jest": "^27.0.2", - "@types/js-search": "^1.4.0", "@types/jsonwebtoken": "^8.5.5", "@types/koa": "^2.13.4", "@types/koa-compress": "^4.0.3", @@ -208,16 +228,23 @@ "@types/koa-sslify": "^4.0.2", "@types/koa-static": "^4.0.2", "@types/markdown-it": "^12.2.3", + "@types/markdown-it-emoji": "^2.0.2", + "@types/markdown-it-container": "^2.0.4", "@types/natural-sort": "^0.0.21", "@types/node": "15.12.2", "@types/nodemailer": "^6.4.4", "@types/passport-oauth2": "^1.4.11", - "@types/prosemirror-inputrules": "^1.0.4", - "@types/prosemirror-keymap": "^1.0.4", - "@types/prosemirror-markdown": "^1.5.3", - "@types/prosemirror-model": "^1.13.2", - "@types/prosemirror-state": "^1.2.8", - "@types/prosemirror-view": "^1.19.1", + "@types/prosemirror-commands": "^1.0.1", + "@types/prosemirror-dropcursor": "^1.0.0", + "@types/prosemirror-gapcursor": "^1.0.1", + "@types/prosemirror-history": "^1.0.1", + "@types/prosemirror-inputrules": "^1.0.2", + "@types/prosemirror-keymap": "^1.0.1", + "@types/prosemirror-markdown": "^1.0.3", + "@types/prosemirror-model": "^1.7.2", + "@types/prosemirror-schema-list": "^1.0.3", + "@types/prosemirror-state": "^1.2.4", + "@types/prosemirror-view": "^1.11.4", "@types/quoted-printable": "^1.0.0", "@types/randomstring": "^1.1.8", "@types/react": "^17.0.34", @@ -231,6 +258,7 @@ "@types/react-table": "^7.7.9", "@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-window": "^1.8.5", + "@types/refractor": "^3.0.2", "@types/semver": "^7.3.9", "@types/sequelize": "^4.28.10", "@types/slug": "^5.0.2", @@ -286,6 +314,7 @@ }, "resolutions": { "socket.io-parser": "^3.4.0", + "prosemirror-transform": "1.2.5", "prosemirror-view": "1.22.0", "dot-prop": "^5.2.0", "js-yaml": "^3.14.1" diff --git a/server/collaboration/utils/markdownToYDoc.ts b/server/collaboration/utils/markdownToYDoc.ts index 128b91714..d14505599 100644 --- a/server/collaboration/utils/markdownToYDoc.ts +++ b/server/collaboration/utils/markdownToYDoc.ts @@ -1,8 +1,8 @@ import { Node, Fragment } from "prosemirror-model"; -import { parser, schema } from "rich-markdown-editor"; import { prosemirrorToYDoc } from "y-prosemirror"; import * as Y from "yjs"; -import embeds from "@shared/embeds"; +import embeds from "@shared/editor/embeds"; +import { parser, schema } from "@server/editor"; export default function markdownToYDoc( markdown: string, @@ -10,7 +10,7 @@ export default function markdownToYDoc( ): Y.Doc { let node = parser.parse(markdown); - // in rich-markdown-editor embeds were created at runtime by converting links + // in the editor embeds were created at runtime by converting links // into embeds where they match. Because we're converting to a CRDT structure // on the server we need to mimic this behavior. function urlsToEmbeds(node: Node): Node { diff --git a/server/commands/documentUpdater.ts b/server/commands/documentUpdater.ts index e9b5ed364..66fce9abb 100644 --- a/server/commands/documentUpdater.ts +++ b/server/commands/documentUpdater.ts @@ -1,9 +1,9 @@ import invariant from "invariant"; import { uniq } from "lodash"; import { Node } from "prosemirror-model"; -import { schema, serializer } from "rich-markdown-editor"; import { yDocToProsemirrorJSON } from "y-prosemirror"; import * as Y from "yjs"; +import { schema, serializer } from "@server/editor"; import { Document, Event } from "@server/models"; export default async function documentUpdater({ diff --git a/server/editor/__snapshots__/renderToHtml.test.ts.snap b/server/editor/__snapshots__/renderToHtml.test.ts.snap new file mode 100644 index 000000000..8195396f1 --- /dev/null +++ b/server/editor/__snapshots__/renderToHtml.test.ts.snap @@ -0,0 +1,129 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders blockquote 1`] = ` +"
+

blockquote

+
" +`; + +exports[`renders bold marks 1`] = `"

this is bold text

"`; + +exports[`renders bullet list 1`] = ` +"
    +
  • item one
  • +
  • item two +
      +
    • nested item
    • +
    +
  • +
" +`; + +exports[`renders checkbox list 1`] = ` +"
    +
  • [ ]unchecked
  • +
  • [x]checked
  • +
" +`; + +exports[`renders code block 1`] = ` +"
this is indented code
+
" +`; + +exports[`renders code fence 1`] = ` +"
this is code
+
" +`; + +exports[`renders code marks 1`] = `"

this is inline code text

"`; + +exports[`renders headings 1`] = ` +"

Heading 1

+

Heading 2

+

Heading 3

+

Heading 4

" +`; + +exports[`renders highlight marks 1`] = `"

this is highlighted text

"`; + +exports[`renders horizontal rule 1`] = `"
"`; + +exports[`renders image 1`] = `"

\\"caption\\"

"`; + +exports[`renders image with alignment 1`] = `"

\\"caption\\"

"`; + +exports[`renders info notice 1`] = ` +"
+

content of notice

+
" +`; + +exports[`renders italic marks 1`] = `"

this is italic text

"`; + +exports[`renders italic marks 2`] = `"

this is also italic text

"`; + +exports[`renders link marks 1`] = `"

this is linked text

"`; + +exports[`renders ordered list 1`] = ` +"
    +
  1. item one
  2. +
  3. item two
  4. +
" +`; + +exports[`renders ordered list 2`] = ` +"
    +
  1. item one
  2. +
  3. item two
  4. +
" +`; + +exports[`renders plain text as paragraph 1`] = `"

plain text

"`; + +exports[`renders table 1`] = ` +" + + + + + + + + + + + + + + + +
+

heading

+

centered

+

right aligned

+

+

center

+

+

+

+

bottom r

" +`; + +exports[`renders template placeholder marks 1`] = `"

this is a placeholder

"`; + +exports[`renders tip notice 1`] = ` +"
+

content of notice

+
" +`; + +exports[`renders underline marks 1`] = `"

this is underlined text

"`; + +exports[`renders underline marks 2`] = `"

this is strikethrough text

"`; + +exports[`renders warning notice 1`] = ` +"
+

content of notice

+
" +`; diff --git a/server/editor/index.test.ts b/server/editor/index.test.ts new file mode 100644 index 000000000..528df9078 --- /dev/null +++ b/server/editor/index.test.ts @@ -0,0 +1,10 @@ +import { parser } from "."; + +test("renders an empty doc", () => { + const ast = parser.parse(""); + + expect(ast.toJSON()).toEqual({ + content: [{ type: "paragraph" }], + type: "doc", + }); +}); diff --git a/server/editor/index.ts b/server/editor/index.ts new file mode 100644 index 000000000..30969f9c6 --- /dev/null +++ b/server/editor/index.ts @@ -0,0 +1,85 @@ +import { Schema } from "prosemirror-model"; +import ExtensionManager from "@shared/editor/lib/ExtensionManager"; + +// marks +import Bold from "@shared/editor/marks/Bold"; +import Code from "@shared/editor/marks/Code"; +import Highlight from "@shared/editor/marks/Highlight"; +import Italic from "@shared/editor/marks/Italic"; +import Link from "@shared/editor/marks/Link"; +import TemplatePlaceholder from "@shared/editor/marks/Placeholder"; +import Strikethrough from "@shared/editor/marks/Strikethrough"; +import Underline from "@shared/editor/marks/Underline"; + +// nodes +import 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"; + +const extensions = new ExtensionManager([ + new Doc(), + new Text(), + new HardBreak(), + new Paragraph(), + new Blockquote(), + new Emoji(), + new BulletList(), + new CodeBlock(), + new CodeFence(), + new CheckboxList(), + new CheckboxItem(), + new Embed(), + new ListItem(), + new Notice(), + new 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({ + nodes: extensions.nodes, + marks: extensions.marks, +}); + +export const parser = extensions.parser({ + schema, + plugins: extensions.rulePlugins, +}); + +export const serializer = extensions.serializer(); + +export const renderToHtml = (markdown: string): string => + render(markdown, extensions.rulePlugins); diff --git a/server/editor/renderToHtml.test.ts b/server/editor/renderToHtml.test.ts new file mode 100644 index 000000000..40b1868c7 --- /dev/null +++ b/server/editor/renderToHtml.test.ts @@ -0,0 +1,154 @@ +import renderToHtml from "./renderToHtml"; + +test("renders an empty string", () => { + expect(renderToHtml("")).toBe(""); +}); + +test("renders plain text as paragraph", () => { + expect(renderToHtml("plain text")).toMatchSnapshot(); +}); + +test("renders blockquote", () => { + expect(renderToHtml("> blockquote")).toMatchSnapshot(); +}); + +test("renders code block", () => { + expect( + renderToHtml(` + this is indented code +`) + ).toMatchSnapshot(); +}); + +test("renders code fence", () => { + expect( + renderToHtml(`\`\`\`javascript +this is code +\`\`\``) + ).toMatchSnapshot(); +}); + +test("renders checkbox list", () => { + expect( + renderToHtml(`- [ ] unchecked +- [x] checked`) + ).toMatchSnapshot(); +}); + +test("renders bullet list", () => { + expect( + renderToHtml(`- item one +- item two + - nested item`) + ).toMatchSnapshot(); +}); + +test("renders info notice", () => { + expect( + renderToHtml(`:::info +content of notice +:::`) + ).toMatchSnapshot(); +}); + +test("renders warning notice", () => { + expect( + renderToHtml(`:::warning +content of notice +:::`) + ).toMatchSnapshot(); +}); + +test("renders tip notice", () => { + expect( + renderToHtml(`:::tip +content of notice +:::`) + ).toMatchSnapshot(); +}); + +test("renders headings", () => { + expect( + renderToHtml(`# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4`) + ).toMatchSnapshot(); +}); + +test("renders horizontal rule", () => { + expect(renderToHtml(`---`)).toMatchSnapshot(); +}); + +test("renders image", () => { + expect( + renderToHtml(`![caption](https://lorempixel.com/200/200)`) + ).toMatchSnapshot(); +}); + +test("renders image with alignment", () => { + expect( + renderToHtml(`![caption](https://lorempixel.com/200/200 "left-40")`) + ).toMatchSnapshot(); +}); + +test("renders table", () => { + expect( + renderToHtml(` +| heading | centered | right aligned | +|---------|:--------:|--------------:| +| | center | | +| | | bottom r | +`) + ).toMatchSnapshot(); +}); + +test("renders bold marks", () => { + expect(renderToHtml(`this is **bold** text`)).toMatchSnapshot(); +}); + +test("renders code marks", () => { + expect(renderToHtml(`this is \`inline code\` text`)).toMatchSnapshot(); +}); + +test("renders highlight marks", () => { + expect(renderToHtml(`this is ==highlighted== text`)).toMatchSnapshot(); +}); + +test("renders italic marks", () => { + expect(renderToHtml(`this is *italic* text`)).toMatchSnapshot(); + expect(renderToHtml(`this is _also italic_ text`)).toMatchSnapshot(); +}); + +test("renders template placeholder marks", () => { + expect(renderToHtml(`this is !!a placeholder!!`)).toMatchSnapshot(); +}); + +test("renders underline marks", () => { + expect(renderToHtml(`this is __underlined__ text`)).toMatchSnapshot(); +}); + +test("renders link marks", () => { + expect( + renderToHtml(`this is [linked](https://www.example.com) text`) + ).toMatchSnapshot(); +}); + +test("renders underline marks", () => { + expect(renderToHtml(`this is ~~strikethrough~~ text`)).toMatchSnapshot(); +}); + +test("renders ordered list", () => { + expect( + renderToHtml(`1. item one +1. item two`) + ).toMatchSnapshot(); + + expect( + renderToHtml(`1. item one +2. item two`) + ).toMatchSnapshot(); +}); diff --git a/server/editor/renderToHtml.ts b/server/editor/renderToHtml.ts new file mode 100644 index 000000000..062d418e6 --- /dev/null +++ b/server/editor/renderToHtml.ts @@ -0,0 +1,29 @@ +import { PluginSimple } from "markdown-it"; +import createMarkdown from "@shared/editor/lib/markdown/rules"; +import breakRule from "@shared/editor/rules/breaks"; +import checkboxRule from "@shared/editor/rules/checkboxes"; +import embedsRule from "@shared/editor/rules/embeds"; +import emojiRule from "@shared/editor/rules/emoji"; +import markRule from "@shared/editor/rules/mark"; +import noticesRule from "@shared/editor/rules/notices"; +import tablesRule from "@shared/editor/rules/tables"; +import underlinesRule from "@shared/editor/rules/underlines"; + +const defaultRules = [ + embedsRule([]), + breakRule, + checkboxRule, + markRule({ delim: "==", mark: "highlight" }), + markRule({ delim: "!!", mark: "placeholder" }), + underlinesRule, + tablesRule, + noticesRule, + emojiRule, +]; + +export default function renderToHtml( + markdown: string, + rulePlugins: PluginSimple[] = defaultRules +): string { + return createMarkdown({ plugins: rulePlugins }).render(markdown).trim(); +} diff --git a/server/routes/api/middlewares/editor.ts b/server/routes/api/middlewares/editor.ts index 645701bd2..91be5035e 100644 --- a/server/routes/api/middlewares/editor.ts +++ b/server/routes/api/middlewares/editor.ts @@ -1,6 +1,6 @@ import { Context, Next } from "koa"; -import pkg from "rich-markdown-editor/package.json"; import semver from "semver"; +import EDITOR_VERSION from "@shared/editor/version"; import { EditorUpdateError } from "@server/errors"; export default function editor() { @@ -12,7 +12,7 @@ export default function editor() { // changes) then force a client reload. if (clientVersion) { const parsedClientVersion = semver.parse(clientVersion as string); - const parsedCurrentVersion = semver.parse(pkg.version); + const parsedCurrentVersion = semver.parse(EDITOR_VERSION); if ( parsedClientVersion && diff --git a/server/utils/parseDocumentIds.ts b/server/utils/parseDocumentIds.ts index b3aad98af..fbdbba53b 100644 --- a/server/utils/parseDocumentIds.ts +++ b/server/utils/parseDocumentIds.ts @@ -1,5 +1,5 @@ import { Node } from "prosemirror-model"; -import { parser } from "rich-markdown-editor"; +import { parser } from "@server/editor"; export default function parseDocumentIds(text: string): string[] { const value = parser.parse(text); diff --git a/server/utils/parseImages.ts b/server/utils/parseImages.ts index 5ef7c2278..1efe6d643 100644 --- a/server/utils/parseImages.ts +++ b/server/utils/parseImages.ts @@ -1,5 +1,5 @@ import { Node } from "prosemirror-model"; -import { parser } from "rich-markdown-editor"; +import { parser } from "@server/editor"; export default function parseImages(text: string): string[] { const value = parser.parse(text); diff --git a/shared/editor/commands/README.md b/shared/editor/commands/README.md new file mode 100644 index 000000000..a2881b5e0 --- /dev/null +++ b/shared/editor/commands/README.md @@ -0,0 +1,5 @@ +https://prosemirror.net/docs/ref/#commands + +Commands are building block functions that encapsulate an editing action. A command function takes an editor state, optionally a dispatch function that it can use to dispatch a transaction and optionally an EditorView instance. It should return a boolean that indicates whether it could perform any action. + +Additional commands that are not included as part of prosemirror-commands, but are often reused can be found in this folder. \ No newline at end of file diff --git a/shared/editor/commands/backspaceToParagraph.ts b/shared/editor/commands/backspaceToParagraph.ts new file mode 100644 index 000000000..ae06ba43f --- /dev/null +++ b/shared/editor/commands/backspaceToParagraph.ts @@ -0,0 +1,26 @@ +import { NodeType } from "prosemirror-model"; +import { EditorState, Transaction } from "prosemirror-state"; + +export default function backspaceToParagraph(type: NodeType) { + return (state: EditorState, dispatch: (tr: Transaction) => void) => { + const { $from, from, to, empty } = state.selection; + + // if the selection has anything in it then use standard delete behavior + if (!empty) return null; + + // check we're in a matching node + if ($from.parent.type !== type) return null; + + // check if we're at the beginning of the heading + const $pos = state.doc.resolve(from - 1); + if ($pos.parent === $from.parent) return null; + + // okay, replace it with a paragraph + dispatch( + state.tr + .setBlockType(from, to, type.schema.nodes.paragraph) + .scrollIntoView() + ); + return true; + }; +} diff --git a/shared/editor/commands/createAndInsertLink.ts b/shared/editor/commands/createAndInsertLink.ts new file mode 100644 index 000000000..7c267eab9 --- /dev/null +++ b/shared/editor/commands/createAndInsertLink.ts @@ -0,0 +1,85 @@ +import { Node } from "prosemirror-model"; +import { EditorView } from "prosemirror-view"; +import { ToastType } from "../types"; + +function findPlaceholderLink(doc: Node, href: string) { + let result: { pos: number; node: Node } | undefined; + + function findLinks(node: Node, pos = 0) { + // get text nodes + if (node.type.name === "text") { + // get marks for text nodes + node.marks.forEach((mark) => { + // any of the marks links? + if (mark.type.name === "link") { + // any of the links to other docs? + if (mark.attrs.href === href) { + result = { node, pos }; + } + } + }); + } + + if (!node.content.size) { + return; + } + + node.descendants(findLinks); + } + + findLinks(doc); + return result; +} + +const createAndInsertLink = async function ( + view: EditorView, + title: string, + href: string, + options: { + dictionary: any; + onCreateLink: (title: string) => Promise; + onShowToast?: (message: string, code: string) => void; + } +) { + const { dispatch, state } = view; + const { onCreateLink, onShowToast } = options; + + try { + const url = await onCreateLink(title); + const result = findPlaceholderLink(view.state.doc, href); + + if (!result) return; + + dispatch( + view.state.tr + .removeMark( + result.pos, + result.pos + result.node.nodeSize, + state.schema.marks.link + ) + .addMark( + result.pos, + result.pos + result.node.nodeSize, + state.schema.marks.link.create({ href: url }) + ) + ); + } catch (err) { + const result = findPlaceholderLink(view.state.doc, href); + if (!result) return; + + dispatch( + view.state.tr.removeMark( + result.pos, + result.pos + result.node.nodeSize, + state.schema.marks.link + ) + ); + + // let the user know + if (onShowToast) { + onShowToast(options.dictionary.createLinkError, ToastType.Error); + } + } +}; + +export default createAndInsertLink; diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts new file mode 100644 index 000000000..986502c20 --- /dev/null +++ b/shared/editor/commands/insertFiles.ts @@ -0,0 +1,143 @@ +import { NodeSelection } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import uploadPlaceholderPlugin, { + findPlaceholder, +} from "../lib/uploadPlaceholder"; +import { ToastType } from "../types"; + +let uploadId = 0; + +export type Options = { + dictionary: any; + replaceExisting?: boolean; + uploadImage: (file: File) => Promise; + onImageUploadStart?: () => void; + onImageUploadStop?: () => void; + onShowToast?: (message: string, code: string) => void; +}; + +const insertFiles = function ( + view: EditorView, + event: Event | React.ChangeEvent, + pos: number, + files: File[], + options: Options +): void { + // filter to only include image files + const images = files.filter((file) => /image/i.test(file.type)); + if (images.length === 0) return; + + const { + dictionary, + uploadImage, + onImageUploadStart, + onImageUploadStop, + onShowToast, + } = options; + + if (!uploadImage) { + console.warn( + "uploadImage callback must be defined to handle image uploads." + ); + return; + } + + // okay, we have some dropped images and a handler – lets stop this + // event going any further up the stack + event.preventDefault(); + + // let the user know we're starting to process the images + if (onImageUploadStart) onImageUploadStart(); + + const { schema } = view.state; + + // we'll use this to track of how many images have succeeded or failed + let complete = 0; + + // the user might have dropped multiple images at once, we need to loop + for (const file of images) { + const id = `upload-${uploadId++}`; + + const { tr } = view.state; + + // insert a placeholder at this position, or mark an existing image as being + // replaced + tr.setMeta(uploadPlaceholderPlugin, { + add: { + id, + file, + pos, + replaceExisting: options.replaceExisting, + }, + }); + view.dispatch(tr); + + // start uploading the image file to the server. Using "then" syntax + // to allow all placeholders to be entered at once with the uploads + // happening in the background in parallel. + uploadImage(file) + .then((src) => { + // otherwise, insert it at the placeholder's position, and remove + // the placeholder itself + const newImg = new Image(); + + newImg.onload = () => { + const result = findPlaceholder(view.state, id); + + // if the content around the placeholder has been deleted + // then forget about inserting this image + if (result === null) { + return; + } + + const [from, to] = result; + view.dispatch( + view.state.tr + .replaceWith(from, to || from, schema.nodes.image.create({ src })) + .setMeta(uploadPlaceholderPlugin, { remove: { id } }) + ); + + // If the users selection is still at the image then make sure to select + // the entire node once done. Otherwise, if the selection has moved + // elsewhere then we don't want to modify it + if (view.state.selection.from === from) { + view.dispatch( + view.state.tr.setSelection( + new NodeSelection(view.state.doc.resolve(from)) + ) + ); + } + }; + + newImg.onerror = (error) => { + throw error; + }; + + newImg.src = src; + }) + .catch((error) => { + console.error(error); + + // cleanup the placeholder if there is a failure + const transaction = view.state.tr.setMeta(uploadPlaceholderPlugin, { + remove: { id }, + }); + view.dispatch(transaction); + + // let the user know + if (onShowToast) { + onShowToast(dictionary.imageUploadError, ToastType.Error); + } + }) + .finally(() => { + complete++; + + // once everything is done, let the user know + if (complete === images.length && onImageUploadStop) { + onImageUploadStop(); + } + }); + } +}; + +export default insertFiles; diff --git a/shared/editor/commands/moveLeft.ts b/shared/editor/commands/moveLeft.ts new file mode 100644 index 000000000..b9c2440c6 --- /dev/null +++ b/shared/editor/commands/moveLeft.ts @@ -0,0 +1,111 @@ +/* +Copyright 2020 Atlassian Pty Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + */ + +// This file is based on the implementation found here: +// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts + +import { + Selection, + EditorState, + Transaction, + TextSelection, +} from "prosemirror-state"; +import isMarkActive from "../queries/isMarkActive"; + +function hasCode(state: EditorState, pos: number) { + const { code_inline } = state.schema.marks; + const node = pos >= 0 && state.doc.nodeAt(pos); + + return node + ? !!node.marks.filter((mark) => mark.type === code_inline).length + : false; +} + +export default function moveLeft() { + return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => { + const { code_inline } = state.schema.marks; + const { empty, $cursor } = state.selection as TextSelection; + if (!empty || !$cursor) { + return false; + } + + const { storedMarks } = state.tr; + + if (code_inline) { + const insideCode = code_inline && isMarkActive(code_inline)(state); + const currentPosHasCode = hasCode(state, $cursor.pos); + const nextPosHasCode = hasCode(state, $cursor.pos - 1); + const nextNextPosHasCode = hasCode(state, $cursor.pos - 2); + + const exitingCode = + currentPosHasCode && !nextPosHasCode && Array.isArray(storedMarks); + const atLeftEdge = + nextPosHasCode && + !nextNextPosHasCode && + (storedMarks === null || + (Array.isArray(storedMarks) && !!storedMarks.length)); + const atRightEdge = + ((exitingCode && Array.isArray(storedMarks) && !storedMarks.length) || + (!exitingCode && storedMarks === null)) && + !nextPosHasCode && + nextNextPosHasCode; + const enteringCode = + !currentPosHasCode && + nextPosHasCode && + Array.isArray(storedMarks) && + !storedMarks.length; + + // at the right edge: remove code mark and move the cursor to the left + if (!insideCode && atRightEdge) { + const tr = state.tr.setSelection( + Selection.near(state.doc.resolve($cursor.pos - 1)) + ); + + dispatch(tr.removeStoredMark(code_inline)); + + return true; + } + + // entering code mark (from right edge): don't move the cursor, just add the mark + if (!insideCode && enteringCode) { + dispatch(state.tr.addStoredMark(code_inline.create())); + return true; + } + + // at the left edge: add code mark and move the cursor to the left + if (insideCode && atLeftEdge) { + const tr = state.tr.setSelection( + Selection.near(state.doc.resolve($cursor.pos - 1)) + ); + + dispatch(tr.addStoredMark(code_inline.create())); + return true; + } + + // exiting code mark (or at the beginning of the line): don't move the cursor, just remove the mark + const isFirstChild = $cursor.index($cursor.depth - 1) === 0; + if ( + insideCode && + (exitingCode || (!$cursor.nodeBefore && isFirstChild)) + ) { + dispatch(state.tr.removeStoredMark(code_inline)); + return true; + } + } + + return false; + }; +} diff --git a/shared/editor/commands/moveRight.ts b/shared/editor/commands/moveRight.ts new file mode 100644 index 000000000..519f95527 --- /dev/null +++ b/shared/editor/commands/moveRight.ts @@ -0,0 +1,71 @@ +/* +Copyright 2020 Atlassian Pty Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + */ + +// This file is based on the implementation found here: +// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts + +import { EditorState, Transaction, TextSelection } from "prosemirror-state"; +import isMarkActive from "../queries/isMarkActive"; + +export default function moveRight() { + return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => { + const { code_inline } = state.schema.marks; + const { empty, $cursor } = state.selection as TextSelection; + if (!empty || !$cursor) { + return false; + } + + const { storedMarks } = state.tr; + if (code_inline) { + const insideCode = isMarkActive(code_inline)(state); + const currentPosHasCode = state.doc.rangeHasMark( + $cursor.pos, + $cursor.pos, + code_inline + ); + const nextPosHasCode = state.doc.rangeHasMark( + $cursor.pos, + $cursor.pos + 1, + code_inline + ); + + const exitingCode = + !currentPosHasCode && + !nextPosHasCode && + (!storedMarks || !!storedMarks.length); + const enteringCode = + !currentPosHasCode && + nextPosHasCode && + (!storedMarks || !storedMarks.length); + + // entering code mark (from the left edge): don't move the cursor, just add the mark + if (!insideCode && enteringCode) { + dispatch(state.tr.addStoredMark(code_inline.create())); + + return true; + } + + // exiting code mark: don't move the cursor, just remove the mark + if (insideCode && exitingCode) { + dispatch(state.tr.removeStoredMark(code_inline)); + + return true; + } + } + + return false; + }; +} diff --git a/shared/editor/commands/splitHeading.ts b/shared/editor/commands/splitHeading.ts new file mode 100644 index 000000000..e5b7e6298 --- /dev/null +++ b/shared/editor/commands/splitHeading.ts @@ -0,0 +1,54 @@ +import { NodeType } from "prosemirror-model"; +import { EditorState, TextSelection, Transaction } from "prosemirror-state"; +import { findBlockNodes } from "prosemirror-utils"; +import findCollapsedNodes from "../queries/findCollapsedNodes"; + +export default function splitHeading(type: NodeType) { + return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => { + const { $from, from, $to, to } = state.selection; + + // check we're in a matching heading node + if ($from.parent.type !== type) return false; + + // check that the caret is at the end of the content, if it isn't then + // standard node splitting behaviour applies + const endPos = $to.after() - 1; + if (endPos !== to) return false; + + // If the node isn't collapsed standard behavior applies + if (!$from.parent.attrs.collapsed) return false; + + // Find the next visible block after this one. It takes into account nested + // collapsed headings and reaching the end of the document + const allBlocks = findBlockNodes(state.doc); + const collapsedBlocks = findCollapsedNodes(state.doc); + const visibleBlocks = allBlocks.filter( + (a) => !collapsedBlocks.find((b) => b.pos === a.pos) + ); + const nextVisibleBlock = visibleBlocks.find((a) => a.pos > from); + const pos = nextVisibleBlock + ? nextVisibleBlock.pos + : state.doc.content.size; + + // Insert our new heading directly before the next visible block + const transaction = state.tr.insert( + pos, + type.create({ ...$from.parent.attrs, collapsed: false }) + ); + + // Move the selection into the new heading node and make sure it's on screen + dispatch( + transaction + .setSelection( + TextSelection.near( + transaction.doc.resolve( + Math.min(pos + 1, transaction.doc.content.size) + ) + ) + ) + .scrollIntoView() + ); + + return true; + }; +} diff --git a/shared/editor/commands/toggleBlockType.ts b/shared/editor/commands/toggleBlockType.ts new file mode 100644 index 000000000..162ef1562 --- /dev/null +++ b/shared/editor/commands/toggleBlockType.ts @@ -0,0 +1,20 @@ +import { setBlockType } from "prosemirror-commands"; +import { NodeType } from "prosemirror-model"; +import { EditorState, Transaction } from "prosemirror-state"; +import isNodeActive from "../queries/isNodeActive"; + +export default function toggleBlockType( + type: NodeType, + toggleType: NodeType, + attrs = {} +) { + return (state: EditorState, dispatch: (tr: Transaction) => void) => { + const isActive = isNodeActive(type, attrs)(state); + + if (isActive) { + return setBlockType(toggleType)(state, dispatch); + } + + return setBlockType(type, attrs)(state, dispatch); + }; +} diff --git a/shared/editor/commands/toggleList.ts b/shared/editor/commands/toggleList.ts new file mode 100644 index 000000000..d7e3d3502 --- /dev/null +++ b/shared/editor/commands/toggleList.ts @@ -0,0 +1,43 @@ +import { NodeType } from "prosemirror-model"; +import { wrapInList, liftListItem } from "prosemirror-schema-list"; +import { EditorState, Transaction } from "prosemirror-state"; +import { findParentNode } from "prosemirror-utils"; +import isList from "../queries/isList"; + +export default function toggleList(listType: NodeType, itemType: NodeType) { + return (state: EditorState, dispatch: (tr: Transaction) => void) => { + const { schema, selection } = state; + const { $from, $to } = selection; + const range = $from.blockRange($to); + + if (!range) { + return false; + } + + const parentList = findParentNode((node) => isList(node, schema))( + selection + ); + + if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) { + if (parentList.node.type === listType) { + return liftListItem(itemType)(state, dispatch); + } + + if ( + isList(parentList.node, schema) && + listType.validContent(parentList.node.content) + ) { + const { tr } = state; + tr.setNodeMarkup(parentList.pos, listType); + + if (dispatch) { + dispatch(tr); + } + + return false; + } + } + + return wrapInList(listType)(state, dispatch); + }; +} diff --git a/shared/editor/commands/toggleWrap.ts b/shared/editor/commands/toggleWrap.ts new file mode 100644 index 000000000..bfd6b5168 --- /dev/null +++ b/shared/editor/commands/toggleWrap.ts @@ -0,0 +1,19 @@ +import { wrapIn, lift } from "prosemirror-commands"; +import { NodeType } from "prosemirror-model"; +import { EditorState, Transaction } from "prosemirror-state"; +import isNodeActive from "../queries/isNodeActive"; + +export default function toggleWrap( + type: NodeType, + attrs?: Record +) { + return (state: EditorState, dispatch: (tr: Transaction) => void) => { + const isActive = isNodeActive(type)(state); + + if (isActive) { + return lift(state, dispatch); + } + + return wrapIn(type, attrs)(state, dispatch); + }; +} diff --git a/shared/embeds/Abstract.test.ts b/shared/editor/embeds/Abstract.test.ts similarity index 100% rename from shared/embeds/Abstract.test.ts rename to shared/editor/embeds/Abstract.test.ts diff --git a/shared/embeds/Abstract.tsx b/shared/editor/embeds/Abstract.tsx similarity index 100% rename from shared/embeds/Abstract.tsx rename to shared/editor/embeds/Abstract.tsx diff --git a/shared/embeds/Airtable.test.ts b/shared/editor/embeds/Airtable.test.ts similarity index 100% rename from shared/embeds/Airtable.test.ts rename to shared/editor/embeds/Airtable.test.ts diff --git a/shared/embeds/Airtable.tsx b/shared/editor/embeds/Airtable.tsx similarity index 100% rename from shared/embeds/Airtable.tsx rename to shared/editor/embeds/Airtable.tsx diff --git a/shared/embeds/Bilibili.test.ts b/shared/editor/embeds/Bilibili.test.ts similarity index 100% rename from shared/embeds/Bilibili.test.ts rename to shared/editor/embeds/Bilibili.test.ts diff --git a/shared/embeds/Bilibili.tsx b/shared/editor/embeds/Bilibili.tsx similarity index 100% rename from shared/embeds/Bilibili.tsx rename to shared/editor/embeds/Bilibili.tsx diff --git a/shared/embeds/Cawemo.test.ts b/shared/editor/embeds/Cawemo.test.ts similarity index 100% rename from shared/embeds/Cawemo.test.ts rename to shared/editor/embeds/Cawemo.test.ts diff --git a/shared/embeds/Cawemo.tsx b/shared/editor/embeds/Cawemo.tsx similarity index 100% rename from shared/embeds/Cawemo.tsx rename to shared/editor/embeds/Cawemo.tsx diff --git a/shared/embeds/ClickUp.test.ts b/shared/editor/embeds/ClickUp.test.ts similarity index 100% rename from shared/embeds/ClickUp.test.ts rename to shared/editor/embeds/ClickUp.test.ts diff --git a/shared/embeds/ClickUp.tsx b/shared/editor/embeds/ClickUp.tsx similarity index 100% rename from shared/embeds/ClickUp.tsx rename to shared/editor/embeds/ClickUp.tsx diff --git a/shared/embeds/Codepen.test.ts b/shared/editor/embeds/Codepen.test.ts similarity index 100% rename from shared/embeds/Codepen.test.ts rename to shared/editor/embeds/Codepen.test.ts diff --git a/shared/embeds/Codepen.tsx b/shared/editor/embeds/Codepen.tsx similarity index 100% rename from shared/embeds/Codepen.tsx rename to shared/editor/embeds/Codepen.tsx diff --git a/shared/embeds/Descript.tsx b/shared/editor/embeds/Descript.tsx similarity index 100% rename from shared/embeds/Descript.tsx rename to shared/editor/embeds/Descript.tsx diff --git a/shared/embeds/Diagrams.test.ts b/shared/editor/embeds/Diagrams.test.ts similarity index 100% rename from shared/embeds/Diagrams.test.ts rename to shared/editor/embeds/Diagrams.test.ts diff --git a/shared/embeds/Diagrams.tsx b/shared/editor/embeds/Diagrams.tsx similarity index 100% rename from shared/embeds/Diagrams.tsx rename to shared/editor/embeds/Diagrams.tsx diff --git a/shared/embeds/Figma.test.ts b/shared/editor/embeds/Figma.test.ts similarity index 100% rename from shared/embeds/Figma.test.ts rename to shared/editor/embeds/Figma.test.ts diff --git a/shared/embeds/Figma.tsx b/shared/editor/embeds/Figma.tsx similarity index 100% rename from shared/embeds/Figma.tsx rename to shared/editor/embeds/Figma.tsx diff --git a/shared/embeds/Framer.test.ts b/shared/editor/embeds/Framer.test.ts similarity index 100% rename from shared/embeds/Framer.test.ts rename to shared/editor/embeds/Framer.test.ts diff --git a/shared/embeds/Framer.tsx b/shared/editor/embeds/Framer.tsx similarity index 100% rename from shared/embeds/Framer.tsx rename to shared/editor/embeds/Framer.tsx diff --git a/shared/embeds/Gist.test.ts b/shared/editor/embeds/Gist.test.ts similarity index 100% rename from shared/embeds/Gist.test.ts rename to shared/editor/embeds/Gist.test.ts diff --git a/shared/embeds/Gist.tsx b/shared/editor/embeds/Gist.tsx similarity index 100% rename from shared/embeds/Gist.tsx rename to shared/editor/embeds/Gist.tsx diff --git a/shared/embeds/GoogleCalendar.test.ts b/shared/editor/embeds/GoogleCalendar.test.ts similarity index 100% rename from shared/embeds/GoogleCalendar.test.ts rename to shared/editor/embeds/GoogleCalendar.test.ts diff --git a/shared/embeds/GoogleCalendar.tsx b/shared/editor/embeds/GoogleCalendar.tsx similarity index 100% rename from shared/embeds/GoogleCalendar.tsx rename to shared/editor/embeds/GoogleCalendar.tsx diff --git a/shared/embeds/GoogleDataStudio.test.ts b/shared/editor/embeds/GoogleDataStudio.test.ts similarity index 100% rename from shared/embeds/GoogleDataStudio.test.ts rename to shared/editor/embeds/GoogleDataStudio.test.ts diff --git a/shared/embeds/GoogleDataStudio.tsx b/shared/editor/embeds/GoogleDataStudio.tsx similarity index 100% rename from shared/embeds/GoogleDataStudio.tsx rename to shared/editor/embeds/GoogleDataStudio.tsx diff --git a/shared/embeds/GoogleDocs.test.ts b/shared/editor/embeds/GoogleDocs.test.ts similarity index 100% rename from shared/embeds/GoogleDocs.test.ts rename to shared/editor/embeds/GoogleDocs.test.ts diff --git a/shared/embeds/GoogleDocs.tsx b/shared/editor/embeds/GoogleDocs.tsx similarity index 100% rename from shared/embeds/GoogleDocs.tsx rename to shared/editor/embeds/GoogleDocs.tsx diff --git a/shared/embeds/GoogleDrawings.test.ts b/shared/editor/embeds/GoogleDrawings.test.ts similarity index 100% rename from shared/embeds/GoogleDrawings.test.ts rename to shared/editor/embeds/GoogleDrawings.test.ts diff --git a/shared/embeds/GoogleDrawings.tsx b/shared/editor/embeds/GoogleDrawings.tsx similarity index 100% rename from shared/embeds/GoogleDrawings.tsx rename to shared/editor/embeds/GoogleDrawings.tsx diff --git a/shared/embeds/GoogleDrive.test.ts b/shared/editor/embeds/GoogleDrive.test.ts similarity index 100% rename from shared/embeds/GoogleDrive.test.ts rename to shared/editor/embeds/GoogleDrive.test.ts diff --git a/shared/embeds/GoogleDrive.tsx b/shared/editor/embeds/GoogleDrive.tsx similarity index 100% rename from shared/embeds/GoogleDrive.tsx rename to shared/editor/embeds/GoogleDrive.tsx diff --git a/shared/embeds/GoogleSheets.test.ts b/shared/editor/embeds/GoogleSheets.test.ts similarity index 100% rename from shared/embeds/GoogleSheets.test.ts rename to shared/editor/embeds/GoogleSheets.test.ts diff --git a/shared/embeds/GoogleSheets.tsx b/shared/editor/embeds/GoogleSheets.tsx similarity index 100% rename from shared/embeds/GoogleSheets.tsx rename to shared/editor/embeds/GoogleSheets.tsx diff --git a/shared/embeds/GoogleSlides.test.ts b/shared/editor/embeds/GoogleSlides.test.ts similarity index 100% rename from shared/embeds/GoogleSlides.test.ts rename to shared/editor/embeds/GoogleSlides.test.ts diff --git a/shared/embeds/GoogleSlides.tsx b/shared/editor/embeds/GoogleSlides.tsx similarity index 100% rename from shared/embeds/GoogleSlides.tsx rename to shared/editor/embeds/GoogleSlides.tsx diff --git a/shared/embeds/InVision.test.ts b/shared/editor/embeds/InVision.test.ts similarity index 100% rename from shared/embeds/InVision.test.ts rename to shared/editor/embeds/InVision.test.ts diff --git a/shared/embeds/InVision.tsx b/shared/editor/embeds/InVision.tsx similarity index 100% rename from shared/embeds/InVision.tsx rename to shared/editor/embeds/InVision.tsx diff --git a/shared/embeds/Loom.test.ts b/shared/editor/embeds/Loom.test.ts similarity index 100% rename from shared/embeds/Loom.test.ts rename to shared/editor/embeds/Loom.test.ts diff --git a/shared/embeds/Loom.tsx b/shared/editor/embeds/Loom.tsx similarity index 100% rename from shared/embeds/Loom.tsx rename to shared/editor/embeds/Loom.tsx diff --git a/shared/embeds/Lucidchart.test.ts b/shared/editor/embeds/Lucidchart.test.ts similarity index 100% rename from shared/embeds/Lucidchart.test.ts rename to shared/editor/embeds/Lucidchart.test.ts diff --git a/shared/embeds/Lucidchart.tsx b/shared/editor/embeds/Lucidchart.tsx similarity index 100% rename from shared/embeds/Lucidchart.tsx rename to shared/editor/embeds/Lucidchart.tsx diff --git a/shared/embeds/Marvel.test.ts b/shared/editor/embeds/Marvel.test.ts similarity index 100% rename from shared/embeds/Marvel.test.ts rename to shared/editor/embeds/Marvel.test.ts diff --git a/shared/embeds/Marvel.tsx b/shared/editor/embeds/Marvel.tsx similarity index 100% rename from shared/embeds/Marvel.tsx rename to shared/editor/embeds/Marvel.tsx diff --git a/shared/embeds/Mindmeister.test.ts b/shared/editor/embeds/Mindmeister.test.ts similarity index 100% rename from shared/embeds/Mindmeister.test.ts rename to shared/editor/embeds/Mindmeister.test.ts diff --git a/shared/embeds/Mindmeister.tsx b/shared/editor/embeds/Mindmeister.tsx similarity index 100% rename from shared/embeds/Mindmeister.tsx rename to shared/editor/embeds/Mindmeister.tsx diff --git a/shared/embeds/Miro.test.ts b/shared/editor/embeds/Miro.test.ts similarity index 100% rename from shared/embeds/Miro.test.ts rename to shared/editor/embeds/Miro.test.ts diff --git a/shared/embeds/Miro.tsx b/shared/editor/embeds/Miro.tsx similarity index 100% rename from shared/embeds/Miro.tsx rename to shared/editor/embeds/Miro.tsx diff --git a/shared/embeds/ModeAnalytics.test.ts b/shared/editor/embeds/ModeAnalytics.test.ts similarity index 100% rename from shared/embeds/ModeAnalytics.test.ts rename to shared/editor/embeds/ModeAnalytics.test.ts diff --git a/shared/embeds/ModeAnalytics.tsx b/shared/editor/embeds/ModeAnalytics.tsx similarity index 100% rename from shared/embeds/ModeAnalytics.tsx rename to shared/editor/embeds/ModeAnalytics.tsx diff --git a/shared/embeds/Pitch.tsx b/shared/editor/embeds/Pitch.tsx similarity index 100% rename from shared/embeds/Pitch.tsx rename to shared/editor/embeds/Pitch.tsx diff --git a/shared/embeds/Prezi.test.ts b/shared/editor/embeds/Prezi.test.ts similarity index 100% rename from shared/embeds/Prezi.test.ts rename to shared/editor/embeds/Prezi.test.ts diff --git a/shared/embeds/Prezi.tsx b/shared/editor/embeds/Prezi.tsx similarity index 100% rename from shared/embeds/Prezi.tsx rename to shared/editor/embeds/Prezi.tsx diff --git a/shared/embeds/Spotify.test.ts b/shared/editor/embeds/Spotify.test.ts similarity index 100% rename from shared/embeds/Spotify.test.ts rename to shared/editor/embeds/Spotify.test.ts diff --git a/shared/embeds/Spotify.tsx b/shared/editor/embeds/Spotify.tsx similarity index 100% rename from shared/embeds/Spotify.tsx rename to shared/editor/embeds/Spotify.tsx diff --git a/shared/embeds/Trello.tsx b/shared/editor/embeds/Trello.tsx similarity index 100% rename from shared/embeds/Trello.tsx rename to shared/editor/embeds/Trello.tsx diff --git a/shared/embeds/Typeform.test.ts b/shared/editor/embeds/Typeform.test.ts similarity index 100% rename from shared/embeds/Typeform.test.ts rename to shared/editor/embeds/Typeform.test.ts diff --git a/shared/embeds/Typeform.tsx b/shared/editor/embeds/Typeform.tsx similarity index 100% rename from shared/embeds/Typeform.tsx rename to shared/editor/embeds/Typeform.tsx diff --git a/shared/embeds/Vimeo.test.ts b/shared/editor/embeds/Vimeo.test.ts similarity index 100% rename from shared/embeds/Vimeo.test.ts rename to shared/editor/embeds/Vimeo.test.ts diff --git a/shared/embeds/Vimeo.tsx b/shared/editor/embeds/Vimeo.tsx similarity index 100% rename from shared/embeds/Vimeo.tsx rename to shared/editor/embeds/Vimeo.tsx diff --git a/shared/embeds/Whimsical.tsx b/shared/editor/embeds/Whimsical.tsx similarity index 100% rename from shared/embeds/Whimsical.tsx rename to shared/editor/embeds/Whimsical.tsx diff --git a/shared/embeds/YouTube.test.ts b/shared/editor/embeds/YouTube.test.ts similarity index 100% rename from shared/embeds/YouTube.test.ts rename to shared/editor/embeds/YouTube.test.ts diff --git a/shared/embeds/YouTube.tsx b/shared/editor/embeds/YouTube.tsx similarity index 100% rename from shared/embeds/YouTube.tsx rename to shared/editor/embeds/YouTube.tsx diff --git a/shared/embeds/components/Frame.tsx b/shared/editor/embeds/components/Frame.tsx similarity index 100% rename from shared/embeds/components/Frame.tsx rename to shared/editor/embeds/components/Frame.tsx diff --git a/shared/embeds/components/Image.tsx b/shared/editor/embeds/components/Image.tsx similarity index 84% rename from shared/embeds/components/Image.tsx rename to shared/editor/embeds/components/Image.tsx index 29b0b60a5..14245c3ba 100644 --- a/shared/embeds/components/Image.tsx +++ b/shared/editor/embeds/components/Image.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { cdnPath } from "../../utils/urls"; +import { cdnPath } from "../../../utils/urls"; type Props = { alt: string; diff --git a/shared/embeds/index.tsx b/shared/editor/embeds/index.tsx similarity index 99% rename from shared/embeds/index.tsx rename to shared/editor/embeds/index.tsx index 9ec9a8f39..6701c5f10 100644 --- a/shared/embeds/index.tsx +++ b/shared/editor/embeds/index.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { EmbedDescriptor } from "rich-markdown-editor/dist/types"; import styled from "styled-components"; +import { EmbedDescriptor } from "@shared/editor/types"; import Abstract from "./Abstract"; import Airtable from "./Airtable"; import Bilibili from "./Bilibili"; diff --git a/shared/editor/lib/Extension.ts b/shared/editor/lib/Extension.ts new file mode 100644 index 000000000..34f92985a --- /dev/null +++ b/shared/editor/lib/Extension.ts @@ -0,0 +1,76 @@ +import { PluginSimple } from "markdown-it"; +import { InputRule } from "prosemirror-inputrules"; +import { NodeType, MarkType, Schema } from "prosemirror-model"; +import { EditorState, Plugin, Transaction } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { Editor } from "../../../app/editor"; + +export type Command = ( + state: EditorState, + dispatch: (tr: Transaction) => void +) => boolean; + +export type CommandFactory = ( + attrs?: Record +) => ( + state: EditorState, + dispatch: (tr: Transaction) => void, + view: EditorView +) => boolean; + +export default class Extension { + options: any; + editor: Editor; + + constructor(options: Record = {}) { + this.options = { + ...this.defaultOptions, + ...options, + }; + } + + bindEditor(editor: Editor) { + this.editor = editor; + } + + get type() { + return "extension"; + } + + get name() { + return ""; + } + + get plugins(): Plugin[] { + return []; + } + + get rulePlugins(): PluginSimple[] { + return []; + } + + get defaultOptions() { + return {}; + } + + keys(_options: { + type?: NodeType | MarkType; + schema: Schema; + }): Record { + return {}; + } + + inputRules(_options: { + type?: NodeType | MarkType; + schema: Schema; + }): InputRule[] { + return []; + } + + commands(_options: { + type?: NodeType | MarkType; + schema: Schema; + }): Record | CommandFactory { + return {}; + } +} diff --git a/shared/editor/lib/ExtensionManager.ts b/shared/editor/lib/ExtensionManager.ts new file mode 100644 index 000000000..b2b3a306f --- /dev/null +++ b/shared/editor/lib/ExtensionManager.ts @@ -0,0 +1,213 @@ +import { PluginSimple } from "markdown-it"; +import { keymap } from "prosemirror-keymap"; +import { MarkdownParser, TokenConfig } from "prosemirror-markdown"; +import { Schema } from "prosemirror-model"; +import { EditorView } from "prosemirror-view"; +import Mark from "../marks/Mark"; +import Node from "../nodes/Node"; +import Extension, { CommandFactory } from "./Extension"; +import makeRules from "./markdown/rules"; +import { MarkdownSerializer } from "./markdown/serializer"; + +export default class ExtensionManager { + extensions: (Node | Mark | Extension)[]; + + constructor(extensions: (Node | Mark | Extension)[] = [], editor?: any) { + if (editor) { + extensions.forEach((extension) => { + extension.bindEditor(editor); + }); + } + + this.extensions = extensions; + } + + get nodes() { + return this.extensions + .filter((extension) => extension.type === "node") + .reduce( + (nodes, node: Node) => ({ + ...nodes, + [node.name]: node.schema, + }), + {} + ); + } + + serializer() { + const nodes = this.extensions + .filter((extension) => extension.type === "node") + .reduce( + (nodes, extension: Node) => ({ + ...nodes, + [extension.name]: extension.toMarkdown, + }), + {} + ); + + const marks = this.extensions + .filter((extension) => extension.type === "mark") + .reduce( + (marks, extension: Mark) => ({ + ...marks, + [extension.name]: extension.toMarkdown, + }), + {} + ); + + return new MarkdownSerializer(nodes, marks); + } + + parser({ + schema, + rules, + plugins, + }: { + schema: Schema; + rules?: Record; + plugins?: PluginSimple[]; + }): MarkdownParser { + const tokens: Record = this.extensions + .filter( + (extension) => extension.type === "mark" || extension.type === "node" + ) + .reduce((nodes, extension: Node | Mark) => { + const md = extension.parseMarkdown(); + if (!md) return nodes; + + return { + ...nodes, + [extension.markdownToken || extension.name]: md, + }; + }, {}); + + return new MarkdownParser(schema, makeRules({ rules, plugins }), tokens); + } + + get marks() { + return this.extensions + .filter((extension) => extension.type === "mark") + .reduce( + (marks, { name, schema }: Mark) => ({ + ...marks, + [name]: schema, + }), + {} + ); + } + + get plugins() { + return this.extensions + .filter((extension) => "plugins" in extension) + .reduce((allPlugins, { plugins }) => [...allPlugins, ...plugins], []); + } + + get rulePlugins() { + return this.extensions + .filter((extension) => "rulePlugins" in extension) + .reduce( + (allRulePlugins, { rulePlugins }) => [ + ...allRulePlugins, + ...rulePlugins, + ], + [] + ); + } + + keymaps({ schema }: { schema: Schema }) { + const extensionKeymaps = this.extensions + .filter((extension) => ["extension"].includes(extension.type)) + .filter((extension) => extension.keys) + .map((extension: Extension) => extension.keys({ schema })); + + const nodeKeymaps = this.extensions + .filter((extension) => ["node", "mark"].includes(extension.type)) + .filter((extension) => extension.keys) + .map((extension: Node | Mark) => + extension.keys({ + type: schema[`${extension.type}s`][extension.name], + schema, + }) + ); + + return [ + ...extensionKeymaps, + ...nodeKeymaps, + ].map((keys: Record) => keymap(keys)); + } + + inputRules({ schema }: { schema: Schema }) { + const extensionInputRules = this.extensions + .filter((extension) => ["extension"].includes(extension.type)) + .filter((extension) => extension.inputRules) + .map((extension: Extension) => extension.inputRules({ schema })); + + const nodeMarkInputRules = this.extensions + .filter((extension) => ["node", "mark"].includes(extension.type)) + .filter((extension) => extension.inputRules) + .map((extension) => + extension.inputRules({ + type: schema[`${extension.type}s`][extension.name], + schema, + }) + ); + + return [...extensionInputRules, ...nodeMarkInputRules].reduce( + (allInputRules, inputRules) => [...allInputRules, ...inputRules], + [] + ); + } + + commands({ schema, view }: { schema: Schema; view: EditorView }) { + return this.extensions + .filter((extension) => extension.commands) + .reduce((allCommands, extension) => { + const { name, type } = extension; + const commands = {}; + + // @ts-expect-error FIXME + const value = extension.commands({ + schema, + ...(["node", "mark"].includes(type) + ? { + type: schema[`${type}s`][name], + } + : {}), + }); + + const apply = ( + callback: CommandFactory, + attrs: Record + ) => { + if (!view.editable) { + return false; + } + view.focus(); + return callback(attrs)(view.state, view.dispatch, view); + }; + + const handle = (_name: string, _value: CommandFactory) => { + if (Array.isArray(_value)) { + commands[_name] = (attrs: Record) => + _value.forEach((callback) => apply(callback, attrs)); + } else if (typeof _value === "function") { + commands[_name] = (attrs: Record) => + apply(_value, attrs); + } + }; + + if (typeof value === "object") { + Object.entries(value).forEach(([commandName, commandValue]) => { + handle(commandName, commandValue); + }); + } else { + handle(name, value); + } + + return { + ...allCommands, + ...commands, + }; + }, {}); + } +} diff --git a/shared/editor/lib/filterExcessSeparators.ts b/shared/editor/lib/filterExcessSeparators.ts new file mode 100644 index 000000000..a57ad680e --- /dev/null +++ b/shared/editor/lib/filterExcessSeparators.ts @@ -0,0 +1,23 @@ +import { EmbedDescriptor, MenuItem } from "../types"; + +export default function filterExcessSeparators( + items: (MenuItem | EmbedDescriptor)[] +): (MenuItem | EmbedDescriptor)[] { + return items.reduce((acc, item, index) => { + // trim separators from start / end + if (item.name === "separator" && index === 0) return acc; + if (item.name === "separator" && index === items.length - 1) return acc; + + // trim double separators looking ahead / behind + const prev = items[index - 1]; + if (prev && prev.name === "separator" && item.name === "separator") + return acc; + + const next = items[index + 1]; + if (next && next.name === "separator" && item.name === "separator") + return acc; + + // otherwise, continue + return [...acc, item]; + }, []); +} diff --git a/shared/editor/lib/getHeadings.ts b/shared/editor/lib/getHeadings.ts new file mode 100644 index 000000000..96734b714 --- /dev/null +++ b/shared/editor/lib/getHeadings.ts @@ -0,0 +1,33 @@ +import { EditorView } from "prosemirror-view"; +import headingToSlug from "./headingToSlug"; + +export default function getHeadings(view: EditorView) { + const headings: { title: string; level: number; id: string }[] = []; + const previouslySeen = {}; + + view.state.doc.forEach((node) => { + if (node.type.name === "heading") { + // calculate the optimal id + const id = headingToSlug(node); + let name = id; + + // 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[id] > 0) { + name = headingToSlug(node, previouslySeen[id]); + } + + // record that we've seen this id for the next loop + previouslySeen[id] = + previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1; + + headings.push({ + title: node.textContent, + level: node.attrs.level, + id: name, + }); + } + }); + return headings; +} diff --git a/shared/editor/lib/getMarkAttrs.ts b/shared/editor/lib/getMarkAttrs.ts new file mode 100644 index 000000000..9d666c78d --- /dev/null +++ b/shared/editor/lib/getMarkAttrs.ts @@ -0,0 +1,26 @@ +import { Node as ProsemirrorNode, Mark } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; +import Node from "../nodes/Node"; + +export default function getMarkAttrs(state: EditorState, type: Node) { + const { from, to } = state.selection; + let marks: Mark[] = []; + + state.doc.nodesBetween(from, to, (node: ProsemirrorNode) => { + marks = [...marks, ...node.marks]; + + if (node.content) { + node.content.forEach((content) => { + marks = [...marks, ...content.marks]; + }); + } + }); + + const mark = marks.find((markItem) => markItem.type.name === type.name); + + if (mark) { + return mark.attrs; + } + + return {}; +} diff --git a/shared/editor/lib/headingToSlug.ts b/shared/editor/lib/headingToSlug.ts new file mode 100644 index 000000000..d467f7814 --- /dev/null +++ b/shared/editor/lib/headingToSlug.ts @@ -0,0 +1,28 @@ +import { escape } from "lodash"; +import { Node } from "prosemirror-model"; +import slugify from "slugify"; + +// Slugify, escape, and remove periods from headings so that they are +// compatible with both url hashes AND dom ID's (querySelector does not like +// ID's that begin with a number or a period, for example). +function safeSlugify(text: string) { + return `h-${escape( + slugify(text, { + remove: /[!"#$%&'\.()*+,\/:;<=>?@\[\]\\^_`{|}~]/g, + lower: true, + }) + )}`; +} + +// calculates a unique slug for this heading based on it's text and position +// in the document that is as stable as possible +export default function headingToSlug(node: Node, index = 0) { + const slugified = safeSlugify(node.textContent); + if (index === 0) return slugified; + return `${slugified}-${index}`; +} + +export function headingToPersistenceKey(node: Node, id?: string) { + const slug = headingToSlug(node); + return `rme-${id || window?.location.pathname}–${slug}`; +} diff --git a/shared/editor/lib/isMarkdown.test.ts b/shared/editor/lib/isMarkdown.test.ts new file mode 100644 index 000000000..1891581ba --- /dev/null +++ b/shared/editor/lib/isMarkdown.test.ts @@ -0,0 +1,71 @@ +import isMarkdown from "./isMarkdown"; + +test("returns false for an empty string", () => { + expect(isMarkdown("")).toBe(false); +}); + +test("returns false for plain text", () => { + expect(isMarkdown("plain text")).toBe(false); +}); + +test("returns true for bullet list", () => { + expect( + isMarkdown(`- item one +- item two + - nested item`) + ).toBe(true); +}); + +test("returns true for numbered list", () => { + expect( + isMarkdown(`1. item one +1. item two`) + ).toBe(true); + expect( + isMarkdown(`1. item one +2. item two`) + ).toBe(true); +}); + +test("returns true for code fence", () => { + expect( + isMarkdown(`\`\`\`javascript +this is code +\`\`\``) + ).toBe(true); +}); + +test("returns false for non-closed fence", () => { + expect( + isMarkdown(`\`\`\` +this is not code +`) + ).toBe(false); +}); + +test("returns true for heading", () => { + expect(isMarkdown(`# Heading 1`)).toBe(true); + expect(isMarkdown(`## Heading 2`)).toBe(true); + expect(isMarkdown(`### Heading 3`)).toBe(true); +}); + +test("returns false for hashtag", () => { + expect(isMarkdown(`Test #hashtag`)).toBe(false); + expect(isMarkdown(` #hashtag`)).toBe(false); +}); + +test("returns true for absolute link", () => { + expect(isMarkdown(`[title](http://www.google.com)`)).toBe(true); +}); + +test("returns true for relative link", () => { + expect(isMarkdown(`[title](/doc/mydoc-234tnes)`)).toBe(true); +}); + +test("returns true for relative image", () => { + expect(isMarkdown(`![alt](/coolimage.png)`)).toBe(true); +}); + +test("returns true for absolute image", () => { + expect(isMarkdown(`![alt](https://www.google.com/coolimage.png)`)).toBe(true); +}); diff --git a/shared/editor/lib/isMarkdown.ts b/shared/editor/lib/isMarkdown.ts new file mode 100644 index 000000000..994ebe33f --- /dev/null +++ b/shared/editor/lib/isMarkdown.ts @@ -0,0 +1,18 @@ +export default function isMarkdown(text: string): boolean { + // code-ish + const fences = text.match(/^```/gm); + if (fences && fences.length > 1) return true; + + // link-ish + if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true; + if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true; + + // heading-ish + if (text.match(/^#{1,6}\s+\S+/gm)) return true; + + // list-ish + const listItems = text.match(/^[\d-*].?\s\S+/gm); + if (listItems && listItems.length > 1) return true; + + return false; +} diff --git a/shared/editor/lib/isModKey.ts b/shared/editor/lib/isModKey.ts new file mode 100644 index 000000000..468a0af6b --- /dev/null +++ b/shared/editor/lib/isModKey.ts @@ -0,0 +1,6 @@ +const SSR = typeof window === "undefined"; +const isMac = !SSR && window.navigator.platform === "MacIntel"; + +export default function isModKey(event: KeyboardEvent | MouseEvent): boolean { + return isMac ? event.metaKey : event.ctrlKey; +} diff --git a/shared/editor/lib/isUrl.ts b/shared/editor/lib/isUrl.ts new file mode 100644 index 000000000..70ce0a029 --- /dev/null +++ b/shared/editor/lib/isUrl.ts @@ -0,0 +1,12 @@ +export default function isUrl(text: string) { + if (text.match(/\n/)) { + return false; + } + + try { + const url = new URL(text); + return url.hostname !== ""; + } catch (err) { + return false; + } +} diff --git a/shared/editor/lib/markInputRule.ts b/shared/editor/lib/markInputRule.ts new file mode 100644 index 000000000..4c67b978e --- /dev/null +++ b/shared/editor/lib/markInputRule.ts @@ -0,0 +1,65 @@ +import { InputRule } from "prosemirror-inputrules"; +import { MarkType, Mark } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; + +function getMarksBetween(start: number, end: number, state: EditorState) { + let marks: { start: number; end: number; mark: Mark }[] = []; + + state.doc.nodesBetween(start, end, (node, pos) => { + marks = [ + ...marks, + ...node.marks.map((mark) => ({ + start: pos, + end: pos + node.nodeSize, + mark, + })), + ]; + }); + + return marks; +} + +export default function ( + regexp: RegExp, + markType: MarkType, + getAttrs?: (match: string[]) => Record +): InputRule { + return new InputRule( + regexp, + (state: EditorState, match: string[], start: number, end: number) => { + const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs; + const { tr } = state; + const m = match.length - 1; + let markEnd = end; + let markStart = start; + + if (match[m]) { + const matchStart = start + match[0].indexOf(match[m - 1]); + const matchEnd = matchStart + match[m - 1].length - 1; + const textStart = matchStart + match[m - 1].lastIndexOf(match[m]); + const textEnd = textStart + match[m].length; + + const excludedMarks = getMarksBetween(start, end, state) + .filter((item) => item.mark.type.excludes(markType)) + .filter((item) => item.end > matchStart); + + if (excludedMarks.length) { + return null; + } + + if (textEnd < matchEnd) { + tr.delete(textEnd, matchEnd); + } + if (textStart > matchStart) { + tr.delete(matchStart, textStart); + } + markStart = matchStart; + markEnd = markStart + match[m].length; + } + + tr.addMark(markStart, markEnd, markType.create(attrs)); + tr.removeStoredMark(markType); + return tr; + } + ); +} diff --git a/shared/editor/lib/markdown/rules.ts b/shared/editor/lib/markdown/rules.ts new file mode 100644 index 000000000..ec1415abe --- /dev/null +++ b/shared/editor/lib/markdown/rules.ts @@ -0,0 +1,18 @@ +import markdownit, { PluginSimple } from "markdown-it"; + +export default function rules({ + rules = {}, + plugins = [], +}: { + rules?: Record; + plugins?: PluginSimple[]; +}) { + const markdownIt = markdownit("default", { + breaks: false, + html: false, + linkify: false, + ...rules, + }); + plugins.forEach((plugin) => markdownIt.use(plugin)); + return markdownIt; +} diff --git a/shared/editor/lib/markdown/serializer.ts b/shared/editor/lib/markdown/serializer.ts new file mode 100644 index 000000000..cd8732717 --- /dev/null +++ b/shared/editor/lib/markdown/serializer.ts @@ -0,0 +1,412 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +// https://raw.githubusercontent.com/ProseMirror/prosemirror-markdown/master/src/to_markdown.js +// forked for table support + +// ::- A specification for serializing a ProseMirror document as +// Markdown/CommonMark text. +export class MarkdownSerializer { + // :: (Object<(state: MarkdownSerializerState, node: Node, parent: Node, index: number)>, Object) + // Construct a serializer with the given configuration. The `nodes` + // object should map node names in a given schema to function that + // take a serializer state and such a node, and serialize the node. + // + // The `marks` object should hold objects with `open` and `close` + // properties, which hold the strings that should appear before and + // after a piece of text marked that way, either directly or as a + // function that takes a serializer state and a mark, and returns a + // string. `open` and `close` can also be functions, which will be + // called as + // + // (state: MarkdownSerializerState, mark: Mark, + // parent: Fragment, index: number) → string + // + // Where `parent` and `index` allow you to inspect the mark's + // context to see which nodes it applies to. + // + // Mark information objects can also have a `mixable` property + // which, when `true`, indicates that the order in which the mark's + // opening and closing syntax appears relative to other mixable + // marks can be varied. (For example, you can say `**a *b***` and + // `*a **b***`, but not `` `a *b*` ``.) + // + // To disable character escaping in a mark, you can give it an + // `escape` property of `false`. Such a mark has to have the highest + // precedence (must always be the innermost mark). + // + // The `expelEnclosingWhitespace` mark property causes the + // serializer to move enclosing whitespace from inside the marks to + // outside the marks. This is necessary for emphasis marks as + // CommonMark does not permit enclosing whitespace inside emphasis + // marks, see: http://spec.commonmark.org/0.26/#example-330 + constructor(nodes, marks) { + // :: Object<(MarkdownSerializerState, Node)> The node serializer + // functions for this serializer. + this.nodes = nodes; + // :: Object The mark serializer info. + this.marks = marks; + } + + // :: (Node, ?Object) → string + // Serialize the content of the given node to + // [CommonMark](http://commonmark.org/). + serialize(content, options?: { tightLists?: boolean }) { + const state = new MarkdownSerializerState(this.nodes, this.marks, options); + state.renderContent(content); + return state.out; + } +} + +// ::- This is an object used to track state and expose +// methods related to markdown serialization. Instances are passed to +// node and mark serialization methods (see `toMarkdown`). +export class MarkdownSerializerState { + inTable = false; + inTightList = false; + closed = false; + delim = ""; + + constructor(nodes, marks, options) { + this.nodes = nodes; + this.marks = marks; + this.delim = this.out = ""; + this.closed = false; + this.inTightList = false; + this.inTable = false; + // :: Object + // The options passed to the serializer. + // tightLists:: ?bool + // Whether to render lists in a tight style. This can be overridden + // on a node level by specifying a tight attribute on the node. + // Defaults to false. + this.options = options || {}; + if (typeof this.options.tightLists === "undefined") + this.options.tightLists = true; + } + + flushClose(size) { + if (this.closed) { + if (!this.atBlank()) this.out += "\n"; + if (size === null || size === undefined) size = 2; + if (size > 1) { + let delimMin = this.delim; + const trim = /\s+$/.exec(delimMin); + if (trim) + delimMin = delimMin.slice(0, delimMin.length - trim[0].length); + for (let i = 1; i < size; i++) this.out += delimMin + "\n"; + } + this.closed = false; + } + } + + // :: (string, ?string, Node, ()) + // Render a block, prefixing each line with `delim`, and the first + // line in `firstDelim`. `node` should be the node that is closed at + // the end of the block, and `f` is a function that renders the + // content of the block. + wrapBlock(delim, firstDelim, node, f) { + const old = this.delim; + this.write(firstDelim || delim); + this.delim += delim; + f(); + this.delim = old; + this.closeBlock(node); + } + + atBlank() { + return /(^|\n)$/.test(this.out); + } + + // :: () + // Ensure the current content ends with a newline. + ensureNewLine() { + if (!this.atBlank()) this.out += "\n"; + } + + // :: (?string) + // Prepare the state for writing output (closing closed paragraphs, + // adding delimiters, and so on), and then optionally add content + // (unescaped) to the output. + write(content) { + this.flushClose(); + if (this.delim && this.atBlank()) this.out += this.delim; + if (content) this.out += content; + } + + // :: (Node) + // Close the block for the given node. + closeBlock(node) { + this.closed = node; + } + + // :: (string, ?bool) + // Add the given text to the document. When escape is not `false`, + // it will be escaped. + text(text, escape) { + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const startOfLine = this.atBlank() || this.closed; + this.write(); + this.out += escape !== false ? this.esc(lines[i], startOfLine) : lines[i]; + if (i !== lines.length - 1) this.out += "\n"; + } + } + + // :: (Node) + // Render the given node as a block. + render(node, parent, index) { + if (typeof parent === "number") throw new Error("!"); + this.nodes[node.type.name](this, node, parent, index); + } + + // :: (Node) + // Render the contents of `parent` as block nodes. + renderContent(parent) { + parent.forEach((node, _, i) => this.render(node, parent, i)); + } + + // :: (Node) + // Render the contents of `parent` as inline content. + renderInline(parent) { + const active = []; + let trailing = ""; + const progress = (node, _, index) => { + let marks = node ? node.marks : []; + + // Remove marks from `hard_break` that are the last node inside + // that mark to prevent parser edge cases with new lines just + // before closing marks. + // (FIXME it'd be nice if we had a schema-agnostic way to + // identify nodes that serialize as hard breaks) + if (node && node.type.name === "hard_break") + marks = marks.filter((m) => { + if (index + 1 === parent.childCount) return false; + const next = parent.child(index + 1); + return ( + m.isInSet(next.marks) && (!next.isText || /\S/.test(next.text)) + ); + }); + + let leading = trailing; + trailing = ""; + // If whitespace has to be expelled from the node, adjust + // leading and trailing accordingly. + if ( + node && + node.isText && + marks.some((mark) => { + const info = this.marks[mark.type.name](); + return info && info.expelEnclosingWhitespace; + }) + ) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + const [_, lead, inner, trail] = /^(\s*)(.*?)(\s*)$/m.exec(node.text); + leading += lead; + trailing = trail; + if (lead || trail) { + node = inner ? node.withText(inner) : null; + if (!node) marks = active; + } + } + + const inner = marks.length && marks[marks.length - 1], + noEsc = inner && this.marks[inner.type.name]().escape === false; + const len = marks.length - (noEsc ? 1 : 0); + + // Try to reorder 'mixable' marks, such as em and strong, which + // in Markdown may be opened and closed in different order, so + // that order of the marks for the token matches the order in + // active. + outer: for (let i = 0; i < len; i++) { + const mark = marks[i]; + if (!this.marks[mark.type.name]().mixable) break; + for (let j = 0; j < active.length; j++) { + const other = active[j]; + if (!this.marks[other.type.name]().mixable) break; + if (mark.eq(other)) { + if (i > j) + marks = marks + .slice(0, j) + .concat(mark) + .concat(marks.slice(j, i)) + .concat(marks.slice(i + 1, len)); + else if (j > i) + marks = marks + .slice(0, i) + .concat(marks.slice(i + 1, j)) + .concat(mark) + .concat(marks.slice(j, len)); + continue outer; + } + } + } + + // Find the prefix of the mark set that didn't change + let keep = 0; + while ( + keep < Math.min(active.length, len) && + marks[keep].eq(active[keep]) + ) + ++keep; + + // Close the marks that need to be closed + while (keep < active.length) + this.text(this.markString(active.pop(), false, parent, index), false); + + // Output any previously expelled trailing whitespace outside the marks + if (leading) this.text(leading); + + // Open the marks that need to be opened + if (node) { + while (active.length < len) { + const add = marks[active.length]; + active.push(add); + this.text(this.markString(add, true, parent, index), false); + } + + // Render the node. Special case code marks, since their content + // may not be escaped. + if (noEsc && node.isText) + this.text( + this.markString(inner, true, parent, index) + + node.text + + this.markString(inner, false, parent, index + 1), + false + ); + else this.render(node, parent, index); + } + }; + parent.forEach(progress); + progress(null, null, parent.childCount); + } + + // :: (Node, string, (number) → string) + // Render a node's content as a list. `delim` should be the extra + // indentation added to all lines except the first in an item, + // `firstDelim` is a function going from an item index to a + // delimiter for the first line of the item. + renderList(node, delim, firstDelim) { + if (this.closed && this.closed.type === node.type) this.flushClose(3); + else if (this.inTightList) this.flushClose(1); + + const isTight = + typeof node.attrs.tight !== "undefined" + ? node.attrs.tight + : this.options.tightLists; + const prevTight = this.inTightList; + const prevList = this.inList; + this.inList = true; + this.inTightList = isTight; + node.forEach((child, _, i) => { + if (i && isTight) this.flushClose(1); + this.wrapBlock(delim, firstDelim(i), node, () => + this.render(child, node, i) + ); + }); + this.inList = prevList; + this.inTightList = prevTight; + } + + renderTable(node) { + this.flushClose(1); + + let headerBuffer = ""; + const prevTable = this.inTable; + this.inTable = true; + + // ensure there is an empty newline above all tables + this.out += "\n"; + + // rows + node.forEach((row, _, i) => { + // cols + row.forEach((cell, _, j) => { + this.out += j === 0 ? "| " : " | "; + + cell.forEach((para) => { + // just padding the output so that empty cells take up the same space + // as headings. + // TODO: Ideally we'd calc the longest cell length and use that + // to pad all the others. + if (para.textContent === "" && para.content.size === 0) { + this.out += " "; + } else { + this.closed = false; + this.render(para, row, j); + } + }); + + if (i === 0) { + if (cell.attrs.alignment === "center") { + headerBuffer += "|:---:"; + } else if (cell.attrs.alignment === "left") { + headerBuffer += "|:---"; + } else if (cell.attrs.alignment === "right") { + headerBuffer += "|---:"; + } else { + headerBuffer += "|----"; + } + } + }); + + this.out += " |\n"; + + if (headerBuffer) { + this.out += `${headerBuffer}|\n`; + headerBuffer = undefined; + } + }); + + this.inTable = prevTable; + } + + // :: (string, ?bool) → string + // Escape the given string so that it can safely appear in Markdown + // content. If `startOfLine` is true, also escape characters that + // has special meaning only at the start of the line. + esc(str = "", startOfLine) { + str = str.replace(/[`*\\~[\]]/g, "\\$&"); + if (startOfLine) { + str = str.replace(/^[:#\-*+]/, "\\$&").replace(/^(\d+)\./, "$1\\."); + } + + if (this.inTable) { + str = str.replace(/\|/gi, "\\$&"); + } + + return str; + } + + quote(str) { + const wrap = + str.indexOf('"') === -1 ? '""' : str.indexOf("'") === -1 ? "''" : "()"; + return wrap[0] + str + wrap[1]; + } + + // :: (string, number) → string + // Repeat the given string `n` times. + repeat(str, n) { + let out = ""; + for (let i = 0; i < n; i++) out += str; + return out; + } + + // : (Mark, bool, string?) → string + // Get the markdown string for a given opening or closing mark. + markString(mark, open, parent, index) { + const info = this.marks[mark.type.name](); + const value = open ? info.open : info.close; + return typeof value === "string" ? value : value(this, mark, parent, index); + } + + // :: (string) → { leading: ?string, trailing: ?string } + // Get leading and trailing whitespace from a string. Values of + // leading or trailing property of the return object will be undefined + // if there is no match. + getEnclosingWhitespace(text) { + return { + leading: (text.match(/^(\s+)/) || [])[0], + trailing: (text.match(/(\s+)$/) || [])[0], + }; + } +} diff --git a/shared/editor/lib/uploadPlaceholder.ts b/shared/editor/lib/uploadPlaceholder.ts new file mode 100644 index 000000000..21b88f7a5 --- /dev/null +++ b/shared/editor/lib/uploadPlaceholder.ts @@ -0,0 +1,74 @@ +import { EditorState, Plugin } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +// based on the example at: https://prosemirror.net/examples/upload/ +const uploadPlaceholder = new Plugin({ + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set: DecorationSet) { + // Adjust decoration positions to changes made by the transaction + set = set.map(tr.mapping, tr.doc); + + // See if the transaction adds or removes any placeholders + const action = tr.getMeta(this); + + if (action?.add) { + if (action.add.replaceExisting) { + const $pos = tr.doc.resolve(action.add.pos); + + if ($pos.nodeAfter?.type.name === "image") { + const deco = Decoration.node( + $pos.pos, + $pos.pos + $pos.nodeAfter.nodeSize, + { + class: "image-replacement-uploading", + }, + { + id: action.add.id, + } + ); + set = set.add(tr.doc, [deco]); + } + } else { + const element = document.createElement("div"); + element.className = "image placeholder"; + + const img = document.createElement("img"); + img.src = URL.createObjectURL(action.add.file); + + element.appendChild(img); + + const deco = Decoration.widget(action.add.pos, element, { + id: action.add.id, + }); + set = set.add(tr.doc, [deco]); + } + } + + if (action?.remove) { + set = set.remove( + set.find(undefined, undefined, (spec) => spec.id === action.remove.id) + ); + } + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, +}); + +export default uploadPlaceholder; + +export function findPlaceholder( + state: EditorState, + id: string +): [number, number] | null { + const decos: DecorationSet = uploadPlaceholder.getState(state); + const found = decos.find(undefined, undefined, (spec) => spec.id === id); + return found.length ? [found[0].from, found[0].to] : null; +} diff --git a/shared/editor/marks/Bold.ts b/shared/editor/marks/Bold.ts new file mode 100644 index 000000000..f4c94e756 --- /dev/null +++ b/shared/editor/marks/Bold.ts @@ -0,0 +1,42 @@ +import { toggleMark } from "prosemirror-commands"; +import { InputRule } from "prosemirror-inputrules"; +import { MarkSpec, MarkType } from "prosemirror-model"; +import markInputRule from "../lib/markInputRule"; +import Mark from "./Mark"; + +export default class Bold extends Mark { + get name() { + return "strong"; + } + + get schema(): MarkSpec { + return { + parseDOM: [{ tag: "b" }, { tag: "strong" }], + toDOM: () => ["strong"], + }; + } + + inputRules({ type }: { type: MarkType }): InputRule[] { + return [markInputRule(/(?:\*\*)([^*]+)(?:\*\*)$/, type)]; + } + + keys({ type }: { type: MarkType }) { + return { + "Mod-b": toggleMark(type), + "Mod-B": toggleMark(type), + }; + } + + toMarkdown() { + return { + open: "**", + close: "**", + mixable: true, + expelEnclosingWhitespace: true, + }; + } + + parseMarkdown() { + return { mark: "strong" }; + } +} diff --git a/shared/editor/marks/Code.ts b/shared/editor/marks/Code.ts new file mode 100644 index 000000000..9ac12087b --- /dev/null +++ b/shared/editor/marks/Code.ts @@ -0,0 +1,87 @@ +import { toggleMark } from "prosemirror-commands"; +import { + MarkSpec, + MarkType, + Node as ProsemirrorNode, + Mark as ProsemirrorMark, +} from "prosemirror-model"; +import moveLeft from "../commands/moveLeft"; +import moveRight from "../commands/moveRight"; +import markInputRule from "../lib/markInputRule"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import Mark from "./Mark"; + +function backticksFor(node: ProsemirrorNode, side: -1 | 1) { + const ticks = /`+/g; + let match: RegExpMatchArray | null; + let len = 0; + + if (node.isText) { + while ((match = ticks.exec(node.text || ""))) { + len = Math.max(len, match[0].length); + } + } + + let result = len > 0 && side > 0 ? " `" : "`"; + for (let i = 0; i < len; i++) { + result += "`"; + } + if (len > 0 && side < 0) { + result += " "; + } + return result; +} + +export default class Code extends Mark { + get name() { + return "code_inline"; + } + + get schema(): MarkSpec { + return { + excludes: "_", + parseDOM: [{ tag: "code", preserveWhitespace: true }], + toDOM: () => ["code", { spellCheck: "false" }], + }; + } + + inputRules({ type }: { type: MarkType }) { + return [markInputRule(/(?:^|[^`])(`([^`]+)`)$/, type)]; + } + + keys({ type }: { type: MarkType }) { + // Note: This key binding only works on non-Mac platforms + // https://github.com/ProseMirror/prosemirror/issues/515 + return { + "Mod`": toggleMark(type), + ArrowLeft: moveLeft(), + ArrowRight: moveRight(), + }; + } + + toMarkdown() { + return { + open( + _state: MarkdownSerializerState, + _mark: ProsemirrorMark, + parent: ProsemirrorNode, + index: number + ) { + return backticksFor(parent.child(index), -1); + }, + close( + _state: MarkdownSerializerState, + _mark: ProsemirrorMark, + parent: ProsemirrorNode, + index: number + ) { + return backticksFor(parent.child(index - 1), 1); + }, + escape: false, + }; + } + + parseMarkdown() { + return { mark: "code_inline" }; + } +} diff --git a/shared/editor/marks/Highlight.ts b/shared/editor/marks/Highlight.ts new file mode 100644 index 000000000..6aac7d23f --- /dev/null +++ b/shared/editor/marks/Highlight.ts @@ -0,0 +1,45 @@ +import { toggleMark } from "prosemirror-commands"; +import { MarkSpec, MarkType } from "prosemirror-model"; +import markInputRule from "../lib/markInputRule"; +import markRule from "../rules/mark"; +import Mark from "./Mark"; + +export default class Highlight extends Mark { + get name() { + return "highlight"; + } + + get schema(): MarkSpec { + return { + parseDOM: [{ tag: "mark" }], + toDOM: () => ["mark"], + }; + } + + inputRules({ type }: { type: MarkType }) { + return [markInputRule(/(?:==)([^=]+)(?:==)$/, type)]; + } + + keys({ type }: { type: MarkType }) { + return { + "Mod-Ctrl-h": toggleMark(type), + }; + } + + get rulePlugins() { + return [markRule({ delim: "==", mark: "highlight" })]; + } + + toMarkdown() { + return { + open: "==", + close: "==", + mixable: true, + expelEnclosingWhitespace: true, + }; + } + + parseMarkdown() { + return { mark: "highlight" }; + } +} diff --git a/shared/editor/marks/Italic.ts b/shared/editor/marks/Italic.ts new file mode 100644 index 000000000..b2191dc11 --- /dev/null +++ b/shared/editor/marks/Italic.ts @@ -0,0 +1,46 @@ +import { toggleMark } from "prosemirror-commands"; +import { InputRule } from "prosemirror-inputrules"; +import { MarkSpec, MarkType } from "prosemirror-model"; +import { Command } from "../lib/Extension"; +import markInputRule from "../lib/markInputRule"; +import Mark from "./Mark"; + +export default class Italic extends Mark { + get name() { + return "em"; + } + + get schema(): MarkSpec { + return { + parseDOM: [{ tag: "i" }, { tag: "em" }], + toDOM: () => ["em"], + }; + } + + inputRules({ type }: { type: MarkType }): InputRule[] { + return [ + markInputRule(/(?:^|[\s])(_([^_]+)_)$/, type), + markInputRule(/(?:^|[^*])(\*([^*]+)\*)$/, type), + ]; + } + + keys({ type }: { type: MarkType }): Record { + return { + "Mod-i": toggleMark(type), + "Mod-I": toggleMark(type), + }; + } + + toMarkdown() { + return { + open: "*", + close: "*", + mixable: true, + expelEnclosingWhitespace: true, + }; + } + + parseMarkdown() { + return { mark: "em" }; + } +} diff --git a/shared/editor/marks/Link.ts b/shared/editor/marks/Link.ts new file mode 100644 index 000000000..ae93c1887 --- /dev/null +++ b/shared/editor/marks/Link.ts @@ -0,0 +1,194 @@ +import Token from "markdown-it/lib/token"; +import { toggleMark } from "prosemirror-commands"; +import { InputRule } from "prosemirror-inputrules"; +import { MarkdownSerializerState } from "prosemirror-markdown"; +import { + MarkSpec, + MarkType, + Node, + Mark as ProsemirrorMark, +} from "prosemirror-model"; +import { Transaction, EditorState, Plugin } from "prosemirror-state"; +import Mark from "./Mark"; + +const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/; + +function isPlainURL( + link: ProsemirrorMark, + parent: Node, + index: number, + side: -1 | 1 +) { + if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) { + return false; + } + + const content = parent.child(index + (side < 0 ? -1 : 0)); + if ( + !content.isText || + content.text !== link.attrs.href || + content.marks[content.marks.length - 1] !== link + ) { + return false; + } + + if (index === (side < 0 ? 1 : parent.childCount - 1)) { + return true; + } + + const next = parent.child(index + (side < 0 ? -2 : 1)); + return !link.isInSet(next.marks); +} + +export default class Link extends Mark { + get name() { + return "link"; + } + + get schema(): MarkSpec { + return { + attrs: { + href: { + default: "", + }, + }, + inclusive: false, + parseDOM: [ + { + tag: "a[href]", + getAttrs: (dom: HTMLElement) => ({ + href: dom.getAttribute("href"), + }), + }, + ], + toDOM: (node) => [ + "a", + { + ...node.attrs, + rel: "noopener noreferrer nofollow", + }, + 0, + ], + }; + } + + inputRules({ type }: { type: MarkType }) { + return [ + new InputRule(LINK_INPUT_REGEX, (state, match, start, end) => { + const [okay, alt, href] = match; + const { tr } = state; + + if (okay) { + tr.replaceWith(start, end, this.editor.schema.text(alt)).addMark( + start, + start + alt.length, + type.create({ href }) + ); + } + + return tr; + }), + ]; + } + + commands({ type }: { type: MarkType }) { + return ({ href } = { href: "" }) => toggleMark(type, { href }); + } + + keys({ type }: { type: MarkType }) { + return { + "Mod-k": (state: EditorState, dispatch: (tr: Transaction) => void) => { + if (state.selection.empty) { + this.options.onKeyboardShortcut(); + return true; + } + + return toggleMark(type, { href: "" })(state, dispatch); + }, + }; + } + + get plugins() { + return [ + new Plugin({ + props: { + handleDOMEvents: { + mouseover: (_view, event: MouseEvent) => { + if ( + event.target instanceof HTMLAnchorElement && + !event.target.className.includes("ProseMirror-widget") + ) { + if (this.options.onHoverLink) { + return this.options.onHoverLink(event); + } + } + return false; + }, + click: (_view, event: MouseEvent) => { + if (event.target instanceof HTMLAnchorElement) { + const href = + event.target.href || + (event.target.parentNode instanceof HTMLAnchorElement + ? event.target.parentNode.href + : ""); + + const isHashtag = href.startsWith("#"); + if (isHashtag && this.options.onClickHashtag) { + event.stopPropagation(); + event.preventDefault(); + this.options.onClickHashtag(href, event); + return true; + } + + if (this.options.onClickLink) { + event.stopPropagation(); + event.preventDefault(); + this.options.onClickLink(href, event); + return true; + } + } + + return false; + }, + }, + }, + }), + ]; + } + + toMarkdown() { + return { + open( + _state: MarkdownSerializerState, + mark: ProsemirrorMark, + parent: Node, + index: number + ) { + return isPlainURL(mark, parent, index, 1) ? "<" : "["; + }, + close( + state: MarkdownSerializerState, + mark: ProsemirrorMark, + parent: Node, + index: number + ) { + return isPlainURL(mark, parent, index, -1) + ? ">" + : "](" + + state.esc(mark.attrs.href) + + (mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + + ")"; + }, + }; + } + + parseMarkdown() { + return { + mark: "link", + getAttrs: (tok: Token) => ({ + href: tok.attrGet("href"), + title: tok.attrGet("title") || null, + }), + }; + } +} diff --git a/shared/editor/marks/Mark.ts b/shared/editor/marks/Mark.ts new file mode 100644 index 000000000..970fe6f97 --- /dev/null +++ b/shared/editor/marks/Mark.ts @@ -0,0 +1,45 @@ +import { toggleMark } from "prosemirror-commands"; +import { InputRule } from "prosemirror-inputrules"; +import { TokenConfig } from "prosemirror-markdown"; +import { + MarkSpec, + MarkType, + Node as ProsemirrorNode, + Schema, +} from "prosemirror-model"; +import Extension, { Command, CommandFactory } from "../lib/Extension"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; + +export default abstract class Mark extends Extension { + get type() { + return "mark"; + } + + get schema(): MarkSpec { + return {}; + } + + get markdownToken(): string { + return ""; + } + + keys(_options: { type: MarkType; schema: Schema }): Record { + return {}; + } + + inputRules(_options: { type: MarkType; schema: Schema }): InputRule[] { + return []; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + console.error("toMarkdown not implemented", state, node); + } + + parseMarkdown(): TokenConfig | void { + return undefined; + } + + commands({ type }: { type: MarkType; schema: Schema }): CommandFactory { + return () => toggleMark(type); + } +} diff --git a/shared/editor/marks/Placeholder.ts b/shared/editor/marks/Placeholder.ts new file mode 100644 index 000000000..14092601c --- /dev/null +++ b/shared/editor/marks/Placeholder.ts @@ -0,0 +1,163 @@ +import { MarkSpec } from "prosemirror-model"; +import { Plugin, TextSelection } from "prosemirror-state"; +import getMarkRange from "../queries/getMarkRange"; +import markRule from "../rules/mark"; +import Mark from "./Mark"; + +export default class Placeholder extends Mark { + get name() { + return "placeholder"; + } + + get schema(): MarkSpec { + return { + parseDOM: [{ tag: "span.template-placeholder" }], + toDOM: () => ["span", { class: "template-placeholder" }], + }; + } + + get rulePlugins() { + return [markRule({ delim: "!!", mark: "placeholder" })]; + } + + toMarkdown() { + return { + open: "!!", + close: "!!", + mixable: true, + expelEnclosingWhitespace: true, + }; + } + + parseMarkdown() { + return { mark: "placeholder" }; + } + + get plugins() { + return [ + new Plugin({ + props: { + handleTextInput: (view, from, to, text) => { + if (this.editor.props.template) { + return false; + } + + const { state, dispatch } = view; + const $from = state.doc.resolve(from); + + const range = getMarkRange($from, state.schema.marks.placeholder); + if (!range) return false; + + const selectionStart = Math.min(from, range.from); + const selectionEnd = Math.max(to, range.to); + + dispatch( + state.tr + .removeMark( + range.from, + range.to, + state.schema.marks.placeholder + ) + .insertText(text, selectionStart, selectionEnd) + ); + + const $to = view.state.doc.resolve(selectionStart + text.length); + dispatch(view.state.tr.setSelection(TextSelection.near($to))); + + return true; + }, + handleKeyDown: (view, event: KeyboardEvent) => { + if (!view.props.editable || !view.props.editable(view.state)) { + return false; + } + if (this.editor.props.template) { + return false; + } + if ( + event.key !== "ArrowLeft" && + event.key !== "ArrowRight" && + event.key !== "Backspace" + ) { + return false; + } + + const { state, dispatch } = view; + + if (event.key === "Backspace") { + const range = getMarkRange( + state.doc.resolve(Math.max(0, state.selection.from - 1)), + state.schema.marks.placeholder + ); + if (!range) return false; + + dispatch( + state.tr + .removeMark( + range.from, + range.to, + state.schema.marks.placeholder + ) + .insertText("", range.from, range.to) + ); + return true; + } + + if (event.key === "ArrowLeft") { + const range = getMarkRange( + state.doc.resolve(Math.max(0, state.selection.from - 1)), + state.schema.marks.placeholder + ); + if (!range) return false; + + const startOfMark = state.doc.resolve(range.from); + dispatch(state.tr.setSelection(TextSelection.near(startOfMark))); + return true; + } + + if (event.key === "ArrowRight") { + const range = getMarkRange( + state.selection.$from, + state.schema.marks.placeholder + ); + if (!range) return false; + + const endOfMark = state.doc.resolve(range.to); + dispatch(state.tr.setSelection(TextSelection.near(endOfMark))); + return true; + } + + return false; + }, + handleClick: (view, pos, event: MouseEvent) => { + if (!view.props.editable || !view.props.editable(view.state)) { + return false; + } + if (this.editor.props.template) { + return false; + } + + if ( + event.target instanceof HTMLSpanElement && + event.target.className.includes("template-placeholder") + ) { + const { state, dispatch } = view; + const range = getMarkRange( + state.selection.$from, + state.schema.marks.placeholder + ); + if (!range) return false; + + event.stopPropagation(); + event.preventDefault(); + const startOfMark = state.doc.resolve(range.from); + dispatch(state.tr.setSelection(TextSelection.near(startOfMark))); + + return true; + } + return false; + }, + }, + }), + ]; + } +} diff --git a/shared/editor/marks/Strikethrough.ts b/shared/editor/marks/Strikethrough.ts new file mode 100644 index 000000000..a76746ea0 --- /dev/null +++ b/shared/editor/marks/Strikethrough.ts @@ -0,0 +1,54 @@ +import { toggleMark } from "prosemirror-commands"; +import { MarkSpec, MarkType } from "prosemirror-model"; +import markInputRule from "../lib/markInputRule"; +import Mark from "./Mark"; + +export default class Strikethrough extends Mark { + get name() { + return "strikethrough"; + } + + get schema(): MarkSpec { + return { + parseDOM: [ + { + tag: "s", + }, + { + tag: "del", + }, + { + tag: "strike", + }, + ], + toDOM: () => ["del", 0], + }; + } + + keys({ type }: { type: MarkType }) { + return { + "Mod-d": toggleMark(type), + }; + } + + inputRules({ type }: { type: MarkType }) { + return [markInputRule(/~([^~]+)~$/, type)]; + } + + toMarkdown() { + return { + open: "~~", + close: "~~", + mixable: true, + expelEnclosingWhitespace: true, + }; + } + + get markdownToken() { + return "s"; + } + + parseMarkdown() { + return { mark: "strikethrough" }; + } +} diff --git a/shared/editor/marks/Underline.ts b/shared/editor/marks/Underline.ts new file mode 100644 index 000000000..876bdaed5 --- /dev/null +++ b/shared/editor/marks/Underline.ts @@ -0,0 +1,51 @@ +import { toggleMark } from "prosemirror-commands"; +import { MarkSpec, MarkType } from "prosemirror-model"; +import markInputRule from "../lib/markInputRule"; +import underlinesRule from "../rules/underlines"; +import Mark from "./Mark"; + +export default class Underline extends Mark { + get name() { + return "underline"; + } + + get schema(): MarkSpec { + return { + parseDOM: [ + { tag: "u" }, + { + style: "text-decoration", + getAttrs: (value) => (value === "underline" ? {} : undefined), + }, + ], + toDOM: () => ["u", 0], + }; + } + + get rulePlugins() { + return [underlinesRule]; + } + + inputRules({ type }: { type: MarkType }) { + return [markInputRule(/(?:__)([^_]+)(?:__)$/, type)]; + } + + keys({ type }: { type: MarkType }) { + return { + "Mod-u": toggleMark(type), + }; + } + + toMarkdown() { + return { + open: "__", + close: "__", + mixable: true, + expelEnclosingWhitespace: true, + }; + } + + parseMarkdown() { + return { mark: "underline" }; + } +} diff --git a/shared/editor/nodes/Blockquote.ts b/shared/editor/nodes/Blockquote.ts new file mode 100644 index 000000000..7f560df86 --- /dev/null +++ b/shared/editor/nodes/Blockquote.ts @@ -0,0 +1,58 @@ +import { wrappingInputRule } from "prosemirror-inputrules"; +import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; +import { EditorState, Transaction } from "prosemirror-state"; +import toggleWrap from "../commands/toggleWrap"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import isNodeActive from "../queries/isNodeActive"; +import Node from "./Node"; + +export default class Blockquote extends Node { + get name() { + return "blockquote"; + } + + get schema(): NodeSpec { + return { + content: "block+", + group: "block", + defining: true, + parseDOM: [{ tag: "blockquote" }], + toDOM: () => ["blockquote", 0], + }; + } + + inputRules({ type }: { type: NodeType }) { + return [wrappingInputRule(/^\s*>\s$/, type)]; + } + + commands({ type }: { type: NodeType }) { + return () => toggleWrap(type); + } + + keys({ type }: { type: NodeType }) { + return { + "Ctrl->": toggleWrap(type), + "Mod-]": toggleWrap(type), + "Shift-Enter": ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (!isNodeActive(type)(state)) { + return false; + } + + const { tr, selection } = state; + dispatch(tr.split(selection.to)); + return true; + }, + }; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.wrapBlock("> ", undefined, node, () => state.renderContent(node)); + } + + parseMarkdown() { + return { block: "blockquote" }; + } +} diff --git a/shared/editor/nodes/BulletList.ts b/shared/editor/nodes/BulletList.ts new file mode 100644 index 000000000..b45344ea2 --- /dev/null +++ b/shared/editor/nodes/BulletList.ts @@ -0,0 +1,47 @@ +import { wrappingInputRule } from "prosemirror-inputrules"; +import { + Schema, + NodeType, + NodeSpec, + Node as ProsemirrorModel, +} from "prosemirror-model"; +import toggleList from "../commands/toggleList"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import Node from "./Node"; + +export default class BulletList extends Node { + get name() { + return "bullet_list"; + } + + get schema(): NodeSpec { + return { + content: "list_item+", + group: "block", + parseDOM: [{ tag: "ul" }], + toDOM: () => ["ul", 0], + }; + } + + commands({ type, schema }: { type: NodeType; schema: Schema }) { + return () => toggleList(type, schema.nodes.list_item); + } + + keys({ type, schema }: { type: NodeType; schema: Schema }) { + return { + "Shift-Ctrl-8": toggleList(type, schema.nodes.list_item), + }; + } + + inputRules({ type }: { type: NodeType }) { + return [wrappingInputRule(/^\s*([-+*])\s$/, type)]; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorModel) { + state.renderList(node, " ", () => (node.attrs.bullet || "*") + " "); + } + + parseMarkdown() { + return { block: "bullet_list" }; + } +} diff --git a/shared/editor/nodes/CheckboxItem.ts b/shared/editor/nodes/CheckboxItem.ts new file mode 100644 index 000000000..8724b768b --- /dev/null +++ b/shared/editor/nodes/CheckboxItem.ts @@ -0,0 +1,107 @@ +import Token from "markdown-it/lib/token"; +import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; +import { + splitListItem, + sinkListItem, + liftListItem, +} from "prosemirror-schema-list"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import checkboxRule from "../rules/checkboxes"; +import Node from "./Node"; + +export default class CheckboxItem extends Node { + get name() { + return "checkbox_item"; + } + + get schema(): NodeSpec { + return { + attrs: { + checked: { + default: false, + }, + }, + content: "paragraph block*", + defining: true, + draggable: true, + parseDOM: [ + { + tag: `li[data-type="${this.name}"]`, + getAttrs: (dom: HTMLLIElement) => ({ + checked: dom.className.includes("checked"), + }), + }, + ], + toDOM: (node) => { + const input = document.createElement("span"); + input.tabIndex = -1; + input.className = "checkbox"; + input.ariaChecked = node.attrs.checked.toString(); + input.setAttribute("role", "checkbox"); + input.addEventListener("click", this.handleClick); + + return [ + "li", + { + "data-type": this.name, + class: node.attrs.checked ? "checked" : undefined, + }, + [ + "span", + { + contentEditable: "false", + }, + input, + ], + ["div", 0], + ]; + }, + }; + } + + get rulePlugins() { + return [checkboxRule]; + } + + handleClick = (event: Event) => { + if (!(event.target instanceof HTMLSpanElement)) { + return; + } + + const { view } = this.editor; + const { tr } = view.state; + const { top, left } = event.target.getBoundingClientRect(); + const result = view.posAtCoords({ top, left }); + + if (result) { + const transaction = tr.setNodeMarkup(result.inside, undefined, { + checked: event.target.ariaChecked !== "true", + }); + view.dispatch(transaction); + } + }; + + keys({ type }: { type: NodeType }) { + return { + Enter: splitListItem(type), + Tab: sinkListItem(type), + "Shift-Tab": liftListItem(type), + "Mod-]": sinkListItem(type), + "Mod-[": liftListItem(type), + }; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.write(node.attrs.checked ? "[x] " : "[ ] "); + state.renderContent(node); + } + + parseMarkdown() { + return { + block: "checkbox_item", + getAttrs: (tok: Token) => ({ + checked: tok.attrGet("checked") ? true : undefined, + }), + }; + } +} diff --git a/shared/editor/nodes/CheckboxList.ts b/shared/editor/nodes/CheckboxList.ts new file mode 100644 index 000000000..8d06177c0 --- /dev/null +++ b/shared/editor/nodes/CheckboxList.ts @@ -0,0 +1,51 @@ +import { wrappingInputRule } from "prosemirror-inputrules"; +import { + NodeSpec, + NodeType, + Schema, + Node as ProsemirrorNode, +} from "prosemirror-model"; +import toggleList from "../commands/toggleList"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import Node from "./Node"; + +export default class CheckboxList extends Node { + get name() { + return "checkbox_list"; + } + + get schema(): NodeSpec { + return { + group: "block", + content: "checkbox_item+", + toDOM: () => ["ul", { class: this.name }, 0], + parseDOM: [ + { + tag: `[class="${this.name}"]`, + }, + ], + }; + } + + keys({ type, schema }: { type: NodeType; schema: Schema }) { + return { + "Shift-Ctrl-7": toggleList(type, schema.nodes.checkbox_item), + }; + } + + commands({ type, schema }: { type: NodeType; schema: Schema }) { + return () => toggleList(type, schema.nodes.checkbox_item); + } + + inputRules({ type }: { type: NodeType }) { + return [wrappingInputRule(/^-?\s*(\[ \])\s$/i, type)]; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.renderList(node, " ", () => "- "); + } + + parseMarkdown() { + return { block: "checkbox_list" }; + } +} diff --git a/shared/editor/nodes/CodeBlock.ts b/shared/editor/nodes/CodeBlock.ts new file mode 100644 index 000000000..02964825d --- /dev/null +++ b/shared/editor/nodes/CodeBlock.ts @@ -0,0 +1,11 @@ +import CodeFence from "./CodeFence"; + +export default class CodeBlock extends CodeFence { + get name() { + return "code_block"; + } + + get markdownToken() { + return "code_block"; + } +} diff --git a/shared/editor/nodes/CodeFence.ts b/shared/editor/nodes/CodeFence.ts new file mode 100644 index 000000000..cc5d59682 --- /dev/null +++ b/shared/editor/nodes/CodeFence.ts @@ -0,0 +1,251 @@ +import copy from "copy-to-clipboard"; +import Token from "markdown-it/lib/token"; +import { textblockTypeInputRule } from "prosemirror-inputrules"; +import { + NodeSpec, + NodeType, + Schema, + Node as ProsemirrorNode, +} from "prosemirror-model"; +import { + EditorState, + Selection, + TextSelection, + Transaction, +} from "prosemirror-state"; +import refractor from "refractor/core"; +import bash from "refractor/lang/bash"; +import clike from "refractor/lang/clike"; +import csharp from "refractor/lang/csharp"; +import css from "refractor/lang/css"; +import go from "refractor/lang/go"; +import java from "refractor/lang/java"; +import javascript from "refractor/lang/javascript"; +import json from "refractor/lang/json"; +import markup from "refractor/lang/markup"; +import objectivec from "refractor/lang/objectivec"; +import perl from "refractor/lang/perl"; +import php from "refractor/lang/php"; +import powershell from "refractor/lang/powershell"; +import python from "refractor/lang/python"; +import ruby from "refractor/lang/ruby"; +import rust from "refractor/lang/rust"; +import sql from "refractor/lang/sql"; +import typescript from "refractor/lang/typescript"; +import yaml from "refractor/lang/yaml"; + +import toggleBlockType from "../commands/toggleBlockType"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import Prism, { LANGUAGES } from "../plugins/Prism"; +import isInCode from "../queries/isInCode"; +import { ToastType } from "../types"; +import Node from "./Node"; + +const PERSISTENCE_KEY = "rme-code-language"; +const DEFAULT_LANGUAGE = "javascript"; + +[ + bash, + css, + clike, + csharp, + go, + java, + javascript, + json, + markup, + objectivec, + perl, + php, + python, + powershell, + ruby, + rust, + sql, + typescript, + yaml, +].forEach(refractor.register); + +export default class CodeFence extends Node { + get languageOptions() { + return Object.entries(LANGUAGES); + } + + get name() { + return "code_fence"; + } + + get schema(): NodeSpec { + return { + attrs: { + language: { + default: DEFAULT_LANGUAGE, + }, + }, + content: "text*", + marks: "", + group: "block", + code: true, + defining: true, + draggable: false, + parseDOM: [ + { tag: "pre", preserveWhitespace: "full" }, + { + tag: ".code-block", + preserveWhitespace: "full", + contentElement: "code", + getAttrs: (dom: HTMLDivElement) => { + return { + language: dom.dataset.language, + }; + }, + }, + ], + toDOM: (node) => { + const button = document.createElement("button"); + button.innerText = "Copy"; + button.type = "button"; + button.addEventListener("click", this.handleCopyToClipboard); + + const select = document.createElement("select"); + select.addEventListener("change", this.handleLanguageChange); + + this.languageOptions.forEach(([key, label]) => { + const option = document.createElement("option"); + const value = key === "none" ? "" : key; + option.value = value; + option.innerText = label; + option.selected = node.attrs.language === value; + select.appendChild(option); + }); + + return [ + "div", + { class: "code-block", "data-language": node.attrs.language }, + ["div", { contentEditable: "false" }, select, button], + ["pre", ["code", { spellCheck: "false" }, 0]], + ]; + }, + }; + } + + commands({ type, schema }: { type: NodeType; schema: Schema }) { + return (attrs: Record) => + toggleBlockType(type, schema.nodes.paragraph, { + language: localStorage?.getItem(PERSISTENCE_KEY) || DEFAULT_LANGUAGE, + ...attrs, + }); + } + + keys({ type, schema }: { type: NodeType; schema: Schema }) { + return { + "Shift-Ctrl-\\": toggleBlockType(type, schema.nodes.paragraph), + "Shift-Enter": ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (!isInCode(state)) return false; + const { + tr, + selection, + }: { tr: Transaction; selection: TextSelection } = state; + const text = selection?.$anchor?.nodeBefore?.text; + + let newText = "\n"; + + if (text) { + const splitByNewLine = text.split("\n"); + const numOfSpaces = splitByNewLine[splitByNewLine.length - 1].search( + /\S|$/ + ); + newText += " ".repeat(numOfSpaces); + } + + dispatch(tr.insertText(newText, selection.from, selection.to)); + return true; + }, + Tab: (state: EditorState, dispatch: (tr: Transaction) => void) => { + if (!isInCode(state)) return false; + + const { tr, selection } = state; + dispatch(tr.insertText(" ", selection.from, selection.to)); + return true; + }, + }; + } + + handleCopyToClipboard = (event: MouseEvent) => { + const { view } = this.editor; + const element = event.target; + if (!(element instanceof HTMLButtonElement)) { + return; + } + const { top, left } = element.getBoundingClientRect(); + const result = view.posAtCoords({ top, left }); + + if (result) { + const node = view.state.doc.nodeAt(result.pos); + if (node) { + copy(node.textContent); + if (this.options.onShowToast) { + this.options.onShowToast( + this.options.dictionary.codeCopied, + ToastType.Info + ); + } + } + } + }; + + handleLanguageChange = (event: InputEvent) => { + const { view } = this.editor; + const { tr } = view.state; + const element = event.currentTarget; + if (!(element instanceof HTMLSelectElement)) { + return; + } + + const { top, left } = element.getBoundingClientRect(); + const result = view.posAtCoords({ top, left }); + + if (result) { + const language = element.value; + + const transaction = tr + .setSelection(Selection.near(view.state.doc.resolve(result.inside))) + .setNodeMarkup(result.inside, undefined, { + language, + }); + view.dispatch(transaction); + + localStorage?.setItem(PERSISTENCE_KEY, language); + } + }; + + get plugins() { + return [Prism({ name: this.name })]; + } + + inputRules({ type }: { type: NodeType }) { + return [textblockTypeInputRule(/^```$/, type)]; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.write("```" + (node.attrs.language || "") + "\n"); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write("```"); + state.closeBlock(node); + } + + get markdownToken() { + return "fence"; + } + + parseMarkdown() { + return { + block: "code_block", + getAttrs: (tok: Token) => ({ language: tok.info }), + }; + } +} diff --git a/shared/editor/nodes/Doc.ts b/shared/editor/nodes/Doc.ts new file mode 100644 index 000000000..88e284a50 --- /dev/null +++ b/shared/editor/nodes/Doc.ts @@ -0,0 +1,14 @@ +import { NodeSpec } from "prosemirror-model"; +import Node from "./Node"; + +export default class Doc extends Node { + get name() { + return "doc"; + } + + get schema(): NodeSpec { + return { + content: "block+", + }; + } +} diff --git a/shared/editor/nodes/Embed.tsx b/shared/editor/nodes/Embed.tsx new file mode 100644 index 000000000..ca98c7ac9 --- /dev/null +++ b/shared/editor/nodes/Embed.tsx @@ -0,0 +1,126 @@ +import Token from "markdown-it/lib/token"; +import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model"; +import { EditorState, Transaction } from "prosemirror-state"; +import * as React from "react"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import embedsRule from "../rules/embeds"; +import { ComponentProps } from "../types"; +import Node from "./Node"; + +const cache = {}; + +export default class Embed extends Node { + get name() { + return "embed"; + } + + get schema(): NodeSpec { + return { + content: "inline*", + group: "block", + atom: true, + attrs: { + href: {}, + }, + parseDOM: [ + { + tag: "iframe[class=embed]", + getAttrs: (dom: HTMLIFrameElement) => { + const { embeds } = this.editor.props; + const href = dom.getAttribute("src") || ""; + + if (embeds) { + for (const embed of embeds) { + const matches = embed.matcher(href); + if (matches) { + return { + href, + }; + } + } + } + + return {}; + }, + }, + ], + toDOM: (node) => [ + "iframe", + { class: "embed", src: node.attrs.href, contentEditable: "false" }, + 0, + ], + }; + } + + get rulePlugins() { + return [embedsRule(this.options.embeds)]; + } + + component({ isEditable, isSelected, theme, node }: ComponentProps) { + const { embeds } = this.editor.props; + + // matches are cached in module state to avoid re running loops and regex + // here. Unfortuantely this function is not compatible with React.memo or + // we would use that instead. + const hit = cache[node.attrs.href]; + let Component = hit ? hit.Component : undefined; + let matches = hit ? hit.matches : undefined; + + if (!Component) { + for (const embed of embeds) { + const m = embed.matcher(node.attrs.href); + if (m) { + Component = embed.component; + matches = m; + cache[node.attrs.href] = { Component, matches }; + } + } + } + + if (!Component) { + return null; + } + + return ( + + ); + } + + commands({ type }: { type: NodeType }) { + return (attrs: Record) => ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + dispatch( + state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView() + ); + return true; + }; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.ensureNewLine(); + state.write( + "[" + + state.esc(node.attrs.href, false) + + "](" + + state.esc(node.attrs.href, false) + + ")" + ); + state.write("\n\n"); + } + + parseMarkdown() { + return { + node: "embed", + getAttrs: (token: Token) => ({ + href: token.attrGet("href"), + }), + }; + } +} diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx new file mode 100644 index 000000000..cce3bca07 --- /dev/null +++ b/shared/editor/nodes/Emoji.tsx @@ -0,0 +1,120 @@ +import nameToEmoji from "gemoji/name-to-emoji.json"; +import Token from "markdown-it/lib/token"; +import { InputRule } from "prosemirror-inputrules"; +import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; +import { EditorState, TextSelection, Transaction } from "prosemirror-state"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import emojiRule from "../rules/emoji"; +import Node from "./Node"; + +export default class Emoji extends Node { + get name() { + return "emoji"; + } + + get schema(): NodeSpec { + return { + attrs: { + style: { + default: "", + }, + "data-name": { + default: undefined, + }, + }, + inline: true, + content: "text*", + marks: "", + group: "inline", + selectable: false, + parseDOM: [ + { + tag: "span.emoji", + preserveWhitespace: "full", + getAttrs: (dom: HTMLDivElement) => ({ + "data-name": dom.dataset.name, + }), + }, + ], + toDOM: (node) => { + if (nameToEmoji[node.attrs["data-name"]]) { + const text = document.createTextNode( + nameToEmoji[node.attrs["data-name"]] + ); + return [ + "span", + { + class: `emoji ${node.attrs["data-name"]}`, + "data-name": node.attrs["data-name"], + }, + text, + ]; + } + const text = document.createTextNode(`:${node.attrs["data-name"]}:`); + return ["span", { class: "emoji" }, text]; + }, + }; + } + + get rulePlugins() { + return [emojiRule]; + } + + commands({ type }: { type: NodeType }) { + return (attrs: Record) => ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + const { selection } = state; + const position = + selection instanceof TextSelection + ? selection.$cursor?.pos + : selection.$to.pos; + if (position === undefined) { + return false; + } + + const node = type.create(attrs); + const transaction = state.tr.insert(position, node); + dispatch(transaction); + return true; + }; + } + + inputRules({ type }: { type: NodeType }): InputRule[] { + return [ + new InputRule(/^:([a-zA-Z0-9_+-]+):$/, (state, match, start, end) => { + const [okay, markup] = match; + const { tr } = state; + if (okay) { + tr.replaceWith( + start - 1, + end, + type.create({ + "data-name": markup, + markup, + }) + ); + } + + return tr; + }), + ]; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + const name = node.attrs["data-name"]; + if (name) { + state.write(`:${name}:`); + } + } + + parseMarkdown() { + return { + node: "emoji", + getAttrs: (tok: Token) => { + return { "data-name": tok.markup.trim() }; + }, + }; + } +} diff --git a/shared/editor/nodes/HardBreak.ts b/shared/editor/nodes/HardBreak.ts new file mode 100644 index 000000000..9389e2327 --- /dev/null +++ b/shared/editor/nodes/HardBreak.ts @@ -0,0 +1,56 @@ +import { NodeSpec, NodeType } from "prosemirror-model"; +import { EditorState, Transaction } from "prosemirror-state"; +import { isInTable } from "prosemirror-tables"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import breakRule from "../rules/breaks"; +import Node from "./Node"; + +export default class HardBreak extends Node { + get name() { + return "br"; + } + + get schema(): NodeSpec { + return { + inline: true, + group: "inline", + selectable: false, + parseDOM: [{ tag: "br" }], + toDOM() { + return ["br"]; + }, + }; + } + + get rulePlugins() { + return [breakRule]; + } + + commands({ type }: { type: NodeType }) { + return () => (state: EditorState, dispatch: (tr: Transaction) => void) => { + dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); + return true; + }; + } + + keys({ type }: { type: NodeType }) { + return { + "Shift-Enter": ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (!isInTable(state)) return false; + dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); + return true; + }, + }; + } + + toMarkdown(state: MarkdownSerializerState) { + state.write(" \\n "); + } + + parseMarkdown() { + return { node: "br" }; + } +} diff --git a/shared/editor/nodes/Heading.ts b/shared/editor/nodes/Heading.ts new file mode 100644 index 000000000..8fbdf2eee --- /dev/null +++ b/shared/editor/nodes/Heading.ts @@ -0,0 +1,280 @@ +import copy from "copy-to-clipboard"; +import { textblockTypeInputRule } from "prosemirror-inputrules"; +import { + Node as ProsemirrorNode, + NodeSpec, + NodeType, + Schema, +} from "prosemirror-model"; +import { Plugin, Selection } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import backspaceToParagraph from "../commands/backspaceToParagraph"; +import splitHeading from "../commands/splitHeading"; +import toggleBlockType from "../commands/toggleBlockType"; +import { Command } from "../lib/Extension"; +import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import { ToastType } from "../types"; +import Node from "./Node"; + +export default class Heading extends Node { + className = "heading-name"; + + get name() { + return "heading"; + } + + get defaultOptions() { + return { + levels: [1, 2, 3, 4], + collapsed: undefined, + }; + } + + get schema(): NodeSpec { + return { + attrs: { + level: { + default: 1, + }, + collapsed: { + default: undefined, + }, + }, + content: "inline*", + group: "block", + defining: true, + draggable: false, + parseDOM: this.options.levels.map((level: number) => ({ + tag: `h${level}`, + attrs: { level }, + contentElement: ".heading-content", + })), + toDOM: (node) => { + const anchor = document.createElement("button"); + anchor.innerText = "#"; + anchor.type = "button"; + anchor.className = "heading-anchor"; + anchor.addEventListener("click", (event) => this.handleCopyLink(event)); + + const fold = document.createElement("button"); + fold.innerText = ""; + fold.innerHTML = + ''; + fold.type = "button"; + fold.className = `heading-fold ${ + node.attrs.collapsed ? "collapsed" : "" + }`; + fold.addEventListener("mousedown", (event) => + this.handleFoldContent(event) + ); + + return [ + `h${node.attrs.level + (this.options.offset || 0)}`, + [ + "span", + { + contentEditable: "false", + class: `heading-actions ${ + node.attrs.collapsed ? "collapsed" : "" + }`, + }, + anchor, + fold, + ], + [ + "span", + { + class: "heading-content", + }, + 0, + ], + ]; + }, + }; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.write(state.repeat("#", node.attrs.level) + " "); + state.renderInline(node); + state.closeBlock(node); + } + + parseMarkdown() { + return { + block: "heading", + getAttrs: (token: Record) => ({ + level: +token.tag.slice(1), + }), + }; + } + + commands({ type, schema }: { type: NodeType; schema: Schema }) { + return (attrs: Record) => { + return toggleBlockType(type, schema.nodes.paragraph, attrs); + }; + } + + handleFoldContent = (event: MouseEvent) => { + event.preventDefault(); + if (!(event.target instanceof HTMLButtonElement)) { + return; + } + + const { view } = this.editor; + const hadFocus = view.hasFocus(); + const { tr } = view.state; + const { top, left } = event.target.getBoundingClientRect(); + const result = view.posAtCoords({ top, left }); + + if (result) { + const node = view.state.doc.nodeAt(result.inside); + + if (node) { + const endOfHeadingPos = result.inside + node.nodeSize; + const $pos = view.state.doc.resolve(endOfHeadingPos); + const collapsed = !node.attrs.collapsed; + + if (collapsed && view.state.selection.to > endOfHeadingPos) { + // move selection to the end of the collapsed heading + tr.setSelection(Selection.near($pos, -1)); + } + + const transaction = tr.setNodeMarkup(result.inside, undefined, { + ...node.attrs, + collapsed, + }); + + const persistKey = headingToPersistenceKey(node, this.editor.props.id); + + if (collapsed) { + localStorage?.setItem(persistKey, "collapsed"); + } else { + localStorage?.removeItem(persistKey); + } + + view.dispatch(transaction); + + if (hadFocus) { + view.focus(); + } + } + } + }; + + handleCopyLink = (event: MouseEvent) => { + // this is unfortunate but appears to be the best way to grab the anchor + // as it's added directly to the dom by a decoration. + const anchor = + event.currentTarget instanceof HTMLButtonElement && + (event.currentTarget.parentNode?.parentNode + ?.previousSibling as HTMLElement); + + if (!anchor || !anchor.className.includes(this.className)) { + throw new Error("Did not find anchor as previous sibling of heading"); + } + const hash = `#${anchor.id}`; + + // the existing url might contain a hash already, lets make sure to remove + // that rather than appending another one. + const urlWithoutHash = window.location.href.split("#")[0]; + copy(urlWithoutHash + hash); + + if (this.options.onShowToast) { + this.options.onShowToast( + this.options.dictionary.linkCopied, + ToastType.Info + ); + } + }; + + keys({ type, schema }: { type: NodeType; schema: Schema }) { + const options = this.options.levels.reduce( + (items: Record, level: number) => ({ + ...items, + ...{ + [`Shift-Ctrl-${level}`]: toggleBlockType( + type, + schema.nodes.paragraph, + { level } + ), + }, + }), + {} + ); + + return { + ...options, + Backspace: backspaceToParagraph(type), + Enter: splitHeading(type), + }; + } + + get plugins() { + const getAnchors = (doc: ProsemirrorNode) => { + const decorations: Decoration[] = []; + const previouslySeen = {}; + + doc.descendants((node, pos) => { + if (node.type.name !== this.name) return; + + // calculate the optimal id + 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; + + decorations.push( + Decoration.widget( + pos, + () => { + const anchor = document.createElement("a"); + anchor.id = id; + anchor.className = this.className; + return anchor; + }, + { + side: -1, + key: id, + } + ) + ); + }); + + return DecorationSet.create(doc, decorations); + }; + + const plugin: Plugin = new Plugin({ + state: { + init: (config, state) => { + return getAnchors(state.doc); + }, + apply: (tr, oldState) => { + return tr.docChanged ? getAnchors(tr.doc) : oldState; + }, + }, + props: { + decorations: (state) => plugin.getState(state), + }, + }); + + return [plugin]; + } + + inputRules({ type }: { type: NodeType }) { + return this.options.levels.map((level: number) => + textblockTypeInputRule(new RegExp(`^(#{1,${level}})\\s$`), type, () => ({ + level, + })) + ); + } +} diff --git a/shared/editor/nodes/HorizontalRule.ts b/shared/editor/nodes/HorizontalRule.ts new file mode 100644 index 000000000..02abcb233 --- /dev/null +++ b/shared/editor/nodes/HorizontalRule.ts @@ -0,0 +1,80 @@ +import Token from "markdown-it/lib/token"; +import { InputRule } from "prosemirror-inputrules"; +import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model"; +import { EditorState, Transaction } from "prosemirror-state"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import Node from "./Node"; + +export default class HorizontalRule extends Node { + get name() { + return "hr"; + } + + get schema(): NodeSpec { + return { + attrs: { + markup: { + default: "---", + }, + }, + group: "block", + parseDOM: [{ tag: "hr" }], + toDOM: (node) => { + return [ + "hr", + { class: node.attrs.markup === "***" ? "page-break" : "" }, + ]; + }, + }; + } + + commands({ type }: { type: NodeType }) { + return (attrs: Record) => ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + dispatch( + state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView() + ); + return true; + }; + } + + keys({ type }: { type: NodeType }) { + return { + "Mod-_": (state: EditorState, dispatch: (tr: Transaction) => void) => { + dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); + return true; + }, + }; + } + + inputRules({ type }: { type: NodeType }) { + return [ + new InputRule(/^(?:---|___\s|\*\*\*\s)$/, (state, match, start, end) => { + const { tr } = state; + + if (match[0]) { + const markup = match[0].trim(); + tr.replaceWith(start - 1, end, type.create({ markup })); + } + + return tr; + }), + ]; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.write(`\n${node.attrs.markup}`); + state.closeBlock(node); + } + + parseMarkdown() { + return { + node: "hr", + getAttrs: (tok: Token) => ({ + markup: tok.markup, + }), + }; + } +} diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx new file mode 100644 index 000000000..a23607359 --- /dev/null +++ b/shared/editor/nodes/Image.tsx @@ -0,0 +1,574 @@ +import Token from "markdown-it/lib/token"; +import { DownloadIcon } from "outline-icons"; +import { InputRule } from "prosemirror-inputrules"; +import { Node as ProsemirrorNode, NodeSpec, NodeType } from "prosemirror-model"; +import { + Plugin, + TextSelection, + NodeSelection, + EditorState, + Transaction, +} from "prosemirror-state"; +import * as React from "react"; +import ImageZoom from "react-medium-image-zoom"; +import styled from "styled-components"; +import getDataTransferFiles from "../../utils/getDataTransferFiles"; +import insertFiles, { Options } from "../commands/insertFiles"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import uploadPlaceholderPlugin from "../lib/uploadPlaceholder"; +import { ComponentProps } from "../types"; +import Node from "./Node"; + +/** + * Matches following attributes in Markdown-typed image: [, alt, src, class] + * + * Example: + * ![Lorem](image.jpg) -> [, "Lorem", "image.jpg"] + * ![](image.jpg "class") -> [, "", "image.jpg", "small"] + * ![Lorem](image.jpg "class") -> [, "Lorem", "image.jpg", "small"] + */ +const IMAGE_INPUT_REGEX = /!\[(?[^\][]*?)]\((?[^\][]*?)(?=“|\))“?(?[^\][”]+)?”?\)$/; + +const uploadPlugin = (options: Options) => + new Plugin({ + props: { + handleDOMEvents: { + paste(view, event: ClipboardEvent): boolean { + if ( + (view.props.editable && !view.props.editable(view.state)) || + !options.uploadImage + ) { + return false; + } + + if (!event.clipboardData) return false; + + // check if we actually pasted any files + const files = Array.prototype.slice + .call(event.clipboardData.items) + .map((dt: any) => dt.getAsFile()) + .filter((file: File) => file); + + if (files.length === 0) return false; + + const { tr } = view.state; + if (!tr.selection.empty) { + tr.deleteSelection(); + } + const pos = tr.selection.from; + + insertFiles(view, event, pos, files, options); + return true; + }, + drop(view, event: DragEvent): boolean { + if ( + (view.props.editable && !view.props.editable(view.state)) || + !options.uploadImage + ) { + return false; + } + + // filter to only include image files + const files = getDataTransferFiles(event).filter((file) => + /image/i.test(file.type) + ); + if (files.length === 0) { + return false; + } + + // grab the position in the document for the cursor + const result = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (result) { + insertFiles(view, event, result.pos, files, options); + return true; + } + + return false; + }, + }, + }, + }); + +const IMAGE_CLASSES = ["right-50", "left-50"]; + +const getLayoutAndTitle = (tokenTitle: string | null) => { + if (!tokenTitle) return {}; + if (IMAGE_CLASSES.includes(tokenTitle)) { + return { + layoutClass: tokenTitle, + }; + } else { + return { + title: tokenTitle, + }; + } +}; + +const downloadImageNode = async (node: ProsemirrorNode) => { + const image = await fetch(node.attrs.src); + const imageBlob = await image.blob(); + const imageURL = URL.createObjectURL(imageBlob); + const extension = imageBlob.type.split("/")[1]; + const potentialName = node.attrs.alt || "image"; + + // create a temporary link node and click it with our image data + const link = document.createElement("a"); + link.href = imageURL; + link.download = `${potentialName}.${extension}`; + document.body.appendChild(link); + link.click(); + + // cleanup + document.body.removeChild(link); +}; + +export default class Image extends Node { + options: Options; + + get name() { + return "image"; + } + + get schema(): NodeSpec { + return { + inline: true, + attrs: { + src: {}, + alt: { + default: null, + }, + layoutClass: { + default: null, + }, + title: { + default: null, + }, + }, + content: "text*", + marks: "", + group: "inline", + selectable: true, + draggable: true, + parseDOM: [ + { + tag: "div[class~=image]", + getAttrs: (dom: HTMLDivElement) => { + const img = dom.getElementsByTagName("img")[0]; + const className = dom.className; + const layoutClassMatched = + className && className.match(/image-(.*)$/); + const layoutClass = layoutClassMatched + ? layoutClassMatched[1] + : null; + return { + src: img?.getAttribute("src"), + alt: img?.getAttribute("alt"), + title: img?.getAttribute("title"), + layoutClass: layoutClass, + }; + }, + }, + { + tag: "img", + getAttrs: (dom: HTMLImageElement) => { + return { + src: dom.getAttribute("src"), + alt: dom.getAttribute("alt"), + title: dom.getAttribute("title"), + }; + }, + }, + ], + toDOM: (node) => { + const className = node.attrs.layoutClass + ? `image image-${node.attrs.layoutClass}` + : "image"; + return [ + "div", + { + class: className, + }, + ["img", { ...node.attrs, contentEditable: "false" }], + ["p", { class: "caption" }, 0], + ]; + }, + }; + } + + handleKeyDown = ({ + node, + getPos, + }: { + node: ProsemirrorNode; + getPos: () => number; + }) => (event: React.KeyboardEvent) => { + // Pressing Enter in the caption field should move the cursor/selection + // below the image + if (event.key === "Enter") { + event.preventDefault(); + + const { view } = this.editor; + const $pos = view.state.doc.resolve(getPos() + node.nodeSize); + view.dispatch( + view.state.tr.setSelection(new TextSelection($pos)).split($pos.pos) + ); + view.focus(); + return; + } + + // Pressing Backspace in an an empty caption field should remove the entire + // image, leaving an empty paragraph + if (event.key === "Backspace" && event.currentTarget.innerText === "") { + const { view } = this.editor; + const $pos = view.state.doc.resolve(getPos()); + const tr = view.state.tr.setSelection(new NodeSelection($pos)); + view.dispatch(tr.deleteSelection()); + view.focus(); + return; + } + }; + + handleBlur = ({ + node, + getPos, + }: { + node: ProsemirrorNode; + getPos: () => number; + }) => (event: React.FocusEvent) => { + const alt = event.currentTarget.innerText; + const { src, title, layoutClass } = node.attrs; + + if (alt === node.attrs.alt) return; + + const { view } = this.editor; + const { tr } = view.state; + + // update meta on object + const pos = getPos(); + const transaction = tr.setNodeMarkup(pos, undefined, { + src, + alt, + title, + layoutClass, + }); + view.dispatch(transaction); + }; + + handleSelect = ({ getPos }: { getPos: () => number }) => ( + event: React.MouseEvent + ) => { + event.preventDefault(); + + const { view } = this.editor; + const $pos = view.state.doc.resolve(getPos()); + const transaction = view.state.tr.setSelection(new NodeSelection($pos)); + view.dispatch(transaction); + }; + + handleDownload = ({ node }: { node: ProsemirrorNode }) => ( + event: React.MouseEvent + ) => { + event.preventDefault(); + event.stopPropagation(); + downloadImageNode(node); + }; + + component = (props: ComponentProps) => { + const { theme, isSelected } = props; + const { alt, src, layoutClass } = props.node.attrs; + const className = layoutClass ? `image image-${layoutClass}` : "image"; + + return ( +
+ + + + + + {alt} + +
+ ); + }; + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + let markdown = + " ![" + + state.esc((node.attrs.alt || "").replace("\n", "") || "", false) + + "](" + + state.esc(node.attrs.src, false); + if (node.attrs.layoutClass) { + markdown += ' "' + state.esc(node.attrs.layoutClass, false) + '"'; + } else if (node.attrs.title) { + markdown += ' "' + state.esc(node.attrs.title, false) + '"'; + } + markdown += ")"; + state.write(markdown); + } + + parseMarkdown() { + return { + node: "image", + getAttrs: (token: Token) => { + return { + src: token.attrGet("src"), + alt: + (token?.children && + token.children[0] && + token.children[0].content) || + null, + ...getLayoutAndTitle(token?.attrGet("title")), + }; + }, + }; + } + + commands({ type }: { type: NodeType }) { + return { + downloadImage: () => (state: EditorState) => { + if (!(state.selection instanceof NodeSelection)) { + return false; + } + const { node } = state.selection; + + if (node.type.name !== "image") { + return false; + } + + downloadImageNode(node); + + return true; + }, + deleteImage: () => ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + dispatch(state.tr.deleteSelection()); + return true; + }, + alignRight: () => ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (!(state.selection instanceof NodeSelection)) { + return false; + } + const attrs = { + ...state.selection.node.attrs, + title: null, + layoutClass: "right-50", + }; + const { selection } = state; + dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs)); + return true; + }, + alignLeft: () => ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (!(state.selection instanceof NodeSelection)) { + return false; + } + const attrs = { + ...state.selection.node.attrs, + title: null, + layoutClass: "left-50", + }; + const { selection } = state; + dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs)); + return true; + }, + replaceImage: () => (state: EditorState) => { + const { view } = this.editor; + const { + uploadImage, + onImageUploadStart, + onImageUploadStop, + onShowToast, + } = this.editor.props; + + if (!uploadImage) { + throw new Error("uploadImage prop is required to replace images"); + } + + // create an input element and click to trigger picker + const inputElement = document.createElement("input"); + inputElement.type = "file"; + inputElement.accept = "image/*"; + inputElement.onchange = (event: Event) => { + const files = getDataTransferFiles(event); + insertFiles(view, event, state.selection.from, files, { + uploadImage, + onImageUploadStart, + onImageUploadStop, + onShowToast, + dictionary: this.options.dictionary, + replaceExisting: true, + }); + }; + inputElement.click(); + return true; + }, + alignCenter: () => ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (!(state.selection instanceof NodeSelection)) { + return false; + } + const attrs = { ...state.selection.node.attrs, layoutClass: null }; + const { selection } = state; + dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs)); + return true; + }, + createImage: (attrs: Record) => ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + const { selection } = state; + const position = + selection instanceof TextSelection + ? selection.$cursor?.pos + : selection.$to.pos; + if (position === undefined) { + return false; + } + + const node = type.create(attrs); + const transaction = state.tr.insert(position, node); + dispatch(transaction); + return true; + }, + }; + } + + inputRules({ type }: { type: NodeType }) { + return [ + new InputRule(IMAGE_INPUT_REGEX, (state, match, start, end) => { + const [okay, alt, src, matchedTitle] = match; + const { tr } = state; + + if (okay) { + tr.replaceWith( + start - 1, + end, + type.create({ + src, + alt, + ...getLayoutAndTitle(matchedTitle), + }) + ); + } + + return tr; + }), + ]; + } + + get plugins() { + return [uploadPlaceholderPlugin, uploadPlugin(this.options)]; + } +} + +const Button = styled.button` + position: absolute; + top: 8px; + right: 8px; + border: 0; + margin: 0; + padding: 0; + border-radius: 4px; + background: ${(props) => props.theme.background}; + color: ${(props) => props.theme.textSecondary}; + width: 24px; + height: 24px; + display: inline-block; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease-in-out; + + &:active { + transform: scale(0.98); + } + + &:hover { + color: ${(props) => props.theme.text}; + opacity: 1; + } +`; + +const Caption = styled.p` + border: 0; + display: block; + font-size: 13px; + font-style: italic; + font-weight: normal; + color: ${(props) => props.theme.textSecondary}; + padding: 2px 0; + line-height: 16px; + text-align: center; + min-height: 1em; + outline: none; + background: none; + resize: none; + user-select: text; + cursor: text; + + &:empty:not(:focus) { + visibility: hidden; + } + + &:empty:before { + color: ${(props) => props.theme.placeholder}; + content: attr(data-caption); + pointer-events: none; + } +`; + +const ImageWrapper = styled.span` + line-height: 0; + display: inline-block; + position: relative; + + &:hover { + ${Button} { + opacity: 0.9; + } + } + + &.ProseMirror-selectednode + ${Caption} { + visibility: visible; + } +`; diff --git a/shared/editor/nodes/ListItem.ts b/shared/editor/nodes/ListItem.ts new file mode 100644 index 000000000..f9fb090fa --- /dev/null +++ b/shared/editor/nodes/ListItem.ts @@ -0,0 +1,283 @@ +import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model"; +import { + splitListItem, + sinkListItem, + liftListItem, +} from "prosemirror-schema-list"; +import { + Transaction, + EditorState, + Plugin, + TextSelection, +} from "prosemirror-state"; +import { findParentNodeClosestToPos } from "prosemirror-utils"; +import { DecorationSet, Decoration } from "prosemirror-view"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import getParentListItem from "../queries/getParentListItem"; +import isInList from "../queries/isInList"; +import isList from "../queries/isList"; +import Node from "./Node"; + +export default class ListItem extends Node { + get name() { + return "list_item"; + } + + get schema(): NodeSpec { + return { + content: "paragraph block*", + defining: true, + draggable: true, + parseDOM: [{ tag: "li" }], + toDOM: () => ["li", 0], + }; + } + + get plugins() { + return [ + new Plugin({ + state: { + init() { + return DecorationSet.empty; + }, + apply: ( + tr: Transaction, + set: DecorationSet, + oldState: EditorState, + newState: EditorState + ) => { + const action = tr.getMeta("li"); + if (!action && !tr.docChanged) { + return set; + } + + // Adjust decoration positions to changes made by the transaction + set = set.map(tr.mapping, tr.doc); + + switch (action?.event) { + case "mouseover": { + const result = findParentNodeClosestToPos( + newState.doc.resolve(action.pos), + (node) => + node.type.name === this.name || + node.type.name === "checkbox_item" + ); + + if (!result) { + return set; + } + + const list = findParentNodeClosestToPos( + newState.doc.resolve(action.pos), + (node) => isList(node, this.editor.schema) + ); + + if (!list) { + return set; + } + + const start = list.node.attrs.order || 1; + + let listItemNumber = 0; + list.node.content.forEach((li, _, index) => { + if (li === result.node) { + listItemNumber = index; + } + }); + + const counterLength = String(start + listItemNumber).length; + + return set.add(tr.doc, [ + Decoration.node( + result.pos, + result.pos + result.node.nodeSize, + { + class: `hovering`, + }, + { + hover: true, + } + ), + Decoration.node( + result.pos, + result.pos + result.node.nodeSize, + { + class: `counter-${counterLength}`, + } + ), + ]); + } + case "mouseout": { + const result = findParentNodeClosestToPos( + newState.doc.resolve(action.pos), + (node) => + node.type.name === this.name || + node.type.name === "checkbox_item" + ); + + if (!result) { + return set; + } + + return set.remove( + set.find( + result.pos, + result.pos + result.node.nodeSize, + (spec) => spec.hover + ) + ); + } + default: + } + + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + handleDOMEvents: { + mouseover: (view, event) => { + const { state, dispatch } = view; + const target = event.target as HTMLElement; + const li = target?.closest("li"); + + if (!li) { + return false; + } + if (!view.dom.contains(li)) { + return false; + } + const pos = view.posAtDOM(li, 0); + if (!pos) { + return false; + } + + dispatch( + state.tr.setMeta("li", { + event: "mouseover", + pos, + }) + ); + return false; + }, + mouseout: (view, event) => { + const { state, dispatch } = view; + const target = event.target as HTMLElement; + const li = target?.closest("li"); + + if (!li) { + return false; + } + if (!view.dom.contains(li)) { + return false; + } + const pos = view.posAtDOM(li, 0); + if (!pos) { + return false; + } + + dispatch( + state.tr.setMeta("li", { + event: "mouseout", + pos, + }) + ); + return false; + }, + }, + }, + }), + ]; + } + + keys({ type }: { type: NodeType }) { + return { + Enter: splitListItem(type), + Tab: sinkListItem(type), + "Shift-Tab": liftListItem(type), + "Mod-]": sinkListItem(type), + "Mod-[": liftListItem(type), + "Shift-Enter": ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (!isInList(state)) return false; + if (!state.selection.empty) return false; + + const { tr, selection } = state; + dispatch(tr.split(selection.to)); + return true; + }, + "Alt-ArrowUp": ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (!state.selection.empty) return false; + const result = getParentListItem(state); + if (!result) return false; + + const [li, pos] = result; + const $pos = state.doc.resolve(pos); + + if ( + !$pos.nodeBefore || + !["list_item", "checkbox_item"].includes($pos.nodeBefore.type.name) + ) { + console.log("Node before not a list item"); + return false; + } + + const { tr } = state; + const newPos = pos - $pos.nodeBefore.nodeSize; + + dispatch( + tr + .delete(pos, pos + li.nodeSize) + .insert(newPos, li) + .setSelection(TextSelection.near(tr.doc.resolve(newPos))) + ); + return true; + }, + "Alt-ArrowDown": ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (!state.selection.empty) return false; + const result = getParentListItem(state); + if (!result) return false; + + const [li, pos] = result; + const $pos = state.doc.resolve(pos + li.nodeSize); + + if ( + !$pos.nodeAfter || + !["list_item", "checkbox_item"].includes($pos.nodeAfter.type.name) + ) { + console.log("Node after not a list item"); + return false; + } + + const { tr } = state; + const newPos = pos + li.nodeSize + $pos.nodeAfter.nodeSize; + + dispatch( + tr + .insert(newPos, li) + .setSelection(TextSelection.near(tr.doc.resolve(newPos))) + .delete(pos, pos + li.nodeSize) + ); + return true; + }, + }; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.renderContent(node); + } + + parseMarkdown() { + return { block: "list_item" }; + } +} diff --git a/shared/editor/nodes/Node.ts b/shared/editor/nodes/Node.ts new file mode 100644 index 000000000..7657e658a --- /dev/null +++ b/shared/editor/nodes/Node.ts @@ -0,0 +1,47 @@ +import { InputRule } from "prosemirror-inputrules"; +import { TokenConfig } from "prosemirror-markdown"; +import { + Node as ProsemirrorNode, + NodeSpec, + NodeType, + Schema, +} from "prosemirror-model"; +import Extension, { Command, CommandFactory } from "../lib/Extension"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; + +export default abstract class Node extends Extension { + get type() { + return "node"; + } + + get schema(): NodeSpec { + return {}; + } + + get markdownToken(): string { + return ""; + } + + inputRules(_options: { type: NodeType; schema: Schema }): InputRule[] { + return []; + } + + keys(_options: { type: NodeType; schema: Schema }): Record { + return {}; + } + + commands(_options: { + type: NodeType; + schema: Schema; + }): Record | CommandFactory { + return {}; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode): void { + console.error("toMarkdown not implemented", state, node); + } + + parseMarkdown(): TokenConfig | void { + return undefined; + } +} diff --git a/shared/editor/nodes/Notice.tsx b/shared/editor/nodes/Notice.tsx new file mode 100644 index 000000000..14273aeb0 --- /dev/null +++ b/shared/editor/nodes/Notice.tsx @@ -0,0 +1,132 @@ +import Token from "markdown-it/lib/token"; +import { WarningIcon, InfoIcon, StarredIcon } from "outline-icons"; +import { wrappingInputRule } from "prosemirror-inputrules"; +import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; +import * as React from "react"; +import ReactDOM from "react-dom"; +import toggleWrap from "../commands/toggleWrap"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import noticesRule from "../rules/notices"; +import Node from "./Node"; + +export default class Notice extends Node { + get styleOptions() { + return Object.entries({ + info: this.options.dictionary.info, + warning: this.options.dictionary.warning, + tip: this.options.dictionary.tip, + }); + } + + get name() { + return "container_notice"; + } + + get rulePlugins() { + return [noticesRule]; + } + + get schema(): NodeSpec { + return { + attrs: { + style: { + default: "info", + }, + }, + content: "block+", + group: "block", + defining: true, + draggable: true, + parseDOM: [ + { + tag: "div.notice-block", + preserveWhitespace: "full", + contentElement: "div:last-child", + getAttrs: (dom: HTMLDivElement) => ({ + style: dom.className.includes("tip") + ? "tip" + : dom.className.includes("warning") + ? "warning" + : undefined, + }), + }, + ], + toDOM: (node) => { + const select = document.createElement("select"); + select.addEventListener("change", this.handleStyleChange); + + this.styleOptions.forEach(([key, label]) => { + const option = document.createElement("option"); + option.value = key; + option.innerText = label; + option.selected = node.attrs.style === key; + select.appendChild(option); + }); + + let component; + + if (node.attrs.style === "tip") { + component = ; + } else if (node.attrs.style === "warning") { + component = ; + } else { + component = ; + } + + const icon = document.createElement("div"); + icon.className = "icon"; + ReactDOM.render(component, icon); + + return [ + "div", + { class: `notice-block ${node.attrs.style}` }, + icon, + ["div", { contentEditable: "false" }, select], + ["div", { class: "content" }, 0], + ]; + }, + }; + } + + commands({ type }: { type: NodeType }) { + return (attrs: Record) => toggleWrap(type, attrs); + } + + handleStyleChange = (event: InputEvent) => { + const { view } = this.editor; + const { tr } = view.state; + const element = event.target; + if (!(element instanceof HTMLSelectElement)) { + return; + } + + const { top, left } = element.getBoundingClientRect(); + const result = view.posAtCoords({ top, left }); + + if (result) { + const transaction = tr.setNodeMarkup(result.inside, undefined, { + style: element.value, + }); + view.dispatch(transaction); + } + }; + + inputRules({ type }: { type: NodeType }) { + return [wrappingInputRule(/^:::$/, type)]; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.write("\n:::" + (node.attrs.style || "info") + "\n"); + state.renderContent(node); + state.ensureNewLine(); + state.write(":::"); + state.closeBlock(node); + } + + parseMarkdown() { + return { + block: "container_notice", + getAttrs: (tok: Token) => ({ style: tok.info }), + }; + } +} diff --git a/shared/editor/nodes/OrderedList.ts b/shared/editor/nodes/OrderedList.ts new file mode 100644 index 000000000..10604b77e --- /dev/null +++ b/shared/editor/nodes/OrderedList.ts @@ -0,0 +1,86 @@ +import Token from "markdown-it/lib/token"; +import { wrappingInputRule } from "prosemirror-inputrules"; +import { + NodeSpec, + NodeType, + Schema, + Node as ProsemirrorNode, +} from "prosemirror-model"; +import toggleList from "../commands/toggleList"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import Node from "./Node"; + +export default class OrderedList extends Node { + get name() { + return "ordered_list"; + } + + get schema(): NodeSpec { + return { + attrs: { + order: { + default: 1, + }, + }, + content: "list_item+", + group: "block", + parseDOM: [ + { + tag: "ol", + getAttrs: (dom: HTMLOListElement) => ({ + order: dom.hasAttribute("start") + ? parseInt(dom.getAttribute("start") || "1", 10) + : 1, + }), + }, + ], + toDOM: (node) => + node.attrs.order === 1 + ? ["ol", 0] + : ["ol", { start: node.attrs.order }, 0], + }; + } + + commands({ type, schema }: { type: NodeType; schema: Schema }) { + return () => toggleList(type, schema.nodes.list_item); + } + + keys({ type, schema }: { type: NodeType; schema: Schema }) { + return { + "Shift-Ctrl-9": toggleList(type, schema.nodes.list_item), + }; + } + + inputRules({ type }: { type: NodeType }) { + return [ + wrappingInputRule( + /^(\d+)\.\s$/, + type, + (match) => ({ order: +match[1] }), + (match, node) => node.childCount + node.attrs.order === +match[1] + ), + ]; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.write("\n"); + + const start = node.attrs.order !== undefined ? node.attrs.order : 1; + const maxW = `${start + node.childCount - 1}`.length; + const space = state.repeat(" ", maxW + 2); + + state.renderList(node, space, (index: number) => { + const nStr = `${start + index}`; + return state.repeat(" ", maxW - nStr.length) + nStr + ". "; + }); + } + + parseMarkdown() { + return { + block: "ordered_list", + getAttrs: (tok: Token) => ({ + order: parseInt(tok.attrGet("start") || "1", 10), + }), + }; + } +} diff --git a/shared/editor/nodes/Paragraph.ts b/shared/editor/nodes/Paragraph.ts new file mode 100644 index 000000000..ac9de1cc8 --- /dev/null +++ b/shared/editor/nodes/Paragraph.ts @@ -0,0 +1,48 @@ +import { setBlockType } from "prosemirror-commands"; +import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import Node from "./Node"; + +export default class Paragraph extends Node { + get name() { + return "paragraph"; + } + + get schema(): NodeSpec { + return { + content: "inline*", + group: "block", + parseDOM: [{ tag: "p" }], + toDOM: () => ["p", 0], + }; + } + + keys({ type }: { type: NodeType }) { + return { + "Shift-Ctrl-0": setBlockType(type), + }; + } + + commands({ type }: { type: NodeType }) { + return () => setBlockType(type); + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + // render empty paragraphs as hard breaks to ensure that newlines are + // persisted between reloads (this breaks from markdown tradition) + if ( + node.textContent.trim() === "" && + node.childCount === 0 && + !state.inTable + ) { + state.write("\\\n"); + } else { + state.renderInline(node); + state.closeBlock(node); + } + } + + parseMarkdown() { + return { block: "paragraph" }; + } +} diff --git a/shared/editor/nodes/ReactNode.ts b/shared/editor/nodes/ReactNode.ts new file mode 100644 index 000000000..da90a465d --- /dev/null +++ b/shared/editor/nodes/ReactNode.ts @@ -0,0 +1,10 @@ +import { ComponentProps } from "../types"; +import Node from "./Node"; + +export default abstract class ReactNode extends Node { + abstract component({ + node, + isSelected, + isEditable, + }: Omit): React.ReactElement; +} diff --git a/shared/editor/nodes/Table.ts b/shared/editor/nodes/Table.ts new file mode 100644 index 000000000..90de2225f --- /dev/null +++ b/shared/editor/nodes/Table.ts @@ -0,0 +1,188 @@ +import { NodeSpec, Node as ProsemirrorNode, Schema } from "prosemirror-model"; +import { + EditorState, + Plugin, + TextSelection, + Transaction, +} from "prosemirror-state"; +import { + addColumnAfter, + addColumnBefore, + deleteColumn, + deleteRow, + deleteTable, + goToNextCell, + isInTable, + tableEditing, + toggleHeaderCell, + toggleHeaderColumn, + toggleHeaderRow, +} from "prosemirror-tables"; +import { + addRowAt, + createTable, + getCellsInColumn, + moveRow, +} from "prosemirror-utils"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import tablesRule from "../rules/tables"; +import Node from "./Node"; + +export default class Table extends Node { + get name() { + return "table"; + } + + get schema(): NodeSpec { + return { + content: "tr+", + tableRole: "table", + isolating: true, + group: "block", + parseDOM: [{ tag: "table" }], + toDOM() { + return [ + "div", + { class: "scrollable-wrapper" }, + [ + "div", + { class: "scrollable" }, + ["table", { class: "rme-table" }, ["tbody", 0]], + ], + ]; + }, + }; + } + + get rulePlugins() { + return [tablesRule]; + } + + commands({ schema }: { schema: Schema }) { + return { + createTable: ({ + rowsCount, + colsCount, + }: { + rowsCount: number; + colsCount: number; + }) => (state: EditorState, dispatch: (tr: Transaction) => void) => { + const offset = state.tr.selection.anchor + 1; + const nodes = createTable(schema, rowsCount, colsCount); + const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView(); + const resolvedPos = tr.doc.resolve(offset); + + tr.setSelection(TextSelection.near(resolvedPos)); + dispatch(tr); + return true; + }, + setColumnAttr: ({ + index, + alignment, + }: { + index: number; + alignment: string; + }) => (state: EditorState, dispatch: (tr: Transaction) => void) => { + const cells = getCellsInColumn(index)(state.selection) || []; + let transaction = state.tr; + cells.forEach(({ pos }) => { + transaction = transaction.setNodeMarkup(pos, undefined, { + alignment, + }); + }); + dispatch(transaction); + return true; + }, + addColumnBefore: () => addColumnBefore, + addColumnAfter: () => addColumnAfter, + deleteColumn: () => deleteColumn, + addRowAfter: ({ index }: { index: number }) => ( + state: EditorState, + dispatch: (tr: Transaction) => void + ) => { + if (index === 0) { + // A little hack to avoid cloning the heading row by cloning the row + // beneath and then moving it to the right index. + const tr = addRowAt(index + 2, true)(state.tr); + dispatch(moveRow(index + 2, index + 1)(tr)); + } else { + dispatch(addRowAt(index + 1, true)(state.tr)); + } + return true; + }, + deleteRow: () => deleteRow, + deleteTable: () => deleteTable, + toggleHeaderColumn: () => toggleHeaderColumn, + toggleHeaderRow: () => toggleHeaderRow, + toggleHeaderCell: () => toggleHeaderCell, + }; + } + + keys() { + return { + Tab: goToNextCell(1), + "Shift-Tab": goToNextCell(-1), + Enter: (state: EditorState, dispatch: (tr: Transaction) => void) => { + if (!isInTable(state)) return false; + + // TODO: Adding row at the end for now, can we find the current cell + // row index and add the row below that? + const cells = getCellsInColumn(0)(state.selection) || []; + + dispatch(addRowAt(cells.length, true)(state.tr)); + return true; + }, + }; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.renderTable(node); + state.closeBlock(node); + } + + parseMarkdown() { + return { block: "table" }; + } + + get plugins() { + return [ + tableEditing(), + new Plugin({ + props: { + decorations: (state) => { + const { doc } = state; + const decorations: Decoration[] = []; + let index = 0; + + doc.descendants((node, pos) => { + if (node.type.name !== this.name) return; + + const elements = document.getElementsByClassName("rme-table"); + const table = elements[index]; + if (!table) return; + + const element = table.parentElement; + const shadowRight = !!( + element && element.scrollWidth > element.clientWidth + ); + + if (shadowRight) { + decorations.push( + Decoration.widget(pos + 1, () => { + const shadow = document.createElement("div"); + shadow.className = "scrollable-shadow right"; + return shadow; + }) + ); + } + index++; + }); + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + } +} diff --git a/shared/editor/nodes/TableCell.ts b/shared/editor/nodes/TableCell.ts new file mode 100644 index 000000000..b72b6ee37 --- /dev/null +++ b/shared/editor/nodes/TableCell.ts @@ -0,0 +1,114 @@ +import Token from "markdown-it/lib/token"; +import { NodeSpec } from "prosemirror-model"; +import { Plugin } from "prosemirror-state"; +import { + isTableSelected, + isRowSelected, + getCellsInColumn, +} from "prosemirror-utils"; +import { DecorationSet, Decoration } from "prosemirror-view"; +import Node from "./Node"; + +export default class TableCell extends Node { + get name() { + return "td"; + } + + get schema(): NodeSpec { + return { + content: "paragraph+", + tableRole: "cell", + isolating: true, + parseDOM: [{ tag: "td" }], + toDOM(node) { + return [ + "td", + node.attrs.alignment + ? { style: `text-align: ${node.attrs.alignment}` } + : {}, + 0, + ]; + }, + attrs: { + colspan: { default: 1 }, + rowspan: { default: 1 }, + alignment: { default: null }, + }, + }; + } + + toMarkdown() { + // see: renderTable + } + + parseMarkdown() { + return { + block: "td", + getAttrs: (tok: Token) => ({ alignment: tok.info }), + }; + } + + get plugins() { + return [ + new Plugin({ + props: { + decorations: (state) => { + const { doc, selection } = state; + const decorations: Decoration[] = []; + const cells = getCellsInColumn(0)(selection); + + if (cells) { + cells.forEach(({ pos }, index) => { + if (index === 0) { + decorations.push( + Decoration.widget(pos + 1, () => { + let className = "grip-table"; + const selected = isTableSelected(selection); + if (selected) { + className += " selected"; + } + const grip = document.createElement("a"); + grip.className = className; + grip.addEventListener("mousedown", (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + this.options.onSelectTable(state); + }); + return grip; + }) + ); + } + decorations.push( + Decoration.widget(pos + 1, () => { + const rowSelected = isRowSelected(index)(selection); + + let className = "grip-row"; + if (rowSelected) { + className += " selected"; + } + if (index === 0) { + className += " first"; + } + if (index === cells.length - 1) { + className += " last"; + } + const grip = document.createElement("a"); + grip.className = className; + grip.addEventListener("mousedown", (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + this.options.onSelectRow(index, state); + }); + return grip; + }) + ); + }); + } + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + } +} diff --git a/shared/editor/nodes/TableHeadCell.ts b/shared/editor/nodes/TableHeadCell.ts new file mode 100644 index 000000000..1cb4995b9 --- /dev/null +++ b/shared/editor/nodes/TableHeadCell.ts @@ -0,0 +1,89 @@ +import Token from "markdown-it/lib/token"; +import { NodeSpec } from "prosemirror-model"; +import { Plugin } from "prosemirror-state"; +import { isColumnSelected, getCellsInRow } from "prosemirror-utils"; +import { DecorationSet, Decoration } from "prosemirror-view"; +import Node from "./Node"; + +export default class TableHeadCell extends Node { + get name() { + return "th"; + } + + get schema(): NodeSpec { + return { + content: "paragraph+", + tableRole: "header_cell", + isolating: true, + parseDOM: [{ tag: "th" }], + toDOM(node) { + return [ + "th", + node.attrs.alignment + ? { style: `text-align: ${node.attrs.alignment}` } + : {}, + 0, + ]; + }, + attrs: { + colspan: { default: 1 }, + rowspan: { default: 1 }, + alignment: { default: null }, + }, + }; + } + + toMarkdown() { + // see: renderTable + } + + parseMarkdown() { + return { + block: "th", + getAttrs: (tok: Token) => ({ alignment: tok.info }), + }; + } + + get plugins() { + return [ + new Plugin({ + props: { + decorations: (state) => { + const { doc, selection } = state; + const decorations: Decoration[] = []; + const cells = getCellsInRow(0)(selection); + + if (cells) { + cells.forEach(({ pos }, index) => { + decorations.push( + Decoration.widget(pos + 1, () => { + const colSelected = isColumnSelected(index)(selection); + let className = "grip-column"; + if (colSelected) { + className += " selected"; + } + if (index === 0) { + className += " first"; + } else if (index === cells.length - 1) { + className += " last"; + } + const grip = document.createElement("a"); + grip.className = className; + grip.addEventListener("mousedown", (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + this.options.onSelectColumn(index, state); + }); + return grip; + }) + ); + }); + } + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + } +} diff --git a/shared/editor/nodes/TableRow.ts b/shared/editor/nodes/TableRow.ts new file mode 100644 index 000000000..1a36a93a0 --- /dev/null +++ b/shared/editor/nodes/TableRow.ts @@ -0,0 +1,23 @@ +import { NodeSpec } from "prosemirror-model"; +import Node from "./Node"; + +export default class TableRow extends Node { + get name() { + return "tr"; + } + + get schema(): NodeSpec { + return { + content: "(th | td)*", + tableRole: "row", + parseDOM: [{ tag: "tr" }], + toDOM() { + return ["tr", 0]; + }, + }; + } + + parseMarkdown() { + return { block: "tr" }; + } +} diff --git a/shared/editor/nodes/Text.ts b/shared/editor/nodes/Text.ts new file mode 100644 index 000000000..fb79e965c --- /dev/null +++ b/shared/editor/nodes/Text.ts @@ -0,0 +1,19 @@ +import { Node as ProsemirrorNode, NodeSpec } from "prosemirror-model"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import Node from "./Node"; + +export default class Text extends Node { + get name() { + return "text"; + } + + get schema(): NodeSpec { + return { + group: "inline", + }; + } + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.text(node.text || "", undefined); + } +} diff --git a/shared/editor/plugins/BlockMenuTrigger.tsx b/shared/editor/plugins/BlockMenuTrigger.tsx new file mode 100644 index 000000000..2b77c8a10 --- /dev/null +++ b/shared/editor/plugins/BlockMenuTrigger.tsx @@ -0,0 +1,193 @@ +import { PlusIcon } from "outline-icons"; +import { InputRule } from "prosemirror-inputrules"; +import { EditorState, Plugin } from "prosemirror-state"; +import { isInTable } from "prosemirror-tables"; +import { findParentNode } from "prosemirror-utils"; +import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; +import * as React from "react"; +import ReactDOM from "react-dom"; +import Extension from "../lib/Extension"; + +const MAX_MATCH = 500; +const OPEN_REGEX = /^\/(\w+)?$/; +const CLOSE_REGEX = /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/; + +// based on the input rules code in Prosemirror, here: +// https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/inputrules.js +export function run( + view: EditorView, + from: number, + to: number, + regex: RegExp, + handler: ( + state: EditorState, + match: RegExpExecArray | null, + from?: number, + to?: number + ) => boolean | null +) { + if (view.composing) { + return false; + } + const state = view.state; + const $from = state.doc.resolve(from); + if ($from.parent.type.spec.code) { + return false; + } + + const textBefore = $from.parent.textBetween( + Math.max(0, $from.parentOffset - MAX_MATCH), + $from.parentOffset, + undefined, + "\ufffc" + ); + + const match = regex.exec(textBefore); + const tr = handler(state, match, match ? from - match[0].length : from, to); + if (!tr) return false; + return true; +} + +export default class BlockMenuTrigger extends Extension { + get name() { + return "blockmenu"; + } + + get plugins() { + const button = document.createElement("button"); + button.className = "block-menu-trigger"; + button.type = "button"; + ReactDOM.render(, button); + + 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; + }, + decorations: (state) => { + const parent = findParentNode( + (node) => node.type.name === "paragraph" + )(state.selection); + + if (!parent) { + return; + } + + const decorations: Decoration[] = []; + const isEmpty = parent && parent.node.content.size === 0; + const isSlash = parent && parent.node.textContent === "/"; + const isTopLevel = state.selection.$from.depth === 1; + + if (isTopLevel) { + if (isEmpty) { + decorations.push( + Decoration.widget(parent.pos, () => { + button.addEventListener("click", () => { + this.options.onOpen(""); + }); + return button; + }) + ); + + decorations.push( + Decoration.node( + parent.pos, + parent.pos + parent.node.nodeSize, + { + class: "placeholder", + "data-empty-text": this.options.dictionary.newLineEmpty, + } + ) + ); + } + + if (isSlash) { + decorations.push( + Decoration.node( + parent.pos, + parent.pos + parent.node.nodeSize, + { + class: "placeholder", + "data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`, + } + ) + ); + } + + return DecorationSet.create(state.doc, decorations); + } + + return; + }, + }, + }), + ]; + } + + inputRules() { + return [ + // main regex should match only: + // /word + new InputRule(OPEN_REGEX, (state, match) => { + if ( + match && + state.selection.$from.parent.type.name === "paragraph" && + !isInTable(state) + ) { + this.options.onOpen(match[1]); + } + return null; + }), + // invert regex should match some of these scenarios: + // /word + // / + // /word + new InputRule(CLOSE_REGEX, (state, match) => { + if (match) { + this.options.onClose(); + } + return null; + }), + ]; + } +} diff --git a/shared/editor/plugins/EmojiTrigger.tsx b/shared/editor/plugins/EmojiTrigger.tsx new file mode 100644 index 000000000..99859e140 --- /dev/null +++ b/shared/editor/plugins/EmojiTrigger.tsx @@ -0,0 +1,93 @@ +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: + // :word + // : + // :word + // :) + new InputRule(CLOSE_REGEX, (state, match) => { + if (match) { + this.options.onClose(); + } + return null; + }), + ]; + } +} diff --git a/shared/editor/plugins/Folding.tsx b/shared/editor/plugins/Folding.tsx new file mode 100644 index 000000000..14d60d780 --- /dev/null +++ b/shared/editor/plugins/Folding.tsx @@ -0,0 +1,72 @@ +import { Plugin } from "prosemirror-state"; +import { findBlockNodes } from "prosemirror-utils"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import Extension from "../lib/Extension"; +import { headingToPersistenceKey } from "../lib/headingToSlug"; +import findCollapsedNodes from "../queries/findCollapsedNodes"; + +export default class Folding extends Extension { + get name() { + return "folding"; + } + + get plugins() { + let loaded = false; + + return [ + new Plugin({ + view: (view) => { + loaded = false; + view.dispatch(view.state.tr.setMeta("folding", { loaded: true })); + return {}; + }, + appendTransaction: (transactions, oldState, newState) => { + if (loaded) return; + if ( + !transactions.some((transaction) => transaction.getMeta("folding")) + ) { + return; + } + + let modified = false; + const tr = newState.tr; + const blocks = findBlockNodes(newState.doc); + + for (const block of blocks) { + if (block.node.type.name === "heading") { + const persistKey = headingToPersistenceKey( + block.node, + this.editor.props.id + ); + const persistedState = localStorage?.getItem(persistKey); + + if (persistedState === "collapsed") { + tr.setNodeMarkup(block.pos, undefined, { + ...block.node.attrs, + collapsed: true, + }); + modified = true; + } + } + } + + loaded = true; + return modified ? tr : null; + }, + props: { + decorations: (state) => { + const { doc } = state; + const decorations: Decoration[] = findCollapsedNodes(doc).map( + (block) => + Decoration.node(block.pos, block.pos + block.node.nodeSize, { + class: "folded-content", + }) + ); + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + } +} diff --git a/shared/editor/plugins/History.ts b/shared/editor/plugins/History.ts new file mode 100644 index 000000000..ad0a94435 --- /dev/null +++ b/shared/editor/plugins/History.ts @@ -0,0 +1,22 @@ +import { history, undo, redo } from "prosemirror-history"; +import { undoInputRule } from "prosemirror-inputrules"; +import Extension from "../lib/Extension"; + +export default class History extends Extension { + get name() { + return "history"; + } + + keys() { + return { + "Mod-z": undo, + "Mod-y": redo, + "Shift-Mod-z": redo, + Backspace: undoInputRule, + }; + } + + get plugins() { + return [history()]; + } +} diff --git a/shared/editor/plugins/Keys.ts b/shared/editor/plugins/Keys.ts new file mode 100644 index 000000000..6f15507ba --- /dev/null +++ b/shared/editor/plugins/Keys.ts @@ -0,0 +1,91 @@ +import { GapCursor } from "prosemirror-gapcursor"; +import { + Plugin, + Selection, + AllSelection, + TextSelection, +} from "prosemirror-state"; +import Extension from "../lib/Extension"; +import isModKey from "../lib/isModKey"; + +export default class Keys extends Extension { + get name() { + return "keys"; + } + + get plugins() { + return [ + new Plugin({ + props: { + handleDOMEvents: { + blur: this.options.onBlur, + focus: this.options.onFocus, + }, + // we can't use the keys bindings for this as we want to preventDefault + // on the original keyboard event when handled + handleKeyDown: (view, event) => { + if (view.state.selection instanceof AllSelection) { + if (event.key === "ArrowUp") { + const selection = Selection.atStart(view.state.doc); + view.dispatch(view.state.tr.setSelection(selection)); + return true; + } + if (event.key === "ArrowDown") { + const selection = Selection.atEnd(view.state.doc); + view.dispatch(view.state.tr.setSelection(selection)); + return true; + } + } + + // edge case where horizontal gap cursor does nothing if Enter key + // is pressed. Insert a newline and then move the cursor into it. + if (view.state.selection instanceof GapCursor) { + if (event.key === "Enter") { + view.dispatch( + view.state.tr.insert( + view.state.selection.from, + view.state.schema.nodes.paragraph.create({}) + ) + ); + view.dispatch( + view.state.tr.setSelection( + TextSelection.near( + view.state.doc.resolve(view.state.selection.from), + -1 + ) + ) + ); + return true; + } + } + + // All the following keys require mod to be down + if (!isModKey(event)) { + return false; + } + + if (event.key === "s") { + event.preventDefault(); + this.options.onSave(); + return true; + } + + if (event.key === "Enter") { + event.preventDefault(); + this.options.onSaveAndExit(); + return true; + } + + if (event.key === "Escape") { + event.preventDefault(); + this.options.onCancel(); + return true; + } + + return false; + }, + }, + }), + ]; + } +} diff --git a/shared/editor/plugins/MaxLength.ts b/shared/editor/plugins/MaxLength.ts new file mode 100644 index 000000000..17cf8a4a3 --- /dev/null +++ b/shared/editor/plugins/MaxLength.ts @@ -0,0 +1,23 @@ +import { Plugin, Transaction } from "prosemirror-state"; +import Extension from "../lib/Extension"; + +export default class MaxLength extends Extension { + get name() { + return "maxlength"; + } + + get plugins() { + return [ + new Plugin({ + filterTransaction: (tr: Transaction) => { + if (this.options.maxLength) { + const result = tr.doc && tr.doc.nodeSize > this.options.maxLength; + return !result; + } + + return true; + }, + }), + ]; + } +} diff --git a/shared/editor/plugins/PasteHandler.ts b/shared/editor/plugins/PasteHandler.ts new file mode 100644 index 000000000..1376cddeb --- /dev/null +++ b/shared/editor/plugins/PasteHandler.ts @@ -0,0 +1,152 @@ +import { toggleMark } from "prosemirror-commands"; +import { Plugin } from "prosemirror-state"; +import { isInTable } from "prosemirror-tables"; +import Extension from "../lib/Extension"; +import isMarkdown from "../lib/isMarkdown"; +import isUrl from "../lib/isUrl"; +import selectionIsInCode from "../queries/isInCode"; +import { LANGUAGES } from "./Prism"; + +/** + * Add support for additional syntax that users paste even though it isn't + * supported by the markdown parser directly by massaging the text content. + * + * @param text The incoming pasted plain text + */ +function normalizePastedMarkdown(text: string): string { + // find checkboxes not contained in a list and wrap them in list items + const CHECKBOX_REGEX = /^\s?(\[(X|\s|_|-)\]\s(.*)?)/gim; + + while (text.match(CHECKBOX_REGEX)) { + text = text.replace(CHECKBOX_REGEX, (match) => `- ${match.trim()}`); + } + + return text; +} + +export default class PasteHandler extends Extension { + get name() { + return "markdown-paste"; + } + + get plugins() { + return [ + new Plugin({ + props: { + handlePaste: (view, event: ClipboardEvent) => { + if (view.props.editable && !view.props.editable(view.state)) { + return false; + } + if (!event.clipboardData) return false; + + const text = event.clipboardData.getData("text/plain"); + const html = event.clipboardData.getData("text/html"); + const vscode = event.clipboardData.getData("vscode-editor-data"); + const { state, dispatch } = view; + + // first check if the clipboard contents can be parsed as a single + // url, this is mainly for allowing pasted urls to become embeds + if (isUrl(text)) { + // just paste the link mark directly onto the selected text + if (!state.selection.empty) { + toggleMark(this.editor.schema.marks.link, { href: text })( + state, + dispatch + ); + return true; + } + + // Is this link embeddable? Create an embed! + const { embeds } = this.editor.props; + + if (embeds && !isInTable(state)) { + for (const embed of embeds) { + const matches = embed.matcher(text); + if (matches) { + this.editor.commands.embed({ + href: text, + }); + return true; + } + } + } + + // well, it's not an embed and there is no text selected – so just + // go ahead and insert the link directly + const transaction = view.state.tr + .insertText(text, state.selection.from, state.selection.to) + .addMark( + state.selection.from, + state.selection.to + text.length, + state.schema.marks.link.create({ href: text }) + ); + view.dispatch(transaction); + return true; + } + + // If the users selection is currently in a code block then paste + // as plain text, ignore all formatting and HTML content. + if (selectionIsInCode(view.state)) { + event.preventDefault(); + + view.dispatch(view.state.tr.insertText(text)); + return true; + } + + // Because VSCode is an especially popular editor that places metadata + // on the clipboard, we can parse it to find out what kind of content + // was pasted. + const vscodeMeta = vscode ? JSON.parse(vscode) : undefined; + const pasteCodeLanguage = vscodeMeta?.mode; + + if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") { + event.preventDefault(); + view.dispatch( + view.state.tr + .replaceSelectionWith( + view.state.schema.nodes.code_fence.create({ + language: Object.keys(LANGUAGES).includes(vscodeMeta.mode) + ? vscodeMeta.mode + : null, + }) + ) + .insertText(text) + ); + return true; + } + + // If the HTML on the clipboard is from Prosemirror then the best + // compatability is to just use the HTML parser, regardless of + // whether it "looks" like Markdown, see: outline/outline#2416 + if (html?.includes("data-pm-slice")) { + return false; + } + + // If the text on the clipboard looks like Markdown OR there is no + // html on the clipboard then try to parse content as Markdown + if ( + isMarkdown(text) || + html.length === 0 || + pasteCodeLanguage === "markdown" + ) { + event.preventDefault(); + + const paste = this.editor.pasteParser.parse( + normalizePastedMarkdown(text) + ); + const slice = paste.slice(0); + + const transaction = view.state.tr.replaceSelection(slice); + view.dispatch(transaction); + return true; + } + + // otherwise use the default HTML parser which will handle all paste + // "from the web" events + return false; + }, + }, + }), + ]; + } +} diff --git a/shared/editor/plugins/Placeholder.ts b/shared/editor/plugins/Placeholder.ts new file mode 100644 index 000000000..b6998f4ee --- /dev/null +++ b/shared/editor/plugins/Placeholder.ts @@ -0,0 +1,50 @@ +import { Plugin } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import Extension from "../lib/Extension"; + +export default class Placeholder extends Extension { + get name() { + return "empty-placeholder"; + } + + get defaultOptions() { + return { + emptyNodeClass: "placeholder", + placeholder: "", + }; + } + + get plugins() { + return [ + new Plugin({ + props: { + decorations: (state) => { + const { doc } = state; + const decorations: Decoration[] = []; + const completelyEmpty = + doc.textContent === "" && + doc.childCount <= 1 && + doc.content.size <= 2; + + doc.descendants((node, pos) => { + if (!completelyEmpty) { + return; + } + if (pos !== 0 || node.type.name !== "paragraph") { + return; + } + + const decoration = Decoration.node(pos, pos + node.nodeSize, { + class: this.options.emptyNodeClass, + "data-empty-text": this.options.placeholder, + }); + decorations.push(decoration); + }); + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + } +} diff --git a/shared/editor/plugins/Prism.ts b/shared/editor/plugins/Prism.ts new file mode 100644 index 000000000..3f5a0cff2 --- /dev/null +++ b/shared/editor/plugins/Prism.ts @@ -0,0 +1,151 @@ +import { flattenDeep } from "lodash"; +import { Node } from "prosemirror-model"; +import { Plugin, PluginKey, Transaction } from "prosemirror-state"; +import { findBlockNodes } from "prosemirror-utils"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import refractor from "refractor/core"; + +export const LANGUAGES = { + none: "None", // additional entry to disable highlighting + bash: "Bash", + css: "CSS", + clike: "C", + csharp: "C#", + go: "Go", + markup: "HTML", + objectivec: "Objective-C", + java: "Java", + javascript: "JavaScript", + json: "JSON", + perl: "Perl", + php: "PHP", + powershell: "Powershell", + python: "Python", + ruby: "Ruby", + rust: "Rust", + sql: "SQL", + typescript: "TypeScript", + yaml: "YAML", +}; + +type ParsedNode = { + text: string; + classes: string[]; +}; + +const cache: Record = {}; + +function getDecorations({ doc, name }: { doc: Node; name: string }) { + const decorations: Decoration[] = []; + const blocks: { node: Node; pos: number }[] = findBlockNodes(doc).filter( + (item) => item.node.type.name === name + ); + + function parseNodes( + nodes: refractor.RefractorNode[], + classNames: string[] = [] + ): any { + return nodes.map((node) => { + if (node.type === "element") { + const classes = [...classNames, ...(node.properties.className || [])]; + return parseNodes(node.children, classes); + } + + return { + text: node.value, + classes: classNames, + }; + }); + } + + blocks.forEach((block) => { + let startPos = block.pos + 1; + const language = block.node.attrs.language; + if (!language || language === "none" || !refractor.registered(language)) { + return; + } + + if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) { + const nodes = refractor.highlight(block.node.textContent, language); + const _decorations = flattenDeep(parseNodes(nodes)) + .map((node: ParsedNode) => { + const from = startPos; + const to = from + node.text.length; + + startPos = to; + + return { + ...node, + from, + to, + }; + }) + .filter((node) => node.classes && node.classes.length) + .map((node) => + Decoration.inline(node.from, node.to, { + class: node.classes.join(" "), + }) + ); + + cache[block.pos] = { + node: block.node, + decorations: _decorations, + }; + } + cache[block.pos].decorations.forEach((decoration) => { + decorations.push(decoration); + }); + }); + + Object.keys(cache) + .filter((pos) => !blocks.find((block) => block.pos === Number(pos))) + .forEach((pos) => { + delete cache[Number(pos)]; + }); + + return DecorationSet.create(doc, decorations); +} + +export default function Prism({ name }: { name: string }) { + let highlighted = false; + + return new Plugin({ + key: new PluginKey("prism"), + state: { + init: (_: Plugin, { doc }) => { + return DecorationSet.create(doc, []); + }, + apply: (transaction: Transaction, decorationSet, oldState, state) => { + const nodeName = state.selection.$head.parent.type.name; + const previousNodeName = oldState.selection.$head.parent.type.name; + const codeBlockChanged = + transaction.docChanged && [nodeName, previousNodeName].includes(name); + const ySyncEdit = !!transaction.getMeta("y-sync$"); + + if (!highlighted || codeBlockChanged || ySyncEdit) { + highlighted = true; + return getDecorations({ doc: transaction.doc, name }); + } + + return decorationSet.map(transaction.mapping, transaction.doc); + }, + }, + view: (view) => { + if (!highlighted) { + // we don't highlight code blocks on the first render as part of mounting + // as it's expensive (relative to the rest of the document). Instead let + // it render un-highlighted and then trigger a defered render of Prism + // by updating the plugins metadata + setTimeout(() => { + view.dispatch(view.state.tr.setMeta("prism", { loaded: true })); + }, 10); + } + return {}; + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); +} diff --git a/shared/editor/plugins/SmartText.ts b/shared/editor/plugins/SmartText.ts new file mode 100644 index 000000000..ce1fe917b --- /dev/null +++ b/shared/editor/plugins/SmartText.ts @@ -0,0 +1,14 @@ +import { ellipsis, smartQuotes, InputRule } from "prosemirror-inputrules"; +import Extension from "../lib/Extension"; + +const rightArrow = new InputRule(/->$/, "→"); + +export default class SmartText extends Extension { + get name() { + return "smart_text"; + } + + inputRules() { + return [rightArrow, ellipsis, ...smartQuotes]; + } +} diff --git a/shared/editor/plugins/TrailingNode.ts b/shared/editor/plugins/TrailingNode.ts new file mode 100644 index 000000000..5f255fb9d --- /dev/null +++ b/shared/editor/plugins/TrailingNode.ts @@ -0,0 +1,58 @@ +import { NodeType } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import Extension from "../lib/Extension"; + +export default class TrailingNode extends Extension { + get name() { + return "trailing_node"; + } + + get defaultOptions() { + return { + node: "paragraph", + notAfter: ["paragraph", "heading"], + }; + } + + get plugins() { + const plugin = new PluginKey(this.name); + const disabledNodes = Object.entries(this.editor.schema.nodes) + .map(([, value]) => value) + .filter((node: NodeType) => this.options.notAfter.includes(node.name)); + + return [ + new Plugin({ + key: plugin, + view: () => ({ + update: (view) => { + const { state } = view; + const insertNodeAtEnd = plugin.getState(state); + + if (!insertNodeAtEnd) { + return; + } + + const { doc, schema, tr } = state; + const type = schema.nodes[this.options.node]; + const transaction = tr.insert(doc.content.size, type.create()); + view.dispatch(transaction); + }, + }), + state: { + init: (_, state) => { + const lastNode = state.tr.doc.lastChild; + return lastNode ? !disabledNodes.includes(lastNode.type) : false; + }, + apply: (tr, value) => { + if (!tr.docChanged) { + return value; + } + + const lastNode = tr.doc.lastChild; + return lastNode ? !disabledNodes.includes(lastNode.type) : false; + }, + }, + }), + ]; + } +} diff --git a/shared/editor/queries/findCollapsedNodes.ts b/shared/editor/queries/findCollapsedNodes.ts new file mode 100644 index 000000000..c9191b561 --- /dev/null +++ b/shared/editor/queries/findCollapsedNodes.ts @@ -0,0 +1,33 @@ +import { Node } from "prosemirror-model"; +import { findBlockNodes, NodeWithPos } from "prosemirror-utils"; + +export default function findCollapsedNodes(doc: Node): NodeWithPos[] { + const blocks = findBlockNodes(doc); + const nodes: NodeWithPos[] = []; + + let withinCollapsedHeading; + + for (const block of blocks) { + if (block.node.type.name === "heading") { + if ( + !withinCollapsedHeading || + block.node.attrs.level <= withinCollapsedHeading + ) { + if (block.node.attrs.collapsed) { + if (!withinCollapsedHeading) { + withinCollapsedHeading = block.node.attrs.level; + } + } else { + withinCollapsedHeading = undefined; + } + continue; + } + } + + if (withinCollapsedHeading) { + nodes.push(block); + } + } + + return nodes; +} diff --git a/shared/editor/queries/getColumnIndex.ts b/shared/editor/queries/getColumnIndex.ts new file mode 100644 index 000000000..8b5ffceab --- /dev/null +++ b/shared/editor/queries/getColumnIndex.ts @@ -0,0 +1,9 @@ +import { CellSelection } from "prosemirror-tables"; + +export default function getColumnIndex(selection: CellSelection) { + const isColSelection = selection.isColSelection && selection.isColSelection(); + if (!isColSelection) return undefined; + + const path = (selection.$from as any).path; + return path[path.length - 5]; +} diff --git a/shared/editor/queries/getMarkRange.ts b/shared/editor/queries/getMarkRange.ts new file mode 100644 index 000000000..c5a057304 --- /dev/null +++ b/shared/editor/queries/getMarkRange.ts @@ -0,0 +1,40 @@ +import { ResolvedPos, MarkType } from "prosemirror-model"; + +export default function getMarkRange($pos?: ResolvedPos, type?: MarkType) { + if (!$pos || !type) { + return false; + } + + const start = $pos.parent.childAfter($pos.parentOffset); + if (!start.node) { + return false; + } + + const mark = start.node.marks.find((mark) => mark.type === type); + if (!mark) { + return false; + } + + let startIndex = $pos.index(); + let startPos = $pos.start() + start.offset; + let endIndex = startIndex + 1; + let endPos = startPos + start.node.nodeSize; + + while ( + startIndex > 0 && + mark.isInSet($pos.parent.child(startIndex - 1).marks) + ) { + startIndex -= 1; + startPos -= $pos.parent.child(startIndex).nodeSize; + } + + while ( + endIndex < $pos.parent.childCount && + mark.isInSet($pos.parent.child(endIndex).marks) + ) { + endPos += $pos.parent.child(endIndex).nodeSize; + endIndex += 1; + } + + return { from: startPos, to: endPos, mark }; +} diff --git a/shared/editor/queries/getParentListItem.ts b/shared/editor/queries/getParentListItem.ts new file mode 100644 index 000000000..9ae354ebc --- /dev/null +++ b/shared/editor/queries/getParentListItem.ts @@ -0,0 +1,14 @@ +import { Node } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; + +export default function getParentListItem( + state: EditorState +): [Node, number] | void { + const $head = state.selection.$head; + for (let d = $head.depth; d > 0; d--) { + const node = $head.node(d); + if (["list_item", "checkbox_item"].includes(node.type.name)) { + return [node, $head.before(d)]; + } + } +} diff --git a/shared/editor/queries/getRowIndex.ts b/shared/editor/queries/getRowIndex.ts new file mode 100644 index 000000000..7cab1f3cd --- /dev/null +++ b/shared/editor/queries/getRowIndex.ts @@ -0,0 +1,9 @@ +import { CellSelection } from "prosemirror-tables"; + +export default function getRowIndex(selection: CellSelection) { + const isRowSelection = selection.isRowSelection && selection.isRowSelection(); + if (!isRowSelection) return undefined; + + const path = (selection.$from as any).path; + return path[path.length - 8]; +} diff --git a/shared/editor/queries/isInCode.ts b/shared/editor/queries/isInCode.ts new file mode 100644 index 000000000..329cb7d68 --- /dev/null +++ b/shared/editor/queries/isInCode.ts @@ -0,0 +1,15 @@ +import { EditorState } from "prosemirror-state"; +import isMarkActive from "./isMarkActive"; + +export default function isInCode(state: EditorState): boolean { + if (state.schema.nodes.code_block) { + const $head = state.selection.$head; + for (let d = $head.depth; d > 0; d--) { + if ($head.node(d).type === state.schema.nodes.code_block) { + return true; + } + } + } + + return isMarkActive(state.schema.marks.code_inline)(state); +} diff --git a/shared/editor/queries/isInList.ts b/shared/editor/queries/isInList.ts new file mode 100644 index 000000000..d7c3a3290 --- /dev/null +++ b/shared/editor/queries/isInList.ts @@ -0,0 +1,16 @@ +import { EditorState } from "prosemirror-state"; + +export default function isInList(state: EditorState) { + const $head = state.selection.$head; + for (let d = $head.depth; d > 0; d--) { + if ( + ["ordered_list", "bullet_list", "checkbox_list"].includes( + $head.node(d).type.name + ) + ) { + return true; + } + } + + return false; +} diff --git a/shared/editor/queries/isList.ts b/shared/editor/queries/isList.ts new file mode 100644 index 000000000..703199845 --- /dev/null +++ b/shared/editor/queries/isList.ts @@ -0,0 +1,9 @@ +import { Node, Schema } from "prosemirror-model"; + +export default function isList(node: Node, schema: Schema) { + return ( + node.type === schema.nodes.bullet_list || + node.type === schema.nodes.ordered_list || + node.type === schema.nodes.checkbox_list + ); +} diff --git a/shared/editor/queries/isMarkActive.ts b/shared/editor/queries/isMarkActive.ts new file mode 100644 index 000000000..32df9d322 --- /dev/null +++ b/shared/editor/queries/isMarkActive.ts @@ -0,0 +1,16 @@ +import { MarkType } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; + +const isMarkActive = (type: MarkType) => (state: EditorState): boolean => { + if (!type) { + return false; + } + + const { from, $from, to, empty } = state.selection; + + return !!(empty + ? type.isInSet(state.storedMarks || $from.marks()) + : state.doc.rangeHasMark(from, to, type)); +}; + +export default isMarkActive; diff --git a/shared/editor/queries/isNodeActive.ts b/shared/editor/queries/isNodeActive.ts new file mode 100644 index 000000000..3da1c24e4 --- /dev/null +++ b/shared/editor/queries/isNodeActive.ts @@ -0,0 +1,23 @@ +import { NodeType } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; +import { findParentNode, findSelectedNodeOfType } from "prosemirror-utils"; + +const isNodeActive = (type: NodeType, attrs: Record = {}) => ( + state: EditorState +) => { + if (!type) { + return false; + } + + const node = + findSelectedNodeOfType(type)(state.selection) || + findParentNode((node) => node.type === type)(state.selection); + + if (!Object.keys(attrs).length || !node) { + return !!node; + } + + return node.node.hasMarkup(type, { ...node.node.attrs, ...attrs }); +}; + +export default isNodeActive; diff --git a/shared/editor/rules/breaks.ts b/shared/editor/rules/breaks.ts new file mode 100644 index 000000000..56a406e27 --- /dev/null +++ b/shared/editor/rules/breaks.ts @@ -0,0 +1,56 @@ +import MarkdownIt from "markdown-it"; +import Token from "markdown-it/lib/token"; + +function isHardbreak(token: Token) { + return ( + token.type === "hardbreak" || + (token.type === "text" && token.content === "\\") + ); +} + +export default function markdownBreakToParagraphs(md: MarkdownIt) { + // insert a new rule after the "inline" rules are parsed + md.core.ruler.after("inline", "breaks", (state) => { + const { Token } = state; + const tokens = state.tokens; + + // work backwards through the tokens and find text that looks like a br + for (let i = tokens.length - 1; i > 0; i--) { + const tokenChildren = tokens[i].children || []; + const matches = tokenChildren.filter(isHardbreak); + + if (matches.length) { + let token; + + const nodes: Token[] = []; + const children = tokenChildren.filter((child) => !isHardbreak(child)); + + let count = matches.length; + if (children.length) count++; + + for (let i = 0; i < count; i++) { + const isLast = i === count - 1; + + token = new Token("paragraph_open", "p", 1); + nodes.push(token); + + const text = new Token("text", "", 0); + text.content = ""; + + token = new Token("inline", "", 0); + token.level = 1; + token.children = isLast ? [text, ...children] : [text]; + token.content = ""; + nodes.push(token); + + token = new Token("paragraph_close", "p", -1); + nodes.push(token); + } + + tokens.splice(i - 1, 3, ...nodes); + } + } + + return false; + }); +} diff --git a/shared/editor/rules/checkboxes.ts b/shared/editor/rules/checkboxes.ts new file mode 100644 index 000000000..806861f08 --- /dev/null +++ b/shared/editor/rules/checkboxes.ts @@ -0,0 +1,105 @@ +import MarkdownIt from "markdown-it"; +import Token from "markdown-it/lib/token"; + +const CHECKBOX_REGEX = /\[(X|\s|_|-)\]\s(.*)?/i; + +function matches(token: Token | void) { + return token && token.content.match(CHECKBOX_REGEX); +} + +function isInline(token: Token | void): boolean { + return !!token && token.type === "inline"; +} + +function isParagraph(token: Token | void): boolean { + return !!token && token.type === "paragraph_open"; +} + +function isListItem(token: Token | void): boolean { + return ( + !!token && + (token.type === "list_item_open" || token.type === "checkbox_item_open") + ); +} + +function looksLikeChecklist(tokens: Token[], index: number) { + return ( + isInline(tokens[index]) && + isListItem(tokens[index - 2]) && + isParagraph(tokens[index - 1]) && + matches(tokens[index]) + ); +} + +export default function markdownItCheckbox(md: MarkdownIt): void { + function render(tokens: Token[], idx: number) { + const token = tokens[idx]; + const checked = !!token.attrGet("checked"); + + if (token.nesting === 1) { + // opening tag + return `
  • ${checked ? "[x]" : "[ ]"}`; + } else { + // closing tag + return "
  • \n"; + } + } + + md.renderer.rules.checkbox_item_open = render; + md.renderer.rules.checkbox_item_close = render; + + // insert a new rule after the "inline" rules are parsed + md.core.ruler.after("inline", "checkboxes", (state) => { + const tokens = state.tokens; + + // work backwards through the tokens and find text that looks like a checkbox + for (let i = tokens.length - 1; i > 0; i--) { + const matches = looksLikeChecklist(tokens, i); + if (matches) { + const value = matches[1]; + const checked = value.toLowerCase() === "x"; + + // convert surrounding list tokens + if (tokens[i - 3].type === "bullet_list_open") { + tokens[i - 3].type = "checkbox_list_open"; + } + + if (tokens[i + 3].type === "bullet_list_close") { + tokens[i + 3].type = "checkbox_list_close"; + } + + // remove [ ] [x] from list item label – must use the content from the + // child for escaped characters to be unescaped correctly. + const tokenChildren = tokens[i].children; + if (tokenChildren) { + const contentMatches = tokenChildren[0].content.match(CHECKBOX_REGEX); + + if (contentMatches) { + const label = contentMatches[2]; + + tokens[i].content = label; + tokenChildren[0].content = label; + } + } + + // open list item and ensure checked state is transferred + tokens[i - 2].type = "checkbox_item_open"; + + if (checked === true) { + tokens[i - 2].attrs = [["checked", "true"]]; + } + + // close the list item + let j = i; + while (tokens[j].type !== "list_item_close") { + j++; + } + tokens[j].type = "checkbox_item_close"; + } + } + + return false; + }); +} diff --git a/shared/editor/rules/embeds.ts b/shared/editor/rules/embeds.ts new file mode 100644 index 000000000..923d70653 --- /dev/null +++ b/shared/editor/rules/embeds.ts @@ -0,0 +1,91 @@ +import MarkdownIt from "markdown-it"; +import Token from "markdown-it/lib/token"; +import { EmbedDescriptor } from "../types"; + +function isParagraph(token: Token) { + return token.type === "paragraph_open"; +} + +function isInline(token: Token) { + return token.type === "inline" && token.level === 1; +} + +function isLinkOpen(token: Token) { + return token.type === "link_open"; +} + +function isLinkClose(token: Token) { + return token.type === "link_close"; +} + +export default function (embeds: EmbedDescriptor[]) { + function isEmbed(token: Token, link: Token) { + const href = link.attrs ? link.attrs[0][1] : ""; + const simpleLink = href === token.content; + + if (!simpleLink) return false; + if (!embeds) return false; + + for (const embed of embeds) { + const matches = embed.matcher(href); + if (matches) { + return { + ...embed, + matches, + }; + } + } + + return false; + } + + return function markdownEmbeds(md: MarkdownIt) { + md.core.ruler.after("inline", "embeds", (state) => { + const tokens = state.tokens; + let insideLink; + + for (let i = 0; i < tokens.length - 1; i++) { + // once we find an inline token look through it's children for links + if (isInline(tokens[i]) && isParagraph(tokens[i - 1])) { + const tokenChildren = tokens[i].children || []; + + for (let j = 0; j < tokenChildren.length - 1; j++) { + const current = tokenChildren[j]; + if (!current) continue; + + if (isLinkOpen(current)) { + insideLink = current; + continue; + } + + if (isLinkClose(current)) { + insideLink = null; + continue; + } + + // of hey, we found a link – lets check to see if it should be + // considered to be an embed + if (insideLink) { + const result = isEmbed(current, insideLink); + if (result) { + const { content } = current; + + // convert to embed token + const token = new Token("embed", "iframe", 0); + token.attrSet("href", content); + + // delete the inline link – this makes the assumption that the + // embed is the only thing in the para. + // TODO: double check this + tokens.splice(i - 1, 3, token); + break; + } + } + } + } + } + + return false; + }); + }; +} diff --git a/shared/editor/rules/emoji.ts b/shared/editor/rules/emoji.ts new file mode 100644 index 000000000..faa73c10d --- /dev/null +++ b/shared/editor/rules/emoji.ts @@ -0,0 +1,10 @@ +import nameToEmoji from "gemoji/name-to-emoji.json"; +import MarkdownIt from "markdown-it"; +import emojiPlugin from "markdown-it-emoji"; + +export default function emoji(md: MarkdownIt) { + return emojiPlugin(md, { + defs: nameToEmoji, + shortcuts: {}, + }); +} diff --git a/shared/editor/rules/mark.ts b/shared/editor/rules/mark.ts new file mode 100644 index 000000000..57a010259 --- /dev/null +++ b/shared/editor/rules/mark.ts @@ -0,0 +1,156 @@ +// Adapted from: +// https://github.com/markdown-it/markdown-it-mark/blob/master/index.js + +import MarkdownIt from "markdown-it"; +import StateInline from "markdown-it/lib/rules_inline/state_inline"; + +export default function (options: { delim: string; mark: string }) { + const delimCharCode = options.delim.charCodeAt(0); + + return function emphasisPlugin(md: MarkdownIt) { + function tokenize(state: StateInline, silent: boolean) { + let i, token; + + const start = state.pos, + marker = state.src.charCodeAt(start); + + if (silent) { + return false; + } + + if (marker !== delimCharCode) { + return false; + } + + const scanned = state.scanDelims(state.pos, true); + const ch = String.fromCharCode(marker); + let len = scanned.length; + + if (len < 2) { + return false; + } + + if (len % 2) { + token = state.push("text", "", 0); + token.content = ch; + len--; + } + + for (i = 0; i < len; i += 2) { + token = state.push("text", "", 0); + token.content = ch + ch; + + if (!scanned.can_open && !scanned.can_close) { + continue; + } + + state.delimiters.push({ + marker, + length: 0, // disable "rule of 3" length checks meant for emphasis + jump: i, + token: state.tokens.length - 1, + end: -1, + open: scanned.can_open, + close: scanned.can_close, + }); + } + + state.pos += scanned.length; + return true; + } + + // Walk through delimiter list and replace text tokens with tags + // + function postProcess( + state: StateInline, + delimiters: StateInline.Delimiter[] + ) { + let i = 0, + j, + startDelim, + endDelim, + token; + const loneMarkers: number[] = [], + max = delimiters.length; + + for (i = 0; i < max; i++) { + startDelim = delimiters[i]; + + if (startDelim.marker !== delimCharCode) { + continue; + } + + if (startDelim.end === -1) { + continue; + } + + endDelim = delimiters[startDelim.end]; + + token = state.tokens[startDelim.token]; + token.type = `${options.mark}_open`; + token.tag = "span"; + token.attrs = [["class", options.mark]]; + token.nesting = 1; + token.markup = options.delim; + token.content = ""; + + token = state.tokens[endDelim.token]; + token.type = `${options.mark}_close`; + token.tag = "span"; + token.nesting = -1; + token.markup = options.delim; + token.content = ""; + + if ( + state.tokens[endDelim.token - 1].type === "text" && + state.tokens[endDelim.token - 1].content === options.delim[0] + ) { + loneMarkers.push(endDelim.token - 1); + } + } + + // If a marker sequence has an odd number of characters, it's split + // like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the + // start of the sequence. + // + // So, we have to move all those markers after subsequent s_close tags. + while (loneMarkers.length) { + i = loneMarkers.pop() as number; + j = i + 1; + + while ( + j < state.tokens.length && + state.tokens[j].type === `${options.mark}_close` + ) { + j++; + } + + j--; + + if (i !== j) { + token = state.tokens[j]; + state.tokens[j] = state.tokens[i]; + state.tokens[i] = token; + } + } + } + + md.inline.ruler.before("emphasis", options.mark, tokenize); + md.inline.ruler2.before("emphasis", options.mark, function (state) { + let curr; + const tokensMeta = state.tokens_meta, + max = (state.tokens_meta || []).length; + + postProcess(state, state.delimiters); + + for (curr = 0; curr < max; curr++) { + const delimiters = tokensMeta[curr]?.delimiters; + if (tokensMeta[curr] && delimiters) { + postProcess(state, delimiters); + } + } + + return false; + }); + }; +} diff --git a/shared/editor/rules/notices.ts b/shared/editor/rules/notices.ts new file mode 100644 index 000000000..db2942f57 --- /dev/null +++ b/shared/editor/rules/notices.ts @@ -0,0 +1,21 @@ +import MarkdownIt from "markdown-it"; +import customFence from "markdown-it-container"; +import Token from "markdown-it/lib/token"; + +export default function notice(md: MarkdownIt): void { + return customFence(md, "notice", { + marker: ":", + validate: () => true, + render: function (tokens: Token[], idx: number) { + const { info } = tokens[idx]; + + if (tokens[idx].nesting === 1) { + // opening tag + return `
    \n`; + } else { + // closing tag + return "
    \n"; + } + }, + }); +} diff --git a/shared/editor/rules/tables.ts b/shared/editor/rules/tables.ts new file mode 100644 index 000000000..cf86772b6 --- /dev/null +++ b/shared/editor/rules/tables.ts @@ -0,0 +1,76 @@ +import MarkdownIt from "markdown-it"; +import Token from "markdown-it/lib/token"; + +const BREAK_REGEX = /(?:^|[^\\])\\n/; + +export default function markdownTables(md: MarkdownIt): void { + // insert a new rule after the "inline" rules are parsed + md.core.ruler.after("inline", "tables-pm", (state) => { + const tokens = state.tokens; + let inside = false; + + for (let i = tokens.length - 1; i > 0; i--) { + if (inside) { + tokens[i].level--; + } + + // convert unescaped \n in the text into real br tag + if (tokens[i].type === "inline" && tokens[i].content.match(BREAK_REGEX)) { + const existing = tokens[i].children || []; + tokens[i].children = []; + + existing.forEach((child) => { + const breakParts = child.content.split(BREAK_REGEX); + + // a schema agnostic way to know if a node is inline code would be + // great, for now we are stuck checking the node type. + if (breakParts.length > 1 && child.type !== "code_inline") { + breakParts.forEach((part, index) => { + const token = new Token("text", "", 1); + token.content = part.trim(); + tokens[i].children?.push(token); + + if (index < breakParts.length - 1) { + const brToken = new Token("br", "br", 1); + tokens[i].children?.push(brToken); + } + }); + } else { + tokens[i].children?.push(child); + } + }); + } + + // filter out incompatible tokens from markdown-it that we don't need + // in prosemirror. thead/tbody do nothing. + if ( + ["thead_open", "thead_close", "tbody_open", "tbody_close"].includes( + tokens[i].type + ) + ) { + inside = !inside; + tokens.splice(i, 1); + } + + if (["th_open", "td_open"].includes(tokens[i].type)) { + // markdown-it table parser does not return paragraphs inside the cells + // but prosemirror requires them, so we add 'em in here. + tokens.splice(i + 1, 0, new Token("paragraph_open", "p", 1)); + + // markdown-it table parser stores alignment as html styles, convert + // to a simple string here + const tokenAttrs = tokens[i].attrs; + if (tokenAttrs) { + const style = tokenAttrs[0][1]; + tokens[i].info = style.split(":")[1]; + } + } + + if (["th_close", "td_close"].includes(tokens[i].type)) { + tokens.splice(i, 0, new Token("paragraph_close", "p", -1)); + } + } + + return false; + }); +} diff --git a/shared/editor/rules/underlines.ts b/shared/editor/rules/underlines.ts new file mode 100644 index 000000000..762155927 --- /dev/null +++ b/shared/editor/rules/underlines.ts @@ -0,0 +1,24 @@ +import MarkdownIt from "markdown-it"; + +export default function markdownUnderlines(md: MarkdownIt) { + md.inline.ruler2.after("emphasis", "underline", (state) => { + const tokens = state.tokens; + + for (let i = tokens.length - 1; i > 0; i--) { + const token = tokens[i]; + + if (token.markup === "__") { + if (token.type === "strong_open") { + tokens[i].tag = "underline"; + tokens[i].type = "underline_open"; + } + if (token.type === "strong_close") { + tokens[i].tag = "underline"; + tokens[i].type = "underline_close"; + } + } + } + + return false; + }); +} diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts new file mode 100644 index 000000000..2fd2f853b --- /dev/null +++ b/shared/editor/types/index.ts @@ -0,0 +1,35 @@ +import { Node as ProsemirrorNode } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; +import * as React from "react"; +import { DefaultTheme } from "styled-components"; + +export enum ToastType { + Error = "error", + Info = "info", +} + +export type MenuItem = { + icon?: typeof React.Component | React.FC; + name?: string; + title?: string; + shortcut?: string; + keywords?: string; + tooltip?: string; + defaultHidden?: boolean; + attrs?: Record; + visible?: boolean; + active?: (state: EditorState) => boolean; +}; + +export type EmbedDescriptor = MenuItem & { + matcher: (url: string) => boolean | [] | RegExpMatchArray; + component: typeof React.Component | React.FC; +}; + +export type ComponentProps = { + theme: DefaultTheme; + node: ProsemirrorNode; + isSelected: boolean; + isEditable: boolean; + getPos: () => number; +}; diff --git a/shared/editor/version.ts b/shared/editor/version.ts new file mode 100644 index 000000000..215a3e5a7 --- /dev/null +++ b/shared/editor/version.ts @@ -0,0 +1,3 @@ +const EDITOR_VERSION = "11.21.3"; + +export default EDITOR_VERSION; diff --git a/shared/theme.ts b/shared/theme.ts index b80ef5df6..b0abcff43 100644 --- a/shared/theme.ts +++ b/shared/theme.ts @@ -64,6 +64,7 @@ export const base = { buttonText: colors.white, textHighlight: "#FDEA9B", textHighlightForeground: colors.almostBlack, + code: colors.lightBlack, codeComment: "#6a737d", codePunctuation: "#5e6687", codeNumber: "#d73a49", @@ -154,6 +155,7 @@ export const light = { tableDivider: colors.smokeDark, tableSelected: colors.primary, tableSelectedBackground: "#E5F7FF", + tableHeaderBackground: colors.white, buttonNeutralBackground: colors.white, buttonNeutralText: colors.almostBlack, buttonNeutralBorder: darken(0.15, colors.white), @@ -203,6 +205,7 @@ export const dark = { tableDivider: colors.lightBlack, tableSelected: colors.primary, tableSelectedBackground: "#002333", + tableHeaderBackground: colors.almostBlack, buttonNeutralBackground: colors.almostBlack, buttonNeutralText: colors.white, buttonNeutralBorder: colors.slateDark, @@ -211,6 +214,7 @@ export const dark = { toastBackground: colors.white, toastText: colors.lightBlack, quote: colors.almostWhite, + code: colors.almostWhite, codeBackground: colors.black, codeBorder: colors.black50, codeString: "#3d8fd1", diff --git a/shared/typings/gemoji.d.ts b/shared/typings/gemoji.d.ts new file mode 100644 index 000000000..4db190daf --- /dev/null +++ b/shared/typings/gemoji.d.ts @@ -0,0 +1 @@ +declare module "gemoji"; diff --git a/shared/typings/markdown-it-mark.d.ts b/shared/typings/markdown-it-mark.d.ts new file mode 100644 index 000000000..799c9f46c --- /dev/null +++ b/shared/typings/markdown-it-mark.d.ts @@ -0,0 +1,5 @@ +declare module "markdown-it-mark" { + function plugin(md: any): void; + + export = plugin; +} diff --git a/shared/typings/prosemirror-model.d.ts b/shared/typings/prosemirror-model.d.ts new file mode 100644 index 000000000..faeab867d --- /dev/null +++ b/shared/typings/prosemirror-model.d.ts @@ -0,0 +1,10 @@ +import "prosemirror-model"; + +declare module "prosemirror-model" { + interface Slice { + // this method is missing in the DefinitelyTyped type definition, so we + // must patch it here. + // https://github.com/ProseMirror/prosemirror-model/blob/bd13a2329fda39f1c4d09abd8f0db2032bdc8014/src/replace.js#L51 + removeBetween(from: number, to: number): Slice; + } +} diff --git a/app/utils/getDataTransferFiles.ts b/shared/utils/getDataTransferFiles.ts similarity index 100% rename from app/utils/getDataTransferFiles.ts rename to shared/utils/getDataTransferFiles.ts diff --git a/webpack.config.js b/webpack.config.js index 956e24477..17acdbe95 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,7 +3,6 @@ const path = require('path'); const webpack = require('webpack'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const { RelativeCiAgentWebpackPlugin } = require('@relative-ci/agent'); -const pkg = require("rich-markdown-editor/package.json"); const WebpackPwaManifest = require("webpack-pwa-manifest"); const WorkboxPlugin = require("workbox-webpack-plugin"); @@ -57,9 +56,6 @@ module.exports = { } }, plugins: [ - new webpack.DefinePlugin({ - EDITOR_VERSION: JSON.stringify(pkg.version) - }), new webpack.IgnorePlugin(/unicode\/category\/So/), new HtmlWebpackPlugin({ template: 'server/static/index.html', diff --git a/yarn.lock b/yarn.lock index 37ce3c2d3..4eb74db99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2705,6 +2705,11 @@ dependencies: "@types/node" "*" +"@types/fuzzy-search@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@types/fuzzy-search/-/fuzzy-search-2.1.2.tgz#d57b2af8fb723baa1792d40d511f431c4c8f75af" + integrity sha512-YOqA50Z3xcycm4Br5+MBUpSumfdOAcv34A8A8yFn62zBQPTzJSXQk11qYE5w8BWQ0KrVThXUgEQh7ZLrYI1NaQ== + "@types/google.analytics@^0.0.42": version "0.0.42" resolved "https://registry.yarnpkg.com/@types/google.analytics/-/google.analytics-0.0.42.tgz#efe6ef9251a22ec8208dbb09f221a48a1863d720" @@ -2718,9 +2723,9 @@ "@types/node" "*" "@types/hast@^2.0.0": - version "2.3.1" - resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.1.tgz#b16872f2a6144c7025f296fb9636a667ebb79cd9" - integrity sha512-viwwrB+6xGzw+G1eWpF9geV3fnsDgXqHG+cqgiHrvQfDUW5hzhCyV7Sy3UJxhfRFBsgky2SSW33qi/YrIkjX5Q== + version "2.3.4" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc" + integrity sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g== dependencies: "@types/unist" "*" @@ -2791,11 +2796,6 @@ jest-diff "^27.0.0" pretty-format "^27.0.0" -"@types/js-search@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@types/js-search/-/js-search-1.4.0.tgz#f2d4afa176a4fc7b17fb46a1593847887fa1fb7b" - integrity sha1-8tSvoXak/HsX+0ahWThHiH+h+3s= - "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.9": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" @@ -2920,6 +2920,20 @@ resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== +"@types/markdown-it-container@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/markdown-it-container/-/markdown-it-container-2.0.4.tgz#da15919befbdce2fe2ff0a472f68d31180345424" + integrity sha512-QgzDCr8OWtWktWtlwPT908sKqZqSHUEaxTH/uVz68tYd6bsCS3defHLzN2rFeoKJ3q344qG0dWQ42K4UQSBqcw== + dependencies: + "@types/markdown-it" "*" + +"@types/markdown-it-emoji@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/markdown-it-emoji/-/markdown-it-emoji-2.0.2.tgz#f12a97df2758f38b4b38f277b468780459faff14" + integrity sha512-2ln8Wjbcj/0oRi/6VnuMeWEHHuK8uapFttvcLmDIe1GKCsFBLOLBX+D+xhDa9oWOQV0IpvxwrSfKKssAqqroog== + dependencies: + "@types/markdown-it" "*" + "@types/markdown-it@*", "@types/markdown-it@^12.2.3": version "12.2.3" resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.2.3.tgz#0d6f6e5e413f8daaa26522904597be3d6cd93b51" @@ -3018,12 +3032,17 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.2.tgz#4c62fae93eb479660c3bd93f9d24d561597a8281" integrity sha512-ekoj4qOQYp7CvjX8ZDBgN86w3MqQhLE1hczEJbEIjgFEumDy+na/4AJAbLXfgEWFNB2pKadM5rPFtuSGMWK7xA== +"@types/prismjs@*": + version "1.16.6" + resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.16.6.tgz#377054f72f671b36dbe78c517ce2b279d83ecc40" + integrity sha512-dTvnamRITNqNkqhlBd235kZl3KfVJQQoT5jkXeiWSBK7i4/TLKBNLV0S1wOt8gy4E2TY722KLtdmv2xc6+Wevg== + "@types/prop-types@*": version "15.7.4" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== -"@types/prosemirror-commands@*": +"@types/prosemirror-commands@*", "@types/prosemirror-commands@^1.0.1": version "1.0.4" resolved "https://registry.yarnpkg.com/@types/prosemirror-commands/-/prosemirror-commands-1.0.4.tgz#d08551415127d93ae62e7239d30db0b5e7208e22" integrity sha512-utDNYB3EXLjAfYIcRWJe6pn3kcQ5kG4RijbT/0Y/TFOm6yhvYS/D9eJVnijdg9LDjykapcezchxGRqFD5LcyaQ== @@ -3032,7 +3051,30 @@ "@types/prosemirror-state" "*" "@types/prosemirror-view" "*" -"@types/prosemirror-inputrules@^1.0.4": +"@types/prosemirror-dropcursor@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@types/prosemirror-dropcursor/-/prosemirror-dropcursor-1.0.3.tgz#49250849b8a0b86e8c29eb1ba70a463e53e46947" + integrity sha512-b0/8njnJ4lwyHKcGuCMf3x7r1KjxyugB1R/c2iMCjplsJHSC7UY9+OysqgJR5uUXRekUSGniiLgBtac/lvH6wg== + dependencies: + "@types/prosemirror-state" "*" + +"@types/prosemirror-gapcursor@^1.0.1": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@types/prosemirror-gapcursor/-/prosemirror-gapcursor-1.0.4.tgz#7df7d373edb33ea8da12084bfd462cf84cd69761" + integrity sha512-9xKjFIG5947dzerFvkLWp6F53JwrUYoYwh3SgcTFEp8SbSfNNrez/PFYVZKPnoqPoaK5WtTdQTaMwpCV9rXQIg== + dependencies: + "@types/prosemirror-model" "*" + "@types/prosemirror-state" "*" + +"@types/prosemirror-history@^1.0.1": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@types/prosemirror-history/-/prosemirror-history-1.0.3.tgz#f1110efbe758129b5475e466ff077f0a8d9b964f" + integrity sha512-5TloMDRavgLjOAKXp1Li8u0xcsspzbT1Cm9F2pwHOkgvQOz1jWQb2VIXO7RVNsFjLBZdIXlyfSLivro3DuMWXg== + dependencies: + "@types/prosemirror-model" "*" + "@types/prosemirror-state" "*" + +"@types/prosemirror-inputrules@^1.0.2": version "1.0.4" resolved "https://registry.yarnpkg.com/@types/prosemirror-inputrules/-/prosemirror-inputrules-1.0.4.tgz#4cb75054d954aa0f6f42099be05eb6c0e6958bae" integrity sha512-lJIMpOjO47SYozQybUkpV6QmfuQt7GZKHtVrvS+mR5UekA8NMC5HRIVMyaIauJLWhKU6oaNjpVaXdw41kh165g== @@ -3040,7 +3082,7 @@ "@types/prosemirror-model" "*" "@types/prosemirror-state" "*" -"@types/prosemirror-keymap@^1.0.4": +"@types/prosemirror-keymap@^1.0.1": version "1.0.4" resolved "https://registry.yarnpkg.com/@types/prosemirror-keymap/-/prosemirror-keymap-1.0.4.tgz#f73c79810e8d0e0a20d153d84f998f02e5afbc0c" integrity sha512-ycevwkqUh+jEQtPwqO7sWGcm+Sybmhu8MpBsM8DlO3+YTKnXbKA6SDz/+q14q1wK3UA8lHJyfR+v+GPxfUSemg== @@ -3050,22 +3092,31 @@ "@types/prosemirror-state" "*" "@types/prosemirror-view" "*" -"@types/prosemirror-markdown@^1.5.3": - version "1.5.3" - resolved "https://registry.yarnpkg.com/@types/prosemirror-markdown/-/prosemirror-markdown-1.5.3.tgz#b16043debd077fb6bfa3de0082f46f8229564423" - integrity sha512-TmrOXvu+IXcmbdsHVZk062tYl0jhlJsTDO2l4O84GFmR1CAY3MyNnVlVj3jkJ+iWhJzEjfnNc/UxGSPXBjoN8g== +"@types/prosemirror-markdown@^1.0.3": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@types/prosemirror-markdown/-/prosemirror-markdown-1.5.5.tgz#f3cbc27398d1b606c45b2a02354dbfc49928c466" + integrity sha512-1a97EHWU0uuNHw8zaRaMXHSUQGd/jUl2wCcaBtMNvMwgdVUx1hg6A7hbs032Aiwq/nQfaC360/KwiFy0nMTkzA== dependencies: "@types/markdown-it" "*" "@types/prosemirror-model" "*" -"@types/prosemirror-model@*", "@types/prosemirror-model@^1.13.2": - version "1.13.2" - resolved "https://registry.yarnpkg.com/@types/prosemirror-model/-/prosemirror-model-1.13.2.tgz#2adad3ec478f83204f155d7fb94c9dfde2fc3296" - integrity sha512-a2rDB0aZ+7aIP7uBqQq1wLb4Hg4qqEvpkCqvhsgT/gG8IWC0peCAZfQ24sgTco0qSJLeDgIbtPeU6mgr869/kg== +"@types/prosemirror-model@*", "@types/prosemirror-model@^1.7.2": + version "1.16.0" + resolved "https://registry.yarnpkg.com/@types/prosemirror-model/-/prosemirror-model-1.16.0.tgz#8b22c7431a4c93f7f550fc89c4b0e2d44d42c8b6" + integrity sha512-nv93YLyTEcDDl17OB90EldxZjyJQJll2WSMLDvLzTewbpvE/vtMjHT3j4mik3uSzQ6YD486AcloCO3WODY/lDg== dependencies: "@types/orderedmap" "*" -"@types/prosemirror-state@*", "@types/prosemirror-state@^1.2.8": +"@types/prosemirror-schema-list@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@types/prosemirror-schema-list/-/prosemirror-schema-list-1.0.3.tgz#bdf1893a7915fbdc5c49b3cac9368e96213d70de" + integrity sha512-uWybOf+M2Ea7rlbs0yLsS4YJYNGXYtn4N+w8HCw3Vvfl6wBAROzlMt0gV/D/VW/7J/LlAjwMezuGe8xi24HzXA== + dependencies: + "@types/orderedmap" "*" + "@types/prosemirror-model" "*" + "@types/prosemirror-state" "*" + +"@types/prosemirror-state@*", "@types/prosemirror-state@^1.2.4": version "1.2.8" resolved "https://registry.yarnpkg.com/@types/prosemirror-state/-/prosemirror-state-1.2.8.tgz#65080eeec52f63c50bf7034377f07773b4f6b2ac" integrity sha512-mq9uyQWcpu8jeamO6Callrdvf/e1H/aRLR2kZWSpZrPHctEsxWHBbluD/wqVjXBRIOoMHLf6ZvOkrkmGLoCHVA== @@ -3081,10 +3132,10 @@ dependencies: "@types/prosemirror-model" "*" -"@types/prosemirror-view@*", "@types/prosemirror-view@^1.19.1": - version "1.19.1" - resolved "https://registry.yarnpkg.com/@types/prosemirror-view/-/prosemirror-view-1.19.1.tgz#f12309ef07dfb701d20c2e4d0292d42ba34a081b" - integrity sha512-fyQ4NVxAdfISWrE2qT8cpZdosXoH/1JuVYMBs9CdaXPbvi/8R2L2tkkcMRM314piKrO8nfYH5OBZKzP2Ax3jtA== +"@types/prosemirror-view@*", "@types/prosemirror-view@^1.11.4": + version "1.19.2" + resolved "https://registry.yarnpkg.com/@types/prosemirror-view/-/prosemirror-view-1.19.2.tgz#1bab4daf0f1f14313fe0d3f6b57f0a3b4ef6c50d" + integrity sha512-pmh2DuMJzva4D7SxspRKIzkV6FK2o52uAqGjq2dPYcQFPwu4+5RcS1TMjFVCh1R+Ia1Rx8wsCNIId/5+6DB0Bg== dependencies: "@types/prosemirror-model" "*" "@types/prosemirror-state" "*" @@ -3207,6 +3258,13 @@ dependencies: "@types/react" "*" +"@types/refractor@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/refractor/-/refractor-3.0.2.tgz#2d42128d59f78f84d2c799ffc5ab5cadbcba2d82" + integrity sha512-2HMXuwGuOqzUG+KUTm9GDJCHl0LCBKsB5cg28ujEmVi/0qgTb6jOmkVSO5K48qXksyl2Fr3C0Q2VrgD4zbwyXg== + dependencies: + "@types/prismjs" "*" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -3334,9 +3392,9 @@ source-map "^0.6.1" "@types/unist@*": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" - integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" + integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== "@types/utf8@^3.0.0": version "3.0.0" @@ -5101,15 +5159,6 @@ cli-color@^2.0.0: memoizee "^0.4.15" timers-ext "^0.1.7" -clipboard@^2.0.0: - version "2.0.6" - resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.6.tgz#52921296eec0fdf77ead1749421b21c968647376" - integrity sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg== - dependencies: - good-listener "^1.2.2" - select "^1.1.2" - tiny-emitter "^2.0.0" - cliui@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" @@ -5334,10 +5383,10 @@ compute-scroll-into-view@1.0.14: resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.14.tgz#80e3ebb25d6aa89f42e533956cb4b16a04cfe759" integrity sha512-mKDjINe3tc6hGelUMNDzuhorIUZ7kS7BwyY0r2wQd2HOH2tRuJykiC06iSEX8y1TuhNzvz4GcJnK16mM2J1NMQ== -compute-scroll-into-view@^1.0.16: - version "1.0.16" - resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" - integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ== +compute-scroll-into-view@^1.0.17: + version "1.0.17" + resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab" + integrity sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg== concat-map@0.0.1: version "0.0.1" @@ -5484,7 +5533,7 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -copy-to-clipboard@3.3.1, copy-to-clipboard@^3.0.8, copy-to-clipboard@^3.3.1: +copy-to-clipboard@3.3.1, copy-to-clipboard@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae" integrity sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw== @@ -5912,11 +5961,6 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -delegate@^3.1.2: - version "3.2.0" - resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" - integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== - delegates@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" @@ -6375,11 +6419,6 @@ entities@^2.0.0, entities@~2.1.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== -entities@~2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" - integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== - env-ci@^5.3.3: version "5.4.1" resolved "https://registry.yarnpkg.com/env-ci/-/env-ci-5.4.1.tgz#814387ddd6857b37472ef612361f34d720c29a18" @@ -7670,13 +7709,6 @@ globby@^11.0.4: merge2 "^1.3.0" slash "^3.0.0" -good-listener@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" - integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= - dependencies: - delegate "^3.1.2" - google-closure-compiler-js@^20170423.0.0: version "20170423.0.0" resolved "https://registry.yarnpkg.com/google-closure-compiler-js/-/google-closure-compiler-js-20170423.0.0.tgz#e9e8b40dadfdf0e64044c9479b5d26d228778fbc" @@ -9329,11 +9361,6 @@ js-beautify@^1.6.12, js-beautify@^1.8.8: glob "^7.1.3" nopt "^5.0.0" -js-search@^1.4.2: - version "1.4.3" - resolved "https://registry.yarnpkg.com/js-search/-/js-search-1.4.3.tgz#23a86d7e064ca53a473930edc48615b6b1c1954a" - integrity sha512-Sny5pf00kX1sM1KzvUC9nGYWXOvBfy30rmvZWeRktpg+esQKedIXrXNee/I2CAnsouCyaTjitZpRflDACx4toA== - "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -9893,17 +9920,10 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= -linkify-it@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf" - integrity sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw== - dependencies: - uc.micro "^1.0.1" - linkify-it@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.2.tgz#f55eeb8bc1d3ae754049e124ab3bb56d97797fb8" - integrity sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ== + version "3.0.3" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.3.tgz#a98baf44ce45a550efb4d49c769d07524cc2fa2e" + integrity sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ== dependencies: uc.micro "^1.0.1" @@ -10214,21 +10234,10 @@ markdown-it-emoji@^2.0.0: resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-2.0.0.tgz#3164ad4c009efd946e98274f7562ad611089a231" integrity sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ== -markdown-it@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc" - integrity sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg== - dependencies: - argparse "^1.0.7" - entities "~2.0.0" - linkify-it "^2.0.0" - mdurl "^1.0.1" - uc.micro "^1.0.5" - -markdown-it@^12.2.0: - version "12.2.0" - resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.2.0.tgz#091f720fd5db206f80de7a8d1f1a7035fd0d38db" - integrity sha512-Wjws+uCrVQRqOoJvze4HCqkKl1AsSh95iFAeQDwnyfxM09divCBSXlDR1uTvyUP3Grzpn4Ru8GeCxYPM8vkCQg== +markdown-it@^12.0.0, markdown-it@^12.3.2: + version "12.3.2" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.3.2.tgz#bf92ac92283fe983fe4de8ff8abfb5ad72cd0c90" + integrity sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg== dependencies: argparse "^2.0.1" entities "~2.1.0" @@ -11025,7 +11034,7 @@ os-browserify@^0.3.0: resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= -outline-icons@^1.38.1, outline-icons@^1.39.0: +outline-icons@^1.39.0: version "1.39.0" resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.39.0.tgz#d7b1ecf5d3d2803b4e0defa86300e397f7c4ac2e" integrity sha512-5TpVKHhGYzqeLJvY+FEP+Atn7pEMPM3Rkv6iezb2yZkPoQx5Tbnla1jUdE2aCSzeFZws+mLm17hzANcoRW2sYQ== @@ -11676,12 +11685,10 @@ pretty@^2.0.0: extend-shallow "^2.0.1" js-beautify "^1.6.12" -prismjs@~1.23.0: - version "1.23.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" - integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== - optionalDependencies: - clipboard "^2.0.0" +prismjs@~1.25.0: + version "1.25.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756" + integrity sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg== process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: version "2.0.1" @@ -11756,27 +11763,27 @@ property-information@^5.0.0: xtend "^4.0.0" prosemirror-commands@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.1.6.tgz#727c045eb60e7d61f1b1d0a9411a7f60286bb1de" - integrity sha512-oh3DN9qhtYR+4/Yfb9+hAKjiV4pEqPRxsPqUeoVqJP7Dfbk4jQKYZFDKMZgLAdSFi49fMeiVYieN3fpjOLuP0Q== + version "1.1.12" + resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.1.12.tgz#5cb0fef4e5a0039e2fa19b42a5626af03d7c2ec3" + integrity sha512-+CrMs3w/ZVPSkR+REg8KL/clyFLv/1+SgY/OMN+CB22Z24j9TZDje72vL36lOZ/E4NeRXuiCcmENcW/vAcG67A== dependencies: prosemirror-model "^1.0.0" prosemirror-state "^1.0.0" prosemirror-transform "^1.0.0" prosemirror-dropcursor@^1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.3.3.tgz#583d6a82b4960d468132c07c40803cc1d697fda4" - integrity sha512-zavE+wh+qkDcP7LaWn+jXVujGyQzBYSmM8E2HSngQ3KaaR+HJYgEBYGs9ynLHqKWLlLCXsxWdVYPV49v4caFyg== + version "1.4.0" + resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.4.0.tgz#91a859d4ee79c99b1c0ba6ee61c093b195c0d9f0" + integrity sha512-6+YwTjmqDwlA/Dm+5wK67ezgqgjA/MhSDgaNxKUzH97SmeuWFXyLeDRxxOPZeSo7yTxcDGUCWTEjmQZsVBuMrQ== dependencies: prosemirror-state "^1.0.0" prosemirror-transform "^1.1.0" prosemirror-view "^1.1.0" prosemirror-gapcursor@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.1.5.tgz#0c37fd6cbb1d7c46358c2e7397f8da9a8b5c6246" - integrity sha512-SjbUZq5pgsBDuV3hu8GqgIpZR5eZvGLM+gPQTqjVVYSMUCfKW3EGXTEYaLHEl1bGduwqNC95O3bZflgtAb4L6w== + version "1.2.1" + resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.2.1.tgz#02365e1bcc1ad25d390b0fb7f0e94a7fc173ad75" + integrity sha512-PHa9lj27iM/g4C46gxVzsefuXVfy/LrGQH4QjMRht7VDBgw77iWYWn8ZHMWSFkwtr9jQEuxI5gccHHHwWG80nw== dependencies: prosemirror-keymap "^1.0.0" prosemirror-model "^1.0.0" @@ -11784,9 +11791,9 @@ prosemirror-gapcursor@^1.1.5: prosemirror-view "^1.0.0" prosemirror-history@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.1.3.tgz#4f76a1e71db4ef7cdf0e13dec6d8da2aeaecd489" - integrity sha512-zGDotijea+vnfnyyUGyiy1wfOQhf0B/b6zYcCouBV8yo6JmrE9X23M5q7Nf/nATywEZbgRLG70R4DmfSTC+gfg== + version "1.2.0" + resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.2.0.tgz#04cc4df8d2f7b2a46651a2780de191ada6d465ea" + integrity sha512-B9v9xtf4fYbKxQwIr+3wtTDNLDZcmMMmGiI3TAPShnUzvo+Rmv1GiUrsQChY1meetHl7rhML2cppF3FTs7f7UQ== dependencies: prosemirror-state "^1.2.2" prosemirror-transform "^1.0.0" @@ -11801,32 +11808,32 @@ prosemirror-inputrules@^1.1.3: prosemirror-transform "^1.0.0" prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.1.2, prosemirror-keymap@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.1.4.tgz#8b481bf8389a5ac40d38dbd67ec3da2c7eac6a6d" - integrity sha512-Al8cVUOnDFL4gcI5IDlG6xbZ0aOD/i3B17VT+1JbHWDguCgt/lBHVTHUBcKvvbSg6+q/W4Nj1Fu6bwZSca3xjg== + version "1.1.5" + resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.1.5.tgz#b5984c7d30f5c75956c853126c54e9e624c0327b" + integrity sha512-8SZgPH3K+GLsHL2wKuwBD9rxhsbnVBTwpHCO4VUO5GmqUQlxd/2GtBVWTsyLq4Dp3N9nGgPd3+lZFKUDuVp+Vw== dependencies: prosemirror-state "^1.0.0" w3c-keyname "^2.2.0" prosemirror-markdown@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.5.2.tgz#f188ad14caa8c2f499b4d3eb6082e19f1d9d366e" - integrity sha512-e9rVnRULVACEjCvIBOj5P2dGTE/nz8kKspA/GWZXVgtQgqeJEvQ+tUNeZkeRZJ2/I3XPzuWjeoWnwJmkMnIKrg== + version "1.7.0" + resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.7.0.tgz#6a1cdb7b8625b443ab887fd4ca62585b86b04351" + integrity sha512-jZAehQ6KN5pSTFEdb1+iTvdogRBlKH3+Qqb6wjRuh8auRomEYVvAmSY9OsUL7689HzOUiy02T4f2WxB/t2I8rQ== dependencies: - markdown-it "^10.0.0" + markdown-it "^12.0.0" prosemirror-model "^1.0.0" prosemirror-model@^1.0.0, prosemirror-model@^1.13.3, prosemirror-model@^1.14.3, prosemirror-model@^1.8.1: - version "1.15.0" - resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.15.0.tgz#23bc09098daa7c309dba90a76a1b989ce6f61405" - integrity sha512-hQJv7SnIhlAy9ga3lhPPgaufhvCbQB9tHwscJ9E1H1pPHmN8w5V/lURueoYv9Kc3/bpNWoyHa8r3g//m7N0ChQ== + version "1.16.1" + resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.16.1.tgz#fb388270bc9609b66298d6a7e15d0cc1d6c61253" + integrity sha512-r1/w0HDU40TtkXp0DyKBnFPYwd8FSlUSJmGCGFv4DeynfeSlyQF2FD0RQbVEMOe6P3PpUSXM6LZBV7W/YNZ4mA== dependencies: orderedmap "^1.1.0" prosemirror-schema-list@^1.1.2: - version "1.1.4" - resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.1.4.tgz#471f9caf2d2bed93641d2e490434c0d2d4330df1" - integrity sha512-pNTuZflacFOBlxrTcWSdWhjoB8BaucwfJVp/gJNxztOwaN3wQiC65axclXyplf6TKgXD/EkWfS/QAov3/Znadw== + version "1.1.6" + resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.1.6.tgz#c3e13fe2f74750e4a53ff88d798dc0c4ccca6707" + integrity sha512-aFGEdaCWmJzouZ8DwedmvSsL50JpRkqhQ6tcpThwJONVVmCgI36LJHtoQ4VGZbusMavaBhXXr33zyD2IVsTlkw== dependencies: prosemirror-model "^1.0.0" prosemirror-transform "^1.0.0" @@ -11862,7 +11869,7 @@ prosemirror-utils@^0.9.6: resolved "https://registry.yarnpkg.com/prosemirror-utils/-/prosemirror-utils-0.9.6.tgz#3d97bd85897e3b535555867dc95a51399116a973" integrity sha512-UC+j9hQQ1POYfMc5p7UFxBTptRiGPR7Kkmbl3jVvU8VgQbkI89tR/GK+3QYC8n+VvBZrtAoCrJItNhWSxX3slA== -prosemirror-view@1.18.1, prosemirror-view@1.22.0, prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3: +prosemirror-view@1.22.0, prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3: version "1.22.0" resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.22.0.tgz#235dca38ed62ae028d627be5684d45b36550d310" integrity sha512-kCZoHp0Sa2AHhwV64aNFi69QlqBocZ8G8gbvZRFDdEj57whOCdPjdVLYBVyM9+S5oHDSrzjkoMm+JvvHFhMRJw== @@ -12191,7 +12198,7 @@ react-medium-image-zoom@^3.1.3: resolved "https://registry.yarnpkg.com/react-medium-image-zoom/-/react-medium-image-zoom-3.1.3.tgz#b1470abc5a342d65c23021c01bafa8c731821478" integrity sha512-5CoU8whSCz5Xz2xNeGD34dDfZ6jaf/pybdfZh8HNUmA9mbXbLfj0n6bQWfEUwkq9lsNg1sEkyeIJq2tcvZY8bw== -react-portal@^4.2.0, react-portal@^4.2.1: +react-portal@^4.2.0: version "4.2.1" resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-4.2.1.tgz#12c1599238c06fb08a9800f3070bea2a3f78b1a6" integrity sha512-fE9kOBagwmTXZ3YGRYb4gcMy+kSA+yLO0xnPankjRlfBv4uCpFXqKPfkpsGQQR15wkZ9EssnvTOl1yMzbkxhPQ== @@ -12471,13 +12478,13 @@ reflect.ownkeys@^0.2.0: integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA= refractor@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.3.1.tgz#ebbc04b427ea81dc25ad333f7f67a0b5f4f0be3a" - integrity sha512-vaN6R56kLMuBszHSWlwTpcZ8KTMG6aUCok4GrxYDT20UIOXxOc5o6oDc8tNTzSlH3m2sI+Eu9Jo2kVdDcUTWYw== + version "3.5.0" + resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.5.0.tgz#334586f352dda4beaf354099b48c2d18e0819aec" + integrity sha512-QwPJd3ferTZ4cSPPjdP5bsYHMytwWYnAN5EEnLtGvkqp/FCCnGsBgxrm9EuIDnjUC3Uc/kETtvVi7fSIVC74Dg== dependencies: hastscript "^6.0.0" parse-entities "^2.0.0" - prismjs "~1.23.0" + prismjs "~1.25.0" regenerate-unicode-properties@^8.2.0: version "8.2.0" @@ -12664,11 +12671,6 @@ reselect@^4.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.4.tgz#66df0aff41b6ee0f51e2cc17cfaf2c1995916f32" integrity sha512-i1LgXw8DKSU5qz1EV0ZIKz4yIUHJ7L3bODh+Da6HmVSm9vdL/hG7IpbgzQ3k2XSirzf8/eI7OMEs81gb1VV2fQ== -resize-observer-polyfill@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" - integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== - resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -12761,40 +12763,6 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rich-markdown-editor@^11.21.3: - version "11.21.3" - resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.21.3.tgz#9c1fc32d10e75c1adf53e7fda7d4a72be52f3363" - integrity sha512-XqVDfwDiGMVVtb+HUY35FQvfo97ECAe+Pgo1veFiYiL5f9Jfp9zuF6fOK5eze9h8rApBsgD9iVHEsA8/gyv5Jg== - dependencies: - copy-to-clipboard "^3.0.8" - fuzzy-search "^3.2.1" - gemoji "6.x" - lodash "^4.17.11" - markdown-it "^12.2.0" - markdown-it-container "^3.0.0" - markdown-it-emoji "^2.0.0" - outline-icons "^1.38.1" - prosemirror-commands "^1.1.6" - prosemirror-dropcursor "^1.3.3" - prosemirror-gapcursor "^1.1.5" - prosemirror-history "^1.1.3" - prosemirror-inputrules "^1.1.3" - prosemirror-keymap "^1.1.4" - prosemirror-markdown "^1.5.2" - prosemirror-model "^1.13.3" - prosemirror-schema-list "^1.1.2" - prosemirror-state "^1.3.4" - prosemirror-tables "^1.1.1" - prosemirror-transform "1.2.5" - prosemirror-utils "^0.9.6" - prosemirror-view "1.18.1" - react-medium-image-zoom "^3.1.3" - react-portal "^4.2.1" - refractor "^3.3.1" - resize-observer-polyfill "^1.5.1" - slugify "^1.4.0" - smooth-scroll-into-view-if-needed "^1.1.29" - rimraf@2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" @@ -13013,17 +12981,12 @@ schema-utils@^3.0.0: ajv "^6.12.5" ajv-keywords "^3.5.2" -scroll-into-view-if-needed@^2.2.26: - version "2.2.26" - resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.26.tgz#e4917da0c820135ff65ad6f7e4b7d7af568c4f13" - integrity sha512-SQ6AOKfABaSchokAmmaxVnL9IArxEnLEX9j4wAZw+x4iUTb40q7irtHG3z4GtAWz5veVZcCnubXDBRyLVQaohw== +scroll-into-view-if-needed@^2.2.28: + version "2.2.28" + resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.28.tgz#5a15b2f58a52642c88c8eca584644e01703d645a" + integrity sha512-8LuxJSuFVc92+0AdNv4QOxRL4Abeo1DgLnGNkn1XlaujPH/3cCFz3QI60r2VNu4obJJROzgnIUw5TKQkZvZI1w== dependencies: - compute-scroll-into-view "^1.0.16" - -select@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" - integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= + compute-scroll-into-view "^1.0.17" semver-diff@^2.0.0: version "2.1.0" @@ -13275,17 +13238,17 @@ slug@^4.0.4: resolved "https://registry.yarnpkg.com/slug/-/slug-4.0.4.tgz#87138f22e2f5062e6522995dc4e8c5df63841b1f" integrity sha512-GwSsWjX2rcIAtbKWGaxnzASWljWraPQry0RmCnRkji2pEggVjgb5HjXdqN0Gb6JOPg19rsC3hHUYXbOnHXKv2Q== -slugify@^1.4.0: - version "1.4.6" - resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.4.6.tgz#ef288d920a47fb01c2be56b3487b6722f5e34ace" - integrity sha512-ZdJIgv9gdrYwhXqxsH9pv7nXxjUEyQ6nqhngRxoAAOlmMGA28FDq5O4/5US4G2/Nod7d1ovNcgURQJ7kHq50KQ== +slugify@^1.6.5: + version "1.6.5" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.5.tgz#c8f5c072bf2135b80703589b39a3d41451fbe8c8" + integrity sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ== -smooth-scroll-into-view-if-needed@^1.1.29: - version "1.1.29" - resolved "https://registry.yarnpkg.com/smooth-scroll-into-view-if-needed/-/smooth-scroll-into-view-if-needed-1.1.29.tgz#4f532d9f0353dbca122e546fb062e7b5e0643734" - integrity sha512-UxvIEbmMEqwbw0aZI4SOAtwwkMaLYVION20bDQmazVp3sNb1+WIA5koukqoJizRuAAUANRmcBcrTnodcB7maqw== +smooth-scroll-into-view-if-needed@^1.1.32: + version "1.1.32" + resolved "https://registry.yarnpkg.com/smooth-scroll-into-view-if-needed/-/smooth-scroll-into-view-if-needed-1.1.32.tgz#57718cb2caa5265ade3e96006dfcf28b2fdcfca0" + integrity sha512-1/Ui1kD/9U4E6B6gYvJ6qhEiZPHMT9ZHi/OKJVEiCFhmcMqPm7y4G15pIl/NhuPTkDF/u57eEOK4Frh4721V/w== dependencies: - scroll-into-view-if-needed "^2.2.26" + scroll-into-view-if-needed "^2.2.28" snapdragon-node@^2.0.1: version "2.1.1" @@ -14110,11 +14073,6 @@ tiny-cookie@^2.3.1: resolved "https://registry.yarnpkg.com/tiny-cookie/-/tiny-cookie-2.3.2.tgz#3b5fb4e0888cfa0b4728d5f6b7be3d3a88e6a5f0" integrity sha512-qbymkVh+6+Gc/c9sqnvbG+dOHH6bschjphK3SHgIfT6h/t+63GBL37JXNoXEc6u/+BcwU6XmaWUuf19ouLVtPg== -tiny-emitter@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" - integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== - tiny-invariant@^1.0.1, tiny-invariant@^1.0.2, tiny-invariant@^1.0.6, tiny-invariant@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9"