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:
@@ -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.
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
662
app/editor/components/SuggestionsMenu.tsx
Normal file
662
app/editor/components/SuggestionsMenu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user