296 lines
8.7 KiB
TypeScript
296 lines
8.7 KiB
TypeScript
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 getAttachmentMenuItems from "../menus/attachment";
|
|
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;
|
|
canUpdate?: boolean;
|
|
onOpen: () => void;
|
|
onClose: () => void;
|
|
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
|
onClickLink: (
|
|
href: string,
|
|
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
|
) => void;
|
|
onCreateLink?: (title: string) => Promise<string>;
|
|
};
|
|
|
|
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 &&
|
|
["image", "attachment"].includes(selection.node.type.name)
|
|
) {
|
|
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<HTMLDivElement | null>(null);
|
|
const isMobile = useMobile();
|
|
const isActive = useIsActive(view.state) || isMobile;
|
|
const isDragging = useIsDragging();
|
|
const previousIsActive = usePrevious(isActive);
|
|
|
|
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,
|
|
nested?: boolean
|
|
): Promise<void> => {
|
|
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, {
|
|
nested,
|
|
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, canUpdate, ...rest } =
|
|
props;
|
|
const { state } = view;
|
|
const { selection } = state;
|
|
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
|
|
|
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 isAttachmentSelection =
|
|
selection instanceof NodeSelection &&
|
|
selection.node.type.name === "attachment";
|
|
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
|
|
|
let items: MenuItem[] = [];
|
|
|
|
if (isCodeSelection && selection.empty) {
|
|
items = getCodeMenuItems(state, readOnly, dictionary);
|
|
} else if (isTableSelection) {
|
|
items = getTableMenuItems(state, 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 (isAttachmentSelection) {
|
|
items = readOnly ? [] : getAttachmentMenuItems(state, dictionary);
|
|
} else if (isDividerSelection) {
|
|
items = getDividerMenuItems(state, dictionary);
|
|
} else if (readOnly) {
|
|
items = getReadOnlyMenuItems(state, !!canUpdate, 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;
|
|
}
|
|
if (item.visible === false) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
items = filterExcessSeparators(items);
|
|
if (!items.length) {
|
|
return null;
|
|
}
|
|
|
|
const showLinkToolbar = link && range;
|
|
|
|
return (
|
|
<FloatingToolbar
|
|
active={isActive}
|
|
ref={menuRef}
|
|
width={showLinkToolbar ? 336 : undefined}
|
|
>
|
|
{showLinkToolbar ? (
|
|
<LinkEditor
|
|
key={`${range.from}-${range.to}`}
|
|
dictionary={dictionary}
|
|
view={view}
|
|
mark={range.mark}
|
|
from={range.from}
|
|
to={range.to}
|
|
onClickLink={props.onClickLink}
|
|
onSearchLink={props.onSearchLink}
|
|
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
|
onSelectLink={handleOnSelectLink}
|
|
/>
|
|
) : (
|
|
<ToolbarMenu items={items} {...rest} />
|
|
)}
|
|
</FloatingToolbar>
|
|
);
|
|
}
|