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
This commit is contained in:
Tom Moor
2023-03-13 21:05:06 -04:00
committed by GitHub
parent f6ac73a741
commit 4182cbd5d0
12 changed files with 891 additions and 928 deletions

View File

@@ -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<string, CommandFactory>;
onOpen: () => void;
onClose: () => void;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
@@ -37,15 +36,12 @@ type Props = {
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
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<Props> {
isActive = false;
menuRef = React.createRef<HTMLDivElement>();
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<HTMLDivElement | null>(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<void> => {
const { dictionary, onCreateLink, view, onShowToast } = this.props;
const handleOnCreateLink = async (title: string): Promise<void> => {
const { onCreateLink } = props;
if (!onCreateLink) {
return;
@@ -156,7 +151,7 @@ export default class SelectionToolbar extends React.Component<Props> {
});
};
handleOnSelectLink = ({
const handleOnSelectLink = ({
href,
from,
to,
@@ -165,7 +160,6 @@ export default class SelectionToolbar extends React.Component<Props> {
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<Props> {
);
};
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 (
<FloatingToolbar active={isVisible(this.props)} ref={this.menuRef}>
{link && range ? (
<LinkEditor
key={`${range.from}-${range.to}`}
dictionary={dictionary}
mark={range.mark}
from={range.from}
to={range.to}
onCreateLink={onCreateLink ? this.handleOnCreateLink : undefined}
onSelectLink={this.handleOnSelectLink}
{...rest}
/>
) : (
<ToolbarMenu items={items} {...rest} />
)}
</FloatingToolbar>
);
// 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 (
<FloatingToolbar active={isActive} ref={menuRef}>
{link && range ? (
<LinkEditor
key={`${range.from}-${range.to}`}
dictionary={dictionary}
view={view}
mark={range.mark}
from={range.from}
to={range.to}
onShowToast={onShowToast}
onClickLink={props.onClickLink}
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
onSelectLink={handleOnSelectLink}
/>
) : (
<ToolbarMenu items={items} {...rest} />
)}
</FloatingToolbar>
);
}