From 4182cbd5d0041306e1f2f1d699ce322526315a6d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 13 Mar 2023 21:05:06 -0400 Subject: [PATCH] chore: Refactoring some editor controls (#5023) * Refactor EmojiMenu * Refactor CommandMenu to functional component * Remove more direct props, refactor to useEditor * Remove hardcoded IDs * Refactor SelectionToolbar to functional component * fix: Positioning of suggestion menu on long paragraphs --- app/components/Portal.tsx | 5 + app/editor/components/BlockMenu.tsx | 31 +- app/editor/components/CommandMenu.tsx | 695 ------------------ app/editor/components/EmojiMenu.tsx | 75 +- app/editor/components/EmojiMenuItem.tsx | 13 +- app/editor/components/FloatingToolbar.tsx | 17 +- app/editor/components/MentionMenu.tsx | 22 +- app/editor/components/MentionMenuItem.tsx | 13 +- app/editor/components/SelectionToolbar.tsx | 245 +++--- app/editor/components/SuggestionsMenu.tsx | 662 +++++++++++++++++ ...ndMenuItem.tsx => SuggestionsMenuItem.tsx} | 14 +- app/editor/index.tsx | 27 +- 12 files changed, 891 insertions(+), 928 deletions(-) delete mode 100644 app/editor/components/CommandMenu.tsx create mode 100644 app/editor/components/SuggestionsMenu.tsx rename app/editor/components/{CommandMenuItem.tsx => SuggestionsMenuItem.tsx} (80%) diff --git a/app/components/Portal.tsx b/app/components/Portal.tsx index 0f344454f..719f4843d 100644 --- a/app/components/Portal.tsx +++ b/app/components/Portal.tsx @@ -8,6 +8,11 @@ export const PortalContext = React.createContext< HTMLElement | null | undefined >(undefined); +/** + * A hook that returns the portal context value. + */ +export const usePortalContext = () => React.useContext(PortalContext); + /** * A portal component that uses context to render into a different dom node * or the root of body if no context is available. diff --git a/app/editor/components/BlockMenu.tsx b/app/editor/components/BlockMenu.tsx index 1d82ba6c1..7cca6e5f5 100644 --- a/app/editor/components/BlockMenu.tsx +++ b/app/editor/components/BlockMenu.tsx @@ -1,32 +1,39 @@ import { findParentNode } from "prosemirror-utils"; import React from "react"; +import useDictionary from "~/hooks/useDictionary"; import getMenuItems from "../menus/block"; -import CommandMenu, { Props } from "./CommandMenu"; -import CommandMenuItem from "./CommandMenuItem"; +import { useEditor } from "./EditorContext"; +import SuggestionsMenu, { + Props as SuggestionsMenuProps, +} from "./SuggestionsMenu"; +import SuggestionsMenuItem from "./SuggestionsMenuItem"; -type BlockMenuProps = Omit< - Props, +type Props = Omit< + SuggestionsMenuProps, "renderMenuItem" | "items" | "onClearSearch" > & - Required>; + Required>; -function BlockMenu(props: BlockMenuProps) { - const clearSearch = () => { - const { state, dispatch } = props.view; +function BlockMenu(props: Props) { + const { view } = useEditor(); + const dictionary = useDictionary(); + + const clearSearch = React.useCallback(() => { + const { state, dispatch } = view; const parent = findParentNode((node) => !!node)(state.selection); if (parent) { dispatch(state.tr.insertText("", parent.pos, state.selection.to)); } - }; + }, [view]); return ( - ( - )} - items={getMenuItems(props.dictionary)} + items={getMenuItems(dictionary)} /> ); } diff --git a/app/editor/components/CommandMenu.tsx b/app/editor/components/CommandMenu.tsx deleted file mode 100644 index 418a8c8bd..000000000 --- a/app/editor/components/CommandMenu.tsx +++ /dev/null @@ -1,695 +0,0 @@ -import { capitalize } from "lodash"; -import { findDomRefAtPos, findParentNode } from "prosemirror-utils"; -import { EditorView } from "prosemirror-view"; -import * as React from "react"; -import { Trans } from "react-i18next"; -import { VisuallyHidden } from "reakit/VisuallyHidden"; -import styled from "styled-components"; -import insertFiles from "@shared/editor/commands/insertFiles"; -import { EmbedDescriptor } from "@shared/editor/embeds"; -import { CommandFactory } from "@shared/editor/lib/Extension"; -import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; -import { MenuItem } from "@shared/editor/types"; -import { depths } from "@shared/styles"; -import { getEventFiles } from "@shared/utils/files"; -import { AttachmentValidation } from "@shared/validations"; -import { Portal } from "~/components/Portal"; -import Scrollable from "~/components/Scrollable"; -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; - uploadFile?: (file: File) => Promise; - onFileUploadStart?: () => void; - onFileUploadStop?: () => void; - onShowToast: (message: string) => void; - onLinkToolbarOpen?: () => void; - onClose: (insertNewLine?: boolean) => 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.PureComponent< - Props, - State -> { - menuRef = React.createRef(); - inputRef = React.createRef(); - - state: State = { - left: -1000, - top: 0, - bottom: undefined, - isAbove: false, - selectedIndex: 0, - insertItem: undefined, - }; - - componentDidMount() { - window.addEventListener("mousedown", this.handleMouseDown); - window.addEventListener("keydown", this.handleKeyDown); - } - - componentDidUpdate(prevProps: Props) { - if (!prevProps.isActive && this.props.isActive) { - // reset scroll position to top when opening menu as the contents are - // hidden, not unrendered - if (this.menuRef.current) { - this.menuRef.current.scroll({ top: 0 }); - } - 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("mousedown", this.handleMouseDown); - window.removeEventListener("keydown", this.handleKeyDown); - } - - handleMouseDown = (event: MouseEvent) => { - if ( - !this.menuRef.current || - this.menuRef.current.contains(event.target as Element) - ) { - return; - } - - this.props.onClose(); - }; - - 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(true); - } - } - - 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?.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?.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.triggerFilePick( - AttachmentValidation.imageContentTypes.join(", ") - ); - case "attachment": - return this.triggerFilePick("*"); - case "embed": - return this.triggerLinkInput(item); - case "link": { - this.clearSearch(); - this.props.onClose(); - this.props.onLinkToolbarOpen?.(); - return; - } - default: - this.insertNode(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.dictionary.embedInvalidLink); - return; - } - - this.insertNode({ - 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.insertNode({ - name: "embed", - attrs: { - href, - }, - }); - } - }; - - triggerFilePick = (accept: string) => { - if (this.inputRef.current) { - if (accept) { - this.inputRef.current.accept = accept; - } - this.inputRef.current.click(); - } - }; - - triggerLinkInput = (item: EmbedDescriptor) => { - this.setState({ insertItem: item }); - }; - - handleFilesPicked = (event: React.ChangeEvent) => { - const files = getEventFiles(event); - - const { - view, - uploadFile, - onFileUploadStart, - onFileUploadStop, - onShowToast, - } = this.props; - const { state } = view; - const parent = findParentNode((node) => !!node)(state.selection); - - this.clearSearch(); - - if (!uploadFile) { - throw new Error("uploadFile prop is required to replace files"); - } - - if (parent) { - insertFiles(view, event, parent.pos, files, { - uploadFile, - onFileUploadStart, - onFileUploadStop, - onShowToast, - dictionary: this.props.dictionary, - isAttachment: this.inputRef.current?.accept === "*", - }); - } - - if (this.inputRef.current) { - this.inputRef.current.value = ""; - } - - this.props.onClose(); - }; - - clearSearch = () => { - this.props.onClearSearch(); - }; - - insertNode(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); - } - if (item.appendSpace) { - const { view } = this.props; - const { dispatch } = view; - dispatch(view.state.tr.insertText(" ")); - } - - 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 offsetWidth = ref ? ref.offsetWidth : 0; - 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 = 12; - - const offsetParent = ref?.offsetParent - ? ref.offsetParent.getBoundingClientRect() - : ({ - width: 0, - height: 0, - top: 0, - left: 0, - } as DOMRect); - - let leftPos = Math.min( - left - offsetParent.left, - window.innerWidth - offsetParent.left - offsetWidth - margin - ); - if (props.rtl) { - leftPos = right - offsetWidth; - } - - if (startPos.top - offsetHeight > margin) { - return { - left: leftPos, - top: undefined, - bottom: offsetParent.bottom - top, - isAbove: false, - }; - } else { - return { - left: leftPos, - top: bottom - offsetParent.top, - bottom: undefined, - isAbove: true, - }; - } - } - - get filtered() { - const { - embeds = [], - search = "", - uploadFile, - commands, - filterable = true, - } = this.props; - let items: (EmbedDescriptor | MenuItem)[] = [...this.props.items]; - const embedItems: EmbedDescriptor[] = []; - - for (const embed of embeds) { - if (embed.title && embed.visible !== false) { - embedItems.push( - new EmbedDescriptor({ - ...embed, - name: "embed", - }) - ); - } - } - - if (embedItems.length) { - items = items.concat( - { - name: "separator", - }, - embedItems - ); - } - - const searchInput = search.toLowerCase(); - 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 (!uploadFile && item.name === "image") { - return false; - } - - // some items (defaultHidden) are not visible until a search query exists - if (!search) { - return !item.defaultHidden; - } - - if (!filterable) { - return item; - } - - return ( - (item.title || "").toLowerCase().includes(searchInput) || - (item.keywords || "").toLowerCase().includes(searchInput) - ); - }); - - return filterExcessSeparators( - filtered.sort((item) => { - return searchInput && - (item.title || "").toLowerCase().startsWith(searchInput) - ? -1 - : 1; - }) - ); - } - - render() { - const { dictionary, isActive, uploadFile } = this.props; - const items = this.filtered; - const { insertItem, ...positioning } = this.state; - - return ( - - - {insertItem ? ( - - - - ) : ( - - {items.map((item, index) => { - if (item.name === "separator") { - return ( - -
-
- ); - } - - if (!item.title) { - return null; - } - - const handlePointer = () => { - if (this.state.selectedIndex !== index) { - this.setState({ selectedIndex: index }); - } - }; - - return ( - - {this.props.renderMenuItem(item as any, index, { - selected: index === this.state.selectedIndex, - onClick: () => this.insertItem(item), - })} - - ); - })} - {items.length === 0 && ( - - {dictionary.noResults} - - )} -
- )} - {uploadFile && ( - - - - )} -
-
- ); - } -} - -const LinkInputWrapper = styled.div` - margin: 8px; -`; - -const LinkInput = styled(Input)` - height: 32px; - width: 100%; - color: ${(props) => props.theme.textSecondary}; -`; - -const List = styled.ol` - list-style: none; - text-align: left; - height: 100%; - padding: 6px; - 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: 32px; - padding: 0 16px; -`; - -export const Wrapper = styled(Scrollable)<{ - active: boolean; - top?: number; - bottom?: number; - left?: number; - isAbove: boolean; -}>` - color: ${(props) => props.theme.textSecondary}; - font-family: ${(props) => props.theme.fontFamily}; - position: absolute; - z-index: ${depths.editorToolbar}; - ${(props) => props.top !== undefined && `top: ${props.top}px`}; - ${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`}; - left: ${(props) => props.left}px; - background: ${(props) => props.theme.menuBackground}; - border-radius: 6px; - 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: 280px; - height: auto; - max-height: 324px; - - * { - box-sizing: border-box; - } - - hr { - border: 0; - height: 0; - border-top: 1px solid ${(props) => props.theme.divider}; - } - - ${({ 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/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx index 28e9be245..9cdeea313 100644 --- a/app/editor/components/EmojiMenu.tsx +++ b/app/editor/components/EmojiMenu.tsx @@ -1,8 +1,11 @@ import FuzzySearch from "fuzzy-search"; import gemojies from "gemoji"; import React from "react"; -import CommandMenu, { Props } from "./CommandMenu"; +import { useEditor } from "./EditorContext"; import EmojiMenuItem from "./EmojiMenuItem"; +import SuggestionsMenu, { + Props as SuggestionsMenuProps, +} from "./SuggestionsMenu"; type Emoji = { name: string; @@ -21,19 +24,16 @@ const searcher = new FuzzySearch<{ sort: true, }); -class EmojiMenu extends React.PureComponent< - Omit< - Props, - | "renderMenuItem" - | "items" - | "onLinkToolbarOpen" - | "embeds" - | "onClearSearch" - > -> { - get items(): Emoji[] { - const { search = "" } = this.props; +type Props = Omit< + SuggestionsMenuProps, + "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch" +>; +const EmojiMenu = (props: Props) => { + const { search = "" } = props; + const { view } = useEditor(); + + const items = React.useMemo(() => { const n = search.toLowerCase(); const result = searcher.search(n).map((item) => { const description = item.description; @@ -48,42 +48,37 @@ class EmojiMenu extends React.PureComponent< }); return result.slice(0, 10); - } + }, [search]); - clearSearch = () => { - const { state, dispatch } = this.props.view; + const clearSearch = React.useCallback(() => { + const { state, dispatch } = view; // clear search input dispatch( state.tr.insertText( "", - state.selection.$from.pos - (this.props.search ?? "").length - 1, + state.selection.$from.pos - (props.search ?? "").length - 1, state.selection.to ) ); - }; + }, [view, props.search]); - render() { - const containerId = "emoji-menu-container"; - return ( - ( - - )} - items={this.items} - /> - ); - } -} + return ( + ( + + )} + items={items} + /> + ); +}; export default EmojiMenu; diff --git a/app/editor/components/EmojiMenuItem.tsx b/app/editor/components/EmojiMenuItem.tsx index 9464b21c5..89089b6c3 100644 --- a/app/editor/components/EmojiMenuItem.tsx +++ b/app/editor/components/EmojiMenuItem.tsx @@ -1,21 +1,24 @@ import * as React from "react"; import styled from "styled-components"; -import CommandMenuItem, { - Props as CommandMenuItemProps, -} from "./CommandMenuItem"; +import SuggestionsMenuItem, { + Props as SuggestionsMenuItemProps, +} from "./SuggestionsMenuItem"; const Emoji = styled.span` font-size: 16px; line-height: 1.6em; `; -type EmojiMenuItemProps = Omit & { +type EmojiMenuItemProps = Omit< + SuggestionsMenuItemProps, + "shortcut" | "theme" +> & { emoji: string; }; export default function EmojiMenuItem({ emoji, ...rest }: EmojiMenuItemProps) { return ( - {emoji}} /> diff --git a/app/editor/components/FloatingToolbar.tsx b/app/editor/components/FloatingToolbar.tsx index 99f7d0629..8d38df698 100644 --- a/app/editor/components/FloatingToolbar.tsx +++ b/app/editor/components/FloatingToolbar.tsx @@ -25,11 +25,9 @@ const defaultPosition = { function usePosition({ menuRef, - isSelectingText, active, }: { menuRef: React.RefObject; - isSelectingText: boolean; active?: boolean; }) { const { view } = useEditor(); @@ -38,13 +36,7 @@ function usePosition({ const viewportHeight = useViewportHeight(); const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)"); - if ( - !active || - !menuWidth || - !menuHeight || - !menuRef.current || - isSelectingText - ) { + if (!active || !menuWidth || !menuHeight || !menuRef.current) { return defaultPosition; } @@ -173,12 +165,15 @@ const FloatingToolbar = React.forwardRef( const menuRef = ref || React.createRef(); const [isSelectingText, setSelectingText] = React.useState(false); - const position = usePosition({ + let position = usePosition({ menuRef, - isSelectingText, active: props.active, }); + if (isSelectingText) { + position = defaultPosition; + } + useEventListener("mouseup", () => { setSelectingText(false); }); diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index 681659e2c..7b29105d1 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -1,3 +1,4 @@ +import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { v4 } from "uuid"; @@ -8,8 +9,11 @@ import Avatar from "~/components/Avatar"; import Flex from "~/components/Flex"; import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; -import CommandMenu, { Props } from "./CommandMenu"; +import { useEditor } from "./EditorContext"; import MentionMenuItem from "./MentionMenuItem"; +import SuggestionsMenu, { + Props as SuggestionsMenuProps, +} from "./SuggestionsMenu"; interface MentionItem extends MenuItem { name: string; @@ -24,15 +28,16 @@ interface MentionItem extends MenuItem { }; } -type MentionMenuProps = Omit< - Props, +type Props = Omit< + SuggestionsMenuProps, "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch" >; -function MentionMenu({ search, ...rest }: MentionMenuProps) { +function MentionMenu({ search, ...rest }: Props) { const [items, setItems] = React.useState([]); const { t } = useTranslation(); const { users, auth } = useStores(); + const { view } = useEditor(); const { data, request } = useRequest( React.useCallback( () => users.fetchPage({ query: search, filter: "active" }), @@ -65,7 +70,7 @@ function MentionMenu({ search, ...rest }: MentionMenuProps) { }, [auth.user?.id, data]); const clearSearch = () => { - const { state, dispatch } = rest.view; + const { state, dispatch } = view; // clear search input dispatch( @@ -77,11 +82,9 @@ function MentionMenu({ search, ...rest }: MentionMenuProps) { ); }; - const containerId = "mention-menu-container"; return ( - & { +type MentionMenuItemProps = Omit< + SuggestionsMenuItemProps, + "shortcut" | "theme" +> & { label: string; }; @@ -11,5 +14,5 @@ export default function MentionMenuItem({ label, ...rest }: MentionMenuItemProps) { - return ; + return ; } diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx index 9d8855e43..b76ab60a6 100644 --- a/app/editor/components/SelectionToolbar.tsx +++ b/app/editor/components/SelectionToolbar.tsx @@ -1,10 +1,8 @@ import { some } from "lodash"; -import { NodeSelection, TextSelection } from "prosemirror-state"; +import { EditorState, NodeSelection, TextSelection } from "prosemirror-state"; import { CellSelection } from "prosemirror-tables"; -import { EditorView } from "prosemirror-view"; import * as React from "react"; import createAndInsertLink from "@shared/editor/commands/createAndInsertLink"; -import { CommandFactory } from "@shared/editor/lib/Extension"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; import getColumnIndex from "@shared/editor/queries/getColumnIndex"; import getMarkRange from "@shared/editor/queries/getMarkRange"; @@ -13,22 +11,23 @@ import isMarkActive from "@shared/editor/queries/isMarkActive"; import isNodeActive from "@shared/editor/queries/isNodeActive"; import { MenuItem } from "@shared/editor/types"; import { creatingUrlPrefix } from "@shared/utils/urls"; -import { Dictionary } from "~/hooks/useDictionary"; +import useDictionary from "~/hooks/useDictionary"; +import usePrevious from "~/hooks/usePrevious"; +import useToasts from "~/hooks/useToasts"; 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 { useEditor } from "./EditorContext"; 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; @@ -37,15 +36,12 @@ type Props = { event: MouseEvent | React.MouseEvent ) => void; onCreateLink?: (title: string) => Promise; - onShowToast: (message: string) => void; - view: EditorView; }; -function isVisible(props: Props) { - const { view } = props; - const { selection, doc } = view.state; +function useIsActive(state: EditorState) { + const { selection, doc } = state; - if (isMarkActive(view.state.schema.marks.link)(view.state)) { + if (isMarkActive(state.schema.marks.link)(state)) { return true; } if (!selection || selection.empty) { @@ -76,57 +72,56 @@ function isVisible(props: Props) { return some(nodes, (n) => n.content.size); } -export default class SelectionToolbar extends React.Component { - isActive = false; - menuRef = React.createRef(); +export default function SelectionToolbar(props: Props) { + const { onClose, onOpen } = props; + const { view, commands } = useEditor(); + const { showToast: onShowToast } = useToasts(); + const dictionary = useDictionary(); + const menuRef = React.useRef(null); + const isActive = useIsActive(view.state); + const previousIsActuve = usePrevious(isActive); - 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(); - } + // Trigger callbacks when the toolbar is opened or closed + if (previousIsActuve && !isActive) { + onClose(); + } + if (!previousIsActuve && isActive) { + onOpen(); } - componentDidMount(): void { - window.addEventListener("mouseup", this.handleClickOutside); - } + React.useEffect(() => { + const handleClickOutside = (ev: MouseEvent): void => { + if ( + ev.target instanceof HTMLElement && + menuRef.current && + menuRef.current.contains(ev.target) + ) { + return; + } - componentWillUnmount(): void { - window.removeEventListener("mouseup", this.handleClickOutside); - } + if (!isActive || document.activeElement?.tagName === "INPUT") { + return; + } - handleClickOutside = (ev: MouseEvent): void => { - if ( - ev.target instanceof HTMLElement && - this.menuRef.current && - this.menuRef.current.contains(ev.target) - ) { - return; - } + if (view.hasFocus()) { + return; + } - if (!this.isActive || document.activeElement?.tagName === "INPUT") { - return; - } + const { dispatch } = view; + dispatch( + view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0))) + ); + }; - const { view } = this.props; - if (view.hasFocus()) { - return; - } + window.addEventListener("mouseup", handleClickOutside); - const { dispatch } = view; + return () => { + window.removeEventListener("mouseup", handleClickOutside); + }; + }, [isActive, 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; + const handleOnCreateLink = async (title: string): Promise => { + const { onCreateLink } = props; if (!onCreateLink) { return; @@ -156,7 +151,7 @@ export default class SelectionToolbar extends React.Component { }); }; - handleOnSelectLink = ({ + const handleOnSelectLink = ({ href, from, to, @@ -165,7 +160,6 @@ export default class SelectionToolbar extends React.Component { from: number; to: number; }): void => { - const { view } = this.props; const { state, dispatch } = view; const markType = state.schema.marks.link; @@ -177,76 +171,75 @@ export default class SelectionToolbar extends React.Component { ); }; - 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); + const { onCreateLink, isTemplate, rtl, ...rest } = props; + 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?.type?.name === "image"; - - 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); - } - - // 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; - } - - return ( - - {link && range ? ( - - ) : ( - - )} - - ); + // 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?.type?.name === "image"; + + 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); + } + + // Some extensions may be disabled, remove corresponding items + items = items.filter((item) => { + if (item.name === "separator") { + return true; + } + if (item.name && !commands[item.name]) { + return false; + } + return true; + }); + + items = filterExcessSeparators(items); + if (!items.length) { + return null; + } + + return ( + + {link && range ? ( + + ) : ( + + )} + + ); } diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx new file mode 100644 index 000000000..7792bbed3 --- /dev/null +++ b/app/editor/components/SuggestionsMenu.tsx @@ -0,0 +1,662 @@ +import { capitalize } from "lodash"; +import { findParentNode } from "prosemirror-utils"; +import * as React from "react"; +import { Trans } from "react-i18next"; +import { VisuallyHidden } from "reakit/VisuallyHidden"; +import styled from "styled-components"; +import insertFiles from "@shared/editor/commands/insertFiles"; +import { EmbedDescriptor } from "@shared/editor/embeds"; +import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; +import { MenuItem } from "@shared/editor/types"; +import { depths } from "@shared/styles"; +import { getEventFiles } from "@shared/utils/files"; +import { AttachmentValidation } from "@shared/validations"; +import { Portal } from "~/components/Portal"; +import Scrollable from "~/components/Scrollable"; +import useDictionary from "~/hooks/useDictionary"; +import useToasts from "~/hooks/useToasts"; +import { useEditor } from "./EditorContext"; +import Input from "./Input"; + +type TopAnchor = { + top: number; + bottom: undefined; +}; + +type BottomAnchor = { + top: undefined; + bottom: number; +}; + +type LeftAnchor = { + left: number; + right: undefined; +}; + +type RightAnchor = { + left: undefined; + right: number; +}; + +type Position = ((TopAnchor | BottomAnchor) & (LeftAnchor | RightAnchor)) & { + isAbove: boolean; +}; + +const defaultPosition: Position = { + top: 0, + bottom: undefined, + left: -1000, + right: undefined, + isAbove: false, +}; + +export type Props = { + rtl: boolean; + isActive: boolean; + search: string; + uploadFile?: (file: File) => Promise; + onFileUploadStart?: () => void; + onFileUploadStop?: () => void; + onLinkToolbarOpen?: () => void; + onClose: (insertNewLine?: boolean) => void; + onClearSearch: () => void; + embeds?: EmbedDescriptor[]; + renderMenuItem: ( + item: T, + index: number, + options: { + selected: boolean; + onClick: () => void; + } + ) => React.ReactNode; + filterable?: boolean; + items: T[]; +}; + +function SuggestionsMenu(props: Props) { + const { view, commands } = useEditor(); + const { showToast: onShowToast } = useToasts(); + const dictionary = useDictionary(); + const menuRef = React.useRef(null); + const inputRef = React.useRef(null); + const [position, setPosition] = React.useState(defaultPosition); + const [insertItem, setInsertItem] = React.useState< + MenuItem | EmbedDescriptor + >(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + + const calculatePosition = React.useCallback( + (props: Props) => { + if (!props.isActive) { + return defaultPosition; + } + + const caretPosition = () => { + let fromPos; + let toPos; + try { + fromPos = view.coordsAtPos(selection.from); + toPos = view.coordsAtPos(selection.to, -1); + } catch (err) { + console.warn(err); + return { + top: 0, + bottom: 0, + left: 0, + right: 0, + }; + } + + // ensure that start < end for the menu to be positioned correctly + return { + 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), + }; + }; + + const { selection } = view.state; + const ref = menuRef.current; + const offsetWidth = ref ? ref.offsetWidth : 0; + const offsetHeight = ref ? ref.offsetHeight : 0; + const { top, bottom, right, left } = caretPosition(); + const margin = 12; + + const offsetParent = ref?.offsetParent + ? ref.offsetParent.getBoundingClientRect() + : ({ + width: 0, + height: 0, + top: 0, + left: 0, + } as DOMRect); + + let leftPos = Math.min( + left - offsetParent.left, + window.innerWidth - offsetParent.left - offsetWidth - margin + ); + if (props.rtl) { + leftPos = right - offsetWidth; + } + + if (top - offsetHeight > margin) { + return { + left: leftPos, + top: undefined, + bottom: offsetParent.bottom - top, + right: undefined, + isAbove: false, + }; + } else { + return { + left: leftPos, + top: bottom - offsetParent.top, + bottom: undefined, + right: undefined, + isAbove: true, + }; + } + }, + [view] + ); + + React.useEffect(() => { + if (!props.isActive) { + return; + } + + // reset scroll position to top when opening menu as the contents are + // hidden, not unrendered + if (menuRef.current) { + menuRef.current.scroll({ top: 0 }); + } + + setPosition(calculatePosition(props)); + setSelectedIndex(0); + setInsertItem(undefined); + }, [calculatePosition, props.isActive]); + + React.useEffect(() => { + setSelectedIndex(0); + }, [props.search]); + + const insertNode = React.useCallback( + (item: MenuItem | EmbedDescriptor) => { + props.onClearSearch(); + + const command = item.name ? commands[item.name] : undefined; + + if (command) { + command(item.attrs); + } else { + commands[`create${capitalize(item.name)}`](item.attrs); + } + if ("appendSpace" in item) { + const { dispatch } = view; + dispatch(view.state.tr.insertText(" ")); + } + + props.onClose(); + }, + [commands, props, view] + ); + + const handleClickItem = React.useCallback( + (item) => { + switch (item.name) { + case "image": + return triggerFilePick( + AttachmentValidation.imageContentTypes.join(", ") + ); + case "attachment": + return triggerFilePick("*"); + case "embed": + return triggerLinkInput(item); + case "link": { + props.onClearSearch(); + props.onClose(); + props.onLinkToolbarOpen?.(); + return; + } + default: + insertNode(item); + } + }, + [insertNode, props] + ); + + const close = React.useCallback(() => { + props.onClose(); + view.focus(); + }, [props, view]); + + const handleLinkInputKeydown = ( + event: React.KeyboardEvent + ) => { + if (!props.isActive) { + return; + } + if (!insertItem) { + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + + const href = event.currentTarget.value; + const matches = "matcher" in insertItem && insertItem.matcher(href); + + if (!matches) { + onShowToast(dictionary.embedInvalidLink); + return; + } + + insertNode({ + name: "embed", + attrs: { + href, + }, + }); + } + + if (event.key === "Escape") { + props.onClose(); + view.focus(); + } + }; + + const handleLinkInputPaste = ( + event: React.ClipboardEvent + ) => { + if (!props.isActive) { + return; + } + if (!insertItem) { + return; + } + + const href = event.clipboardData.getData("text/plain"); + const matches = "matcher" in insertItem && insertItem.matcher(href); + + if (matches) { + event.preventDefault(); + event.stopPropagation(); + + insertNode({ + name: "embed", + attrs: { + href, + }, + }); + } + }; + + const triggerFilePick = (accept: string) => { + if (inputRef.current) { + if (accept) { + inputRef.current.accept = accept; + } + inputRef.current.click(); + } + }; + + const triggerLinkInput = (item: MenuItem) => { + setInsertItem(item); + }; + + const handleFilesPicked = (event: React.ChangeEvent) => { + const { uploadFile, onFileUploadStart, onFileUploadStop } = props; + const files = getEventFiles(event); + const parent = findParentNode((node) => !!node)(view.state.selection); + + props.onClearSearch(); + + if (!uploadFile) { + throw new Error("uploadFile prop is required to replace files"); + } + + if (parent) { + insertFiles(view, event, parent.pos, files, { + uploadFile, + onFileUploadStart, + onFileUploadStop, + onShowToast, + dictionary, + isAttachment: inputRef.current?.accept === "*", + }); + } + + if (inputRef.current) { + inputRef.current.value = ""; + } + + props.onClose(); + }; + + const filtered = React.useMemo(() => { + const { embeds = [], search = "", uploadFile, filterable = true } = props; + let items: (EmbedDescriptor | MenuItem)[] = [...props.items]; + const embedItems: EmbedDescriptor[] = []; + + for (const embed of embeds) { + if (embed.title && embed.visible !== false) { + embedItems.push( + new EmbedDescriptor({ + ...embed, + name: "embed", + }) + ); + } + } + + if (embedItems.length) { + items = items.concat( + { + name: "separator", + }, + embedItems + ); + } + + const searchInput = search.toLowerCase(); + 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 (!uploadFile && item.name === "image") { + return false; + } + + // some items (defaultHidden) are not visible until a search query exists + if (!search) { + return !item.defaultHidden; + } + + if (!filterable) { + return item; + } + + return ( + (item.title || "").toLowerCase().includes(searchInput) || + (item.keywords || "").toLowerCase().includes(searchInput) + ); + }); + + return filterExcessSeparators( + filtered.sort((item) => { + return searchInput && + (item.title || "").toLowerCase().startsWith(searchInput) + ? -1 + : 1; + }) + ); + }, [commands, props]); + + React.useEffect(() => { + const handleMouseDown = (event: MouseEvent) => { + if ( + !menuRef.current || + menuRef.current.contains(event.target as Element) + ) { + return; + } + + props.onClose(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (!props.isActive) { + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + + const item = filtered[selectedIndex]; + + if (item) { + handleClickItem(item); + } else { + props.onClose(true); + } + } + + if ( + event.key === "ArrowUp" || + (event.key === "Tab" && event.shiftKey) || + (event.ctrlKey && event.key === "p") + ) { + event.preventDefault(); + event.stopPropagation(); + + if (filtered.length) { + const prevIndex = selectedIndex - 1; + const prev = filtered[prevIndex]; + + setSelectedIndex( + Math.max(0, prev?.name === "separator" ? prevIndex - 1 : prevIndex) + ); + } else { + close(); + } + } + + if ( + event.key === "ArrowDown" || + (event.key === "Tab" && !event.shiftKey) || + (event.ctrlKey && event.key === "n") + ) { + event.preventDefault(); + event.stopPropagation(); + + if (filtered.length) { + const total = filtered.length - 1; + const nextIndex = selectedIndex + 1; + const next = filtered[nextIndex]; + + setSelectedIndex( + Math.min( + next?.name === "separator" ? nextIndex + 1 : nextIndex, + total + ) + ); + } else { + close(); + } + } + + if (event.key === "Escape") { + close(); + } + }; + + window.addEventListener("mousedown", handleMouseDown); + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [close, filtered, handleClickItem, props, selectedIndex]); + + const { isActive, uploadFile } = props; + const items = filtered; + + return ( + + + {insertItem ? ( + + + + ) : ( + + {items.map((item, index) => { + if (item.name === "separator") { + return ( + +
+
+ ); + } + + if (!item.title) { + return null; + } + + const handlePointer = () => { + if (selectedIndex !== index) { + setSelectedIndex(index); + } + }; + + return ( + + {props.renderMenuItem(item as any, index, { + selected: index === selectedIndex, + onClick: () => handleClickItem(item), + })} + + ); + })} + {items.length === 0 && ( + + {dictionary.noResults} + + )} +
+ )} + {uploadFile && ( + + + + )} +
+
+ ); +} + +const LinkInputWrapper = styled.div` + margin: 8px; +`; + +const LinkInput = styled(Input)` + height: 32px; + width: 100%; + color: ${(props) => props.theme.textSecondary}; +`; + +const List = styled.ol` + list-style: none; + text-align: left; + height: 100%; + padding: 6px; + 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: 32px; + padding: 0 16px; +`; + +export const Wrapper = styled(Scrollable)<{ + active: boolean; + top?: number; + bottom?: number; + left?: number; + isAbove: boolean; +}>` + color: ${(props) => props.theme.textSecondary}; + font-family: ${(props) => props.theme.fontFamily}; + position: absolute; + z-index: ${depths.editorToolbar}; + ${(props) => props.top !== undefined && `top: ${props.top}px`}; + ${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`}; + left: ${(props) => props.left}px; + background: ${(props) => props.theme.menuBackground}; + border-radius: 6px; + 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: 280px; + height: auto; + max-height: 324px; + + * { + box-sizing: border-box; + } + + hr { + border: 0; + height: 0; + border-top: 1px solid ${(props) => props.theme.divider}; + } + + ${({ active, isAbove }) => + active && + ` + transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1); + pointer-events: all; + opacity: 1; + `}; + + @media print { + display: none; + } +`; + +export default SuggestionsMenu; diff --git a/app/editor/components/CommandMenuItem.tsx b/app/editor/components/SuggestionsMenuItem.tsx similarity index 80% rename from app/editor/components/CommandMenuItem.tsx rename to app/editor/components/SuggestionsMenuItem.tsx index bda2af47e..426a9443b 100644 --- a/app/editor/components/CommandMenuItem.tsx +++ b/app/editor/components/SuggestionsMenuItem.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import scrollIntoView from "smooth-scroll-into-view-if-needed"; import styled from "styled-components"; import MenuItem from "~/components/ContextMenu/MenuItem"; +import { usePortalContext } from "~/components/Portal"; export type Props = { selected: boolean; @@ -10,18 +11,17 @@ export type Props = { icon?: React.ReactElement; title: React.ReactNode; shortcut?: string; - containerId?: string; }; -function CommandMenuItem({ +function SuggestionsMenuItem({ selected, disabled, onClick, title, shortcut, icon, - containerId = "block-menu-container", }: Props) { + const portal = usePortalContext(); const ref = React.useCallback( (node) => { if (selected && node) { @@ -30,14 +30,14 @@ function CommandMenuItem({ block: "nearest", boundary: (parent) => { // All the parent elements of your target are checked until they - // reach the #block-menu-container. Prevents body and other parent + // reach the portal context. Prevents body and other parent // elements from being scrolled - return parent.id !== containerId; + return parent !== portal; }, }); } }, - [selected, containerId] + [selected, portal] ); return ( @@ -60,4 +60,4 @@ const Shortcut = styled.span<{ $active?: boolean }>` text-align: right; `; -export default CommandMenuItem; +export default SuggestionsMenuItem; diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 22da3668b..8fd429994 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -564,6 +564,16 @@ export class Editor extends React.PureComponent< this.view.focus(); }; + /** + * Blur the editor. + */ + public blur = () => { + (this.view.dom as HTMLElement).blur(); + + // Have Safari remove the caret. + window?.getSelection()?.removeAllRanges(); + }; + /** * Returns true if the trimmed content of the editor is an empty string. * @@ -733,7 +743,6 @@ export class Editor extends React.PureComponent< grow, style, className, - dictionary, onKeyDown, } = this.props; const { isRTL } = this.state; @@ -762,9 +771,6 @@ export class Editor extends React.PureComponent< {!readOnly && this.view && ( <>