import some from "lodash/some"; import { EditorState, NodeSelection, TextSelection } from "prosemirror-state"; import * as React from "react"; import createAndInsertLink from "@shared/editor/commands/createAndInsertLink"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; import getMarkRange from "@shared/editor/queries/getMarkRange"; import isInCode from "@shared/editor/queries/isInCode"; import isMarkActive from "@shared/editor/queries/isMarkActive"; import isNodeActive from "@shared/editor/queries/isNodeActive"; import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table"; import { MenuItem } from "@shared/editor/types"; import { creatingUrlPrefix } from "@shared/utils/urls"; import useBoolean from "~/hooks/useBoolean"; import useDictionary from "~/hooks/useDictionary"; import useEventListener from "~/hooks/useEventListener"; import useMobile from "~/hooks/useMobile"; import usePrevious from "~/hooks/usePrevious"; import getCodeMenuItems from "../menus/code"; import getDividerMenuItems from "../menus/divider"; import getFormattingMenuItems from "../menus/formatting"; import getImageMenuItems from "../menus/image"; import getReadOnlyMenuItems from "../menus/readOnly"; 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 = { rtl: boolean; isTemplate: boolean; readOnly?: boolean; canComment?: boolean; onOpen: () => void; onClose: () => void; onSearchLink?: (term: string) => Promise; onClickLink: ( href: string, event: MouseEvent | React.MouseEvent ) => void; onCreateLink?: (title: string) => Promise; }; function useIsActive(state: EditorState) { const { selection, doc } = state; if (isMarkActive(state.schema.marks.link)(state)) { return true; } if ( (isNodeActive(state.schema.nodes.code_block)(state) || isNodeActive(state.schema.nodes.code_fence)(state)) && selection.from > 0 ) { return true; } if (!selection || 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 selectionText = doc.cut(selection.from, selection.to).textContent; if (selection instanceof TextSelection && !selectionText) { return false; } const slice = selection.content(); const fragment = slice.content; const nodes = (fragment as any).content; return some(nodes, (n) => n.content.size); } function useIsDragging() { const [isDragging, setDragging, setNotDragging] = useBoolean(); useEventListener("dragstart", setDragging); useEventListener("dragend", setNotDragging); useEventListener("drop", setNotDragging); return isDragging; } export default function SelectionToolbar(props: Props) { const { onClose, readOnly, onOpen } = props; const { view, commands } = useEditor(); const dictionary = useDictionary(); const menuRef = React.useRef(null); const isActive = useIsActive(view.state); const isDragging = useIsDragging(); const previousIsActive = usePrevious(isActive); const isMobile = useMobile(); React.useEffect(() => { // Trigger callbacks when the toolbar is opened or closed if (previousIsActive && !isActive) { onClose(); } if (!previousIsActive && isActive) { onOpen(); } }, [isActive, onClose, onOpen, previousIsActive]); React.useEffect(() => { const handleClickOutside = (ev: MouseEvent): void => { if ( ev.target instanceof HTMLElement && menuRef.current && menuRef.current.contains(ev.target) ) { return; } if (view.dom.contains(ev.target as HTMLElement)) { return; } if (!isActive || document.activeElement?.tagName === "INPUT") { return; } if (!window.getSelection()?.isCollapsed) { return; } const { dispatch } = view; dispatch( view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0))) ); }; window.addEventListener("mouseup", handleClickOutside); return () => { window.removeEventListener("mouseup", handleClickOutside); }; }, [isActive, previousIsActive, readOnly, view]); const handleOnCreateLink = async (title: string): Promise => { const { onCreateLink } = props; if (!onCreateLink) { return; } const { dispatch, state } = view; const { from, to } = state.selection; if (from === to) { // Do not display a selection toolbar for collapsed selections return; } const href = `${creatingUrlPrefix}${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 })) ); return createAndInsertLink(view, title, href, { onCreateLink, dictionary, }); }; const handleOnSelectLink = ({ href, from, to, }: { href: string; from: number; to: number; }): void => { const { state, dispatch } = view; const markType = state.schema.marks.link; dispatch( state.tr .removeMark(from, to, markType) .addMark(from, to, markType.create({ href })) ); }; const { onCreateLink, isTemplate, rtl, canComment, ...rest } = props; const { state } = view; const { selection } = state; const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state); // no toolbar in read-only without commenting or when dragging if ((readOnly && !canComment) || isDragging) { return null; } const colIndex = getColumnIndex(state); const rowIndex = getRowIndex(state); 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 instanceof NodeSelection && selection.node.type.name === "image"; const isCodeSelection = isInCode(state, { onlyBlock: true }); let items: MenuItem[] = []; if (isCodeSelection && selection.empty) { items = getCodeMenuItems(state, readOnly, dictionary); } else 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 = readOnly ? [] : getImageMenuItems(state, dictionary); } else if (isDividerSelection) { items = getDividerMenuItems(state, dictionary); } else if (readOnly) { items = getReadOnlyMenuItems(state, dictionary); } else { items = getFormattingMenuItems(state, isTemplate, isMobile, 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; } const showLinkToolbar = link && range; return ( {showLinkToolbar ? ( ) : ( )} ); }