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

@@ -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.

View File

@@ -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<Pick<Props, "onLinkToolbarOpen" | "embeds">>;
Required<Pick<SuggestionsMenuProps, "onLinkToolbarOpen" | "embeds">>;
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 (
<CommandMenu
<SuggestionsMenu
{...props}
filterable={true}
onClearSearch={clearSearch}
renderMenuItem={(item, _index, options) => (
<CommandMenuItem
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
@@ -34,7 +41,7 @@ function BlockMenu(props: BlockMenuProps) {
shortcut={item.shortcut}
/>
)}
items={getMenuItems(props.dictionary)}
items={getMenuItems(dictionary)}
/>
);
}

View File

@@ -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<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
commands: Record<string, CommandFactory>;
dictionary: Dictionary;
view: EditorView;
search: string;
uploadFile?: (file: File) => Promise<string>;
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<T extends MenuItem> extends React.PureComponent<
Props<T>,
State
> {
menuRef = React.createRef<HTMLDivElement>();
inputRef = React.createRef<HTMLInputElement>();
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<T>) {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<Portal>
<Wrapper
id={this.props.id || "block-menu-container"}
active={isActive}
ref={this.menuRef}
hiddenScrollbars
{...positioning}
>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={this.handleLinkInputKeydown}
onPaste={this.handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
if (!item.title) {
return null;
}
const handlePointer = () => {
if (this.state.selectedIndex !== index) {
this.setState({ selectedIndex: index });
}
};
return (
<ListItem
key={index}
onPointerMove={handlePointer}
onPointerDown={handlePointer}
>
{this.props.renderMenuItem(item as any, index, {
selected: index === this.state.selectedIndex,
onClick: () => this.insertItem(item),
})}
</ListItem>
);
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</List>
)}
{uploadFile && (
<VisuallyHidden>
<label>
<Trans>Import document</Trans>
<input
type="file"
ref={this.inputRef}
onChange={this.handleFilesPicked}
multiple
/>
</label>
</VisuallyHidden>
)}
</Wrapper>
</Portal>
);
}
}
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;

View File

@@ -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<Emoji>,
| "renderMenuItem"
| "items"
| "onLinkToolbarOpen"
| "embeds"
| "onClearSearch"
>
> {
get items(): Emoji[] {
const { search = "" } = this.props;
type Props = Omit<
SuggestionsMenuProps<Emoji>,
"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 (
<CommandMenu
{...this.props}
id={containerId}
filterable={false}
onClearSearch={this.clearSearch}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
containerId={containerId}
/>
)}
items={this.items}
/>
);
}
}
return (
<SuggestionsMenu
{...props}
filterable={false}
onClearSearch={clearSearch}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
/>
)}
items={items}
/>
);
};
export default EmojiMenu;

View File

@@ -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<CommandMenuItemProps, "shortcut" | "theme"> & {
type EmojiMenuItemProps = Omit<
SuggestionsMenuItemProps,
"shortcut" | "theme"
> & {
emoji: string;
};
export default function EmojiMenuItem({ emoji, ...rest }: EmojiMenuItemProps) {
return (
<CommandMenuItem
<SuggestionsMenuItem
{...rest}
icon={<Emoji className="emoji">{emoji}</Emoji>}
/>

View File

@@ -25,11 +25,9 @@ const defaultPosition = {
function usePosition({
menuRef,
isSelectingText,
active,
}: {
menuRef: React.RefObject<HTMLDivElement>;
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<HTMLDivElement>();
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);
});

View File

@@ -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<MentionItem>,
type Props = Omit<
SuggestionsMenuProps<MentionItem>,
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch"
>;
function MentionMenu({ search, ...rest }: MentionMenuProps) {
function MentionMenu({ search, ...rest }: Props) {
const [items, setItems] = React.useState<MentionItem[]>([]);
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 (
<CommandMenu
<SuggestionsMenu
{...rest}
id={containerId}
filterable={false}
onClearSearch={clearSearch}
search={search}
@@ -91,7 +94,6 @@ function MentionMenu({ search, ...rest }: MentionMenuProps) {
selected={options.selected}
title={item.title}
label={item.attrs.label}
containerId={containerId}
icon={
<Flex
align="center"
@@ -113,4 +115,4 @@ function MentionMenu({ search, ...rest }: MentionMenuProps) {
);
}
export default MentionMenu;
export default observer(MentionMenu);

View File

@@ -1,9 +1,12 @@
import * as React from "react";
import CommandMenuItem, {
Props as CommandMenuItemProps,
} from "./CommandMenuItem";
import SuggestionsMenuItem, {
Props as SuggestionsMenuItemProps,
} from "./SuggestionsMenuItem";
type MentionMenuItemProps = Omit<CommandMenuItemProps, "shortcut" | "theme"> & {
type MentionMenuItemProps = Omit<
SuggestionsMenuItemProps,
"shortcut" | "theme"
> & {
label: string;
};
@@ -11,5 +14,5 @@ export default function MentionMenuItem({
label,
...rest
}: MentionMenuItemProps) {
return <CommandMenuItem {...rest} title={label} />;
return <SuggestionsMenuItem {...rest} title={label} />;
}

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>
);
}

View File

@@ -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<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
search: string;
uploadFile?: (file: File) => Promise<string>;
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<T extends MenuItem>(props: Props<T>) {
const { view, commands } = useEditor();
const { showToast: onShowToast } = useToasts();
const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const [position, setPosition] = React.useState<Position>(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<HTMLInputElement>
) => {
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<HTMLInputElement>
) => {
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<HTMLInputElement>) => {
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 (
<Portal>
<Wrapper active={isActive} ref={menuRef} hiddenScrollbars {...position}>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
if (!item.title) {
return null;
}
const handlePointer = () => {
if (selectedIndex !== index) {
setSelectedIndex(index);
}
};
return (
<ListItem
key={index}
onPointerMove={handlePointer}
onPointerDown={handlePointer}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: () => handleClickItem(item),
})}
</ListItem>
);
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</List>
)}
{uploadFile && (
<VisuallyHidden>
<label>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
onChange={handleFilesPicked}
multiple
/>
</label>
</VisuallyHidden>
)}
</Wrapper>
</Portal>
);
}
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;

View File

@@ -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;

View File

@@ -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 && (
<>
<SelectionToolbar
view={this.view}
dictionary={dictionary}
commands={this.commands}
rtl={isRTL}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionMenu}
@@ -772,7 +778,6 @@ export class Editor extends React.PureComponent<
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
onShowToast={this.props.onShowToast}
/>
<LinkToolbar
isActive={this.state.linkMenuOpen}
@@ -782,29 +787,18 @@ export class Editor extends React.PureComponent<
onClose={this.handleCloseLinkMenu}
/>
<EmojiMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
onShowToast={this.props.onShowToast}
isActive={this.state.emojiMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseEmojiMenu}
/>
<MentionMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
onShowToast={this.props.onShowToast}
isActive={this.state.mentionMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseMentionMenu}
/>
<BlockMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
isActive={this.state.blockMenuOpen}
search={this.state.blockMenuSearch}
@@ -813,7 +807,6 @@ export class Editor extends React.PureComponent<
onLinkToolbarOpen={this.handleOpenLinkMenu}
onFileUploadStart={this.props.onFileUploadStart}
onFileUploadStop={this.props.onFileUploadStop}
onShowToast={this.props.onShowToast}
embeds={this.props.embeds}
/>
</>