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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user