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
|
HTMLElement | null | undefined
|
||||||
>(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
|
* A portal component that uses context to render into a different dom node
|
||||||
* or the root of body if no context is available.
|
* or the root of body if no context is available.
|
||||||
|
|||||||
@@ -1,32 +1,39 @@
|
|||||||
import { findParentNode } from "prosemirror-utils";
|
import { findParentNode } from "prosemirror-utils";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import useDictionary from "~/hooks/useDictionary";
|
||||||
import getMenuItems from "../menus/block";
|
import getMenuItems from "../menus/block";
|
||||||
import CommandMenu, { Props } from "./CommandMenu";
|
import { useEditor } from "./EditorContext";
|
||||||
import CommandMenuItem from "./CommandMenuItem";
|
import SuggestionsMenu, {
|
||||||
|
Props as SuggestionsMenuProps,
|
||||||
|
} from "./SuggestionsMenu";
|
||||||
|
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||||
|
|
||||||
type BlockMenuProps = Omit<
|
type Props = Omit<
|
||||||
Props,
|
SuggestionsMenuProps,
|
||||||
"renderMenuItem" | "items" | "onClearSearch"
|
"renderMenuItem" | "items" | "onClearSearch"
|
||||||
> &
|
> &
|
||||||
Required<Pick<Props, "onLinkToolbarOpen" | "embeds">>;
|
Required<Pick<SuggestionsMenuProps, "onLinkToolbarOpen" | "embeds">>;
|
||||||
|
|
||||||
function BlockMenu(props: BlockMenuProps) {
|
function BlockMenu(props: Props) {
|
||||||
const clearSearch = () => {
|
const { view } = useEditor();
|
||||||
const { state, dispatch } = props.view;
|
const dictionary = useDictionary();
|
||||||
|
|
||||||
|
const clearSearch = React.useCallback(() => {
|
||||||
|
const { state, dispatch } = view;
|
||||||
const parent = findParentNode((node) => !!node)(state.selection);
|
const parent = findParentNode((node) => !!node)(state.selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
dispatch(state.tr.insertText("", parent.pos, state.selection.to));
|
dispatch(state.tr.insertText("", parent.pos, state.selection.to));
|
||||||
}
|
}
|
||||||
};
|
}, [view]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandMenu
|
<SuggestionsMenu
|
||||||
{...props}
|
{...props}
|
||||||
filterable={true}
|
filterable={true}
|
||||||
onClearSearch={clearSearch}
|
onClearSearch={clearSearch}
|
||||||
renderMenuItem={(item, _index, options) => (
|
renderMenuItem={(item, _index, options) => (
|
||||||
<CommandMenuItem
|
<SuggestionsMenuItem
|
||||||
onClick={options.onClick}
|
onClick={options.onClick}
|
||||||
selected={options.selected}
|
selected={options.selected}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
@@ -34,7 +41,7 @@ function BlockMenu(props: BlockMenuProps) {
|
|||||||
shortcut={item.shortcut}
|
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 FuzzySearch from "fuzzy-search";
|
||||||
import gemojies from "gemoji";
|
import gemojies from "gemoji";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import CommandMenu, { Props } from "./CommandMenu";
|
import { useEditor } from "./EditorContext";
|
||||||
import EmojiMenuItem from "./EmojiMenuItem";
|
import EmojiMenuItem from "./EmojiMenuItem";
|
||||||
|
import SuggestionsMenu, {
|
||||||
|
Props as SuggestionsMenuProps,
|
||||||
|
} from "./SuggestionsMenu";
|
||||||
|
|
||||||
type Emoji = {
|
type Emoji = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,19 +24,16 @@ const searcher = new FuzzySearch<{
|
|||||||
sort: true,
|
sort: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
class EmojiMenu extends React.PureComponent<
|
type Props = Omit<
|
||||||
Omit<
|
SuggestionsMenuProps<Emoji>,
|
||||||
Props<Emoji>,
|
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch"
|
||||||
| "renderMenuItem"
|
>;
|
||||||
| "items"
|
|
||||||
| "onLinkToolbarOpen"
|
|
||||||
| "embeds"
|
|
||||||
| "onClearSearch"
|
|
||||||
>
|
|
||||||
> {
|
|
||||||
get items(): Emoji[] {
|
|
||||||
const { search = "" } = this.props;
|
|
||||||
|
|
||||||
|
const EmojiMenu = (props: Props) => {
|
||||||
|
const { search = "" } = props;
|
||||||
|
const { view } = useEditor();
|
||||||
|
|
||||||
|
const items = React.useMemo(() => {
|
||||||
const n = search.toLowerCase();
|
const n = search.toLowerCase();
|
||||||
const result = searcher.search(n).map((item) => {
|
const result = searcher.search(n).map((item) => {
|
||||||
const description = item.description;
|
const description = item.description;
|
||||||
@@ -48,42 +48,37 @@ class EmojiMenu extends React.PureComponent<
|
|||||||
});
|
});
|
||||||
|
|
||||||
return result.slice(0, 10);
|
return result.slice(0, 10);
|
||||||
}
|
}, [search]);
|
||||||
|
|
||||||
clearSearch = () => {
|
const clearSearch = React.useCallback(() => {
|
||||||
const { state, dispatch } = this.props.view;
|
const { state, dispatch } = view;
|
||||||
|
|
||||||
// clear search input
|
// clear search input
|
||||||
dispatch(
|
dispatch(
|
||||||
state.tr.insertText(
|
state.tr.insertText(
|
||||||
"",
|
"",
|
||||||
state.selection.$from.pos - (this.props.search ?? "").length - 1,
|
state.selection.$from.pos - (props.search ?? "").length - 1,
|
||||||
state.selection.to
|
state.selection.to
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
}, [view, props.search]);
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
const containerId = "emoji-menu-container";
|
<SuggestionsMenu
|
||||||
return (
|
{...props}
|
||||||
<CommandMenu
|
filterable={false}
|
||||||
{...this.props}
|
onClearSearch={clearSearch}
|
||||||
id={containerId}
|
renderMenuItem={(item, _index, options) => (
|
||||||
filterable={false}
|
<EmojiMenuItem
|
||||||
onClearSearch={this.clearSearch}
|
onClick={options.onClick}
|
||||||
renderMenuItem={(item, _index, options) => (
|
selected={options.selected}
|
||||||
<EmojiMenuItem
|
title={item.description}
|
||||||
onClick={options.onClick}
|
emoji={item.emoji}
|
||||||
selected={options.selected}
|
/>
|
||||||
title={item.description}
|
)}
|
||||||
emoji={item.emoji}
|
items={items}
|
||||||
containerId={containerId}
|
/>
|
||||||
/>
|
);
|
||||||
)}
|
};
|
||||||
items={this.items}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EmojiMenu;
|
export default EmojiMenu;
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import CommandMenuItem, {
|
import SuggestionsMenuItem, {
|
||||||
Props as CommandMenuItemProps,
|
Props as SuggestionsMenuItemProps,
|
||||||
} from "./CommandMenuItem";
|
} from "./SuggestionsMenuItem";
|
||||||
|
|
||||||
const Emoji = styled.span`
|
const Emoji = styled.span`
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6em;
|
line-height: 1.6em;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type EmojiMenuItemProps = Omit<CommandMenuItemProps, "shortcut" | "theme"> & {
|
type EmojiMenuItemProps = Omit<
|
||||||
|
SuggestionsMenuItemProps,
|
||||||
|
"shortcut" | "theme"
|
||||||
|
> & {
|
||||||
emoji: string;
|
emoji: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EmojiMenuItem({ emoji, ...rest }: EmojiMenuItemProps) {
|
export default function EmojiMenuItem({ emoji, ...rest }: EmojiMenuItemProps) {
|
||||||
return (
|
return (
|
||||||
<CommandMenuItem
|
<SuggestionsMenuItem
|
||||||
{...rest}
|
{...rest}
|
||||||
icon={<Emoji className="emoji">{emoji}</Emoji>}
|
icon={<Emoji className="emoji">{emoji}</Emoji>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ const defaultPosition = {
|
|||||||
|
|
||||||
function usePosition({
|
function usePosition({
|
||||||
menuRef,
|
menuRef,
|
||||||
isSelectingText,
|
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
menuRef: React.RefObject<HTMLDivElement>;
|
menuRef: React.RefObject<HTMLDivElement>;
|
||||||
isSelectingText: boolean;
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { view } = useEditor();
|
const { view } = useEditor();
|
||||||
@@ -38,13 +36,7 @@ function usePosition({
|
|||||||
const viewportHeight = useViewportHeight();
|
const viewportHeight = useViewportHeight();
|
||||||
const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)");
|
const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)");
|
||||||
|
|
||||||
if (
|
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||||
!active ||
|
|
||||||
!menuWidth ||
|
|
||||||
!menuHeight ||
|
|
||||||
!menuRef.current ||
|
|
||||||
isSelectingText
|
|
||||||
) {
|
|
||||||
return defaultPosition;
|
return defaultPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,12 +165,15 @@ const FloatingToolbar = React.forwardRef(
|
|||||||
const menuRef = ref || React.createRef<HTMLDivElement>();
|
const menuRef = ref || React.createRef<HTMLDivElement>();
|
||||||
const [isSelectingText, setSelectingText] = React.useState(false);
|
const [isSelectingText, setSelectingText] = React.useState(false);
|
||||||
|
|
||||||
const position = usePosition({
|
let position = usePosition({
|
||||||
menuRef,
|
menuRef,
|
||||||
isSelectingText,
|
|
||||||
active: props.active,
|
active: props.active,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isSelectingText) {
|
||||||
|
position = defaultPosition;
|
||||||
|
}
|
||||||
|
|
||||||
useEventListener("mouseup", () => {
|
useEventListener("mouseup", () => {
|
||||||
setSelectingText(false);
|
setSelectingText(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
@@ -8,8 +9,11 @@ import Avatar from "~/components/Avatar";
|
|||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import useRequest from "~/hooks/useRequest";
|
import useRequest from "~/hooks/useRequest";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import CommandMenu, { Props } from "./CommandMenu";
|
import { useEditor } from "./EditorContext";
|
||||||
import MentionMenuItem from "./MentionMenuItem";
|
import MentionMenuItem from "./MentionMenuItem";
|
||||||
|
import SuggestionsMenu, {
|
||||||
|
Props as SuggestionsMenuProps,
|
||||||
|
} from "./SuggestionsMenu";
|
||||||
|
|
||||||
interface MentionItem extends MenuItem {
|
interface MentionItem extends MenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,15 +28,16 @@ interface MentionItem extends MenuItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type MentionMenuProps = Omit<
|
type Props = Omit<
|
||||||
Props<MentionItem>,
|
SuggestionsMenuProps<MentionItem>,
|
||||||
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch"
|
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function MentionMenu({ search, ...rest }: MentionMenuProps) {
|
function MentionMenu({ search, ...rest }: Props) {
|
||||||
const [items, setItems] = React.useState<MentionItem[]>([]);
|
const [items, setItems] = React.useState<MentionItem[]>([]);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { users, auth } = useStores();
|
const { users, auth } = useStores();
|
||||||
|
const { view } = useEditor();
|
||||||
const { data, request } = useRequest(
|
const { data, request } = useRequest(
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
() => users.fetchPage({ query: search, filter: "active" }),
|
() => users.fetchPage({ query: search, filter: "active" }),
|
||||||
@@ -65,7 +70,7 @@ function MentionMenu({ search, ...rest }: MentionMenuProps) {
|
|||||||
}, [auth.user?.id, data]);
|
}, [auth.user?.id, data]);
|
||||||
|
|
||||||
const clearSearch = () => {
|
const clearSearch = () => {
|
||||||
const { state, dispatch } = rest.view;
|
const { state, dispatch } = view;
|
||||||
|
|
||||||
// clear search input
|
// clear search input
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -77,11 +82,9 @@ function MentionMenu({ search, ...rest }: MentionMenuProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerId = "mention-menu-container";
|
|
||||||
return (
|
return (
|
||||||
<CommandMenu
|
<SuggestionsMenu
|
||||||
{...rest}
|
{...rest}
|
||||||
id={containerId}
|
|
||||||
filterable={false}
|
filterable={false}
|
||||||
onClearSearch={clearSearch}
|
onClearSearch={clearSearch}
|
||||||
search={search}
|
search={search}
|
||||||
@@ -91,7 +94,6 @@ function MentionMenu({ search, ...rest }: MentionMenuProps) {
|
|||||||
selected={options.selected}
|
selected={options.selected}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
label={item.attrs.label}
|
label={item.attrs.label}
|
||||||
containerId={containerId}
|
|
||||||
icon={
|
icon={
|
||||||
<Flex
|
<Flex
|
||||||
align="center"
|
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 * as React from "react";
|
||||||
import CommandMenuItem, {
|
import SuggestionsMenuItem, {
|
||||||
Props as CommandMenuItemProps,
|
Props as SuggestionsMenuItemProps,
|
||||||
} from "./CommandMenuItem";
|
} from "./SuggestionsMenuItem";
|
||||||
|
|
||||||
type MentionMenuItemProps = Omit<CommandMenuItemProps, "shortcut" | "theme"> & {
|
type MentionMenuItemProps = Omit<
|
||||||
|
SuggestionsMenuItemProps,
|
||||||
|
"shortcut" | "theme"
|
||||||
|
> & {
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,5 +14,5 @@ export default function MentionMenuItem({
|
|||||||
label,
|
label,
|
||||||
...rest
|
...rest
|
||||||
}: MentionMenuItemProps) {
|
}: MentionMenuItemProps) {
|
||||||
return <CommandMenuItem {...rest} title={label} />;
|
return <SuggestionsMenuItem {...rest} title={label} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { some } from "lodash";
|
import { some } from "lodash";
|
||||||
import { NodeSelection, TextSelection } from "prosemirror-state";
|
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||||
import { CellSelection } from "prosemirror-tables";
|
import { CellSelection } from "prosemirror-tables";
|
||||||
import { EditorView } from "prosemirror-view";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||||
import { CommandFactory } from "@shared/editor/lib/Extension";
|
|
||||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||||
import getColumnIndex from "@shared/editor/queries/getColumnIndex";
|
import getColumnIndex from "@shared/editor/queries/getColumnIndex";
|
||||||
import getMarkRange from "@shared/editor/queries/getMarkRange";
|
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 isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||||
import { MenuItem } from "@shared/editor/types";
|
import { MenuItem } from "@shared/editor/types";
|
||||||
import { creatingUrlPrefix } from "@shared/utils/urls";
|
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 getDividerMenuItems from "../menus/divider";
|
||||||
import getFormattingMenuItems from "../menus/formatting";
|
import getFormattingMenuItems from "../menus/formatting";
|
||||||
import getImageMenuItems from "../menus/image";
|
import getImageMenuItems from "../menus/image";
|
||||||
import getTableMenuItems from "../menus/table";
|
import getTableMenuItems from "../menus/table";
|
||||||
import getTableColMenuItems from "../menus/tableCol";
|
import getTableColMenuItems from "../menus/tableCol";
|
||||||
import getTableRowMenuItems from "../menus/tableRow";
|
import getTableRowMenuItems from "../menus/tableRow";
|
||||||
|
import { useEditor } from "./EditorContext";
|
||||||
import FloatingToolbar from "./FloatingToolbar";
|
import FloatingToolbar from "./FloatingToolbar";
|
||||||
import LinkEditor, { SearchResult } from "./LinkEditor";
|
import LinkEditor, { SearchResult } from "./LinkEditor";
|
||||||
import ToolbarMenu from "./ToolbarMenu";
|
import ToolbarMenu from "./ToolbarMenu";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
dictionary: Dictionary;
|
|
||||||
rtl: boolean;
|
rtl: boolean;
|
||||||
isTemplate: boolean;
|
isTemplate: boolean;
|
||||||
commands: Record<string, CommandFactory>;
|
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||||
@@ -37,15 +36,12 @@ type Props = {
|
|||||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||||
) => void;
|
) => void;
|
||||||
onCreateLink?: (title: string) => Promise<string>;
|
onCreateLink?: (title: string) => Promise<string>;
|
||||||
onShowToast: (message: string) => void;
|
|
||||||
view: EditorView;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isVisible(props: Props) {
|
function useIsActive(state: EditorState) {
|
||||||
const { view } = props;
|
const { selection, doc } = state;
|
||||||
const { selection, doc } = view.state;
|
|
||||||
|
|
||||||
if (isMarkActive(view.state.schema.marks.link)(view.state)) {
|
if (isMarkActive(state.schema.marks.link)(state)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!selection || selection.empty) {
|
if (!selection || selection.empty) {
|
||||||
@@ -76,57 +72,56 @@ function isVisible(props: Props) {
|
|||||||
return some(nodes, (n) => n.content.size);
|
return some(nodes, (n) => n.content.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class SelectionToolbar extends React.Component<Props> {
|
export default function SelectionToolbar(props: Props) {
|
||||||
isActive = false;
|
const { onClose, onOpen } = props;
|
||||||
menuRef = React.createRef<HTMLDivElement>();
|
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 {
|
// Trigger callbacks when the toolbar is opened or closed
|
||||||
const visible = isVisible(this.props);
|
if (previousIsActuve && !isActive) {
|
||||||
if (this.isActive && !visible) {
|
onClose();
|
||||||
this.isActive = false;
|
}
|
||||||
this.props.onClose();
|
if (!previousIsActuve && isActive) {
|
||||||
}
|
onOpen();
|
||||||
if (!this.isActive && visible) {
|
|
||||||
this.isActive = true;
|
|
||||||
this.props.onOpen();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount(): void {
|
React.useEffect(() => {
|
||||||
window.addEventListener("mouseup", this.handleClickOutside);
|
const handleClickOutside = (ev: MouseEvent): void => {
|
||||||
}
|
if (
|
||||||
|
ev.target instanceof HTMLElement &&
|
||||||
|
menuRef.current &&
|
||||||
|
menuRef.current.contains(ev.target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
componentWillUnmount(): void {
|
if (!isActive || document.activeElement?.tagName === "INPUT") {
|
||||||
window.removeEventListener("mouseup", this.handleClickOutside);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClickOutside = (ev: MouseEvent): void => {
|
if (view.hasFocus()) {
|
||||||
if (
|
return;
|
||||||
ev.target instanceof HTMLElement &&
|
}
|
||||||
this.menuRef.current &&
|
|
||||||
this.menuRef.current.contains(ev.target)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isActive || document.activeElement?.tagName === "INPUT") {
|
const { dispatch } = view;
|
||||||
return;
|
dispatch(
|
||||||
}
|
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const { view } = this.props;
|
window.addEventListener("mouseup", handleClickOutside);
|
||||||
if (view.hasFocus()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dispatch } = view;
|
return () => {
|
||||||
|
window.removeEventListener("mouseup", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isActive, view]);
|
||||||
|
|
||||||
dispatch(
|
const handleOnCreateLink = async (title: string): Promise<void> => {
|
||||||
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
|
const { onCreateLink } = props;
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOnCreateLink = async (title: string): Promise<void> => {
|
|
||||||
const { dictionary, onCreateLink, view, onShowToast } = this.props;
|
|
||||||
|
|
||||||
if (!onCreateLink) {
|
if (!onCreateLink) {
|
||||||
return;
|
return;
|
||||||
@@ -156,7 +151,7 @@ export default class SelectionToolbar extends React.Component<Props> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
handleOnSelectLink = ({
|
const handleOnSelectLink = ({
|
||||||
href,
|
href,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
@@ -165,7 +160,6 @@ export default class SelectionToolbar extends React.Component<Props> {
|
|||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
}): void => {
|
}): void => {
|
||||||
const { view } = this.props;
|
|
||||||
const { state, dispatch } = view;
|
const { state, dispatch } = view;
|
||||||
|
|
||||||
const markType = state.schema.marks.link;
|
const markType = state.schema.marks.link;
|
||||||
@@ -177,76 +171,75 @@ export default class SelectionToolbar extends React.Component<Props> {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
const { onCreateLink, isTemplate, rtl, ...rest } = props;
|
||||||
const { dictionary, onCreateLink, isTemplate, rtl, ...rest } = this.props;
|
const { state } = view;
|
||||||
const { view } = rest;
|
const { selection }: { selection: any } = state;
|
||||||
const { state } = view;
|
const isCodeSelection = isNodeActive(state.schema.nodes.code_block)(state);
|
||||||
const { selection }: { selection: any } = state;
|
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(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
|
// toolbar is disabled in code blocks, no bold / italic etc
|
||||||
if (isCodeSelection) {
|
if (isCodeSelection) {
|
||||||
return null;
|
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||||
|
import { usePortalContext } from "~/components/Portal";
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -10,18 +11,17 @@ export type Props = {
|
|||||||
icon?: React.ReactElement;
|
icon?: React.ReactElement;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
containerId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function CommandMenuItem({
|
function SuggestionsMenuItem({
|
||||||
selected,
|
selected,
|
||||||
disabled,
|
disabled,
|
||||||
onClick,
|
onClick,
|
||||||
title,
|
title,
|
||||||
shortcut,
|
shortcut,
|
||||||
icon,
|
icon,
|
||||||
containerId = "block-menu-container",
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const portal = usePortalContext();
|
||||||
const ref = React.useCallback(
|
const ref = React.useCallback(
|
||||||
(node) => {
|
(node) => {
|
||||||
if (selected && node) {
|
if (selected && node) {
|
||||||
@@ -30,14 +30,14 @@ function CommandMenuItem({
|
|||||||
block: "nearest",
|
block: "nearest",
|
||||||
boundary: (parent) => {
|
boundary: (parent) => {
|
||||||
// All the parent elements of your target are checked until they
|
// 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
|
// elements from being scrolled
|
||||||
return parent.id !== containerId;
|
return parent !== portal;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selected, containerId]
|
[selected, portal]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,4 +60,4 @@ const Shortcut = styled.span<{ $active?: boolean }>`
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default CommandMenuItem;
|
export default SuggestionsMenuItem;
|
||||||
@@ -564,6 +564,16 @@ export class Editor extends React.PureComponent<
|
|||||||
this.view.focus();
|
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.
|
* Returns true if the trimmed content of the editor is an empty string.
|
||||||
*
|
*
|
||||||
@@ -733,7 +743,6 @@ export class Editor extends React.PureComponent<
|
|||||||
grow,
|
grow,
|
||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
dictionary,
|
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { isRTL } = this.state;
|
const { isRTL } = this.state;
|
||||||
@@ -762,9 +771,6 @@ export class Editor extends React.PureComponent<
|
|||||||
{!readOnly && this.view && (
|
{!readOnly && this.view && (
|
||||||
<>
|
<>
|
||||||
<SelectionToolbar
|
<SelectionToolbar
|
||||||
view={this.view}
|
|
||||||
dictionary={dictionary}
|
|
||||||
commands={this.commands}
|
|
||||||
rtl={isRTL}
|
rtl={isRTL}
|
||||||
isTemplate={this.props.template === true}
|
isTemplate={this.props.template === true}
|
||||||
onOpen={this.handleOpenSelectionMenu}
|
onOpen={this.handleOpenSelectionMenu}
|
||||||
@@ -772,7 +778,6 @@ export class Editor extends React.PureComponent<
|
|||||||
onSearchLink={this.props.onSearchLink}
|
onSearchLink={this.props.onSearchLink}
|
||||||
onClickLink={this.props.onClickLink}
|
onClickLink={this.props.onClickLink}
|
||||||
onCreateLink={this.props.onCreateLink}
|
onCreateLink={this.props.onCreateLink}
|
||||||
onShowToast={this.props.onShowToast}
|
|
||||||
/>
|
/>
|
||||||
<LinkToolbar
|
<LinkToolbar
|
||||||
isActive={this.state.linkMenuOpen}
|
isActive={this.state.linkMenuOpen}
|
||||||
@@ -782,29 +787,18 @@ export class Editor extends React.PureComponent<
|
|||||||
onClose={this.handleCloseLinkMenu}
|
onClose={this.handleCloseLinkMenu}
|
||||||
/>
|
/>
|
||||||
<EmojiMenu
|
<EmojiMenu
|
||||||
view={this.view}
|
|
||||||
commands={this.commands}
|
|
||||||
dictionary={dictionary}
|
|
||||||
rtl={isRTL}
|
rtl={isRTL}
|
||||||
onShowToast={this.props.onShowToast}
|
|
||||||
isActive={this.state.emojiMenuOpen}
|
isActive={this.state.emojiMenuOpen}
|
||||||
search={this.state.blockMenuSearch}
|
search={this.state.blockMenuSearch}
|
||||||
onClose={this.handleCloseEmojiMenu}
|
onClose={this.handleCloseEmojiMenu}
|
||||||
/>
|
/>
|
||||||
<MentionMenu
|
<MentionMenu
|
||||||
view={this.view}
|
|
||||||
commands={this.commands}
|
|
||||||
dictionary={dictionary}
|
|
||||||
rtl={isRTL}
|
rtl={isRTL}
|
||||||
onShowToast={this.props.onShowToast}
|
|
||||||
isActive={this.state.mentionMenuOpen}
|
isActive={this.state.mentionMenuOpen}
|
||||||
search={this.state.blockMenuSearch}
|
search={this.state.blockMenuSearch}
|
||||||
onClose={this.handleCloseMentionMenu}
|
onClose={this.handleCloseMentionMenu}
|
||||||
/>
|
/>
|
||||||
<BlockMenu
|
<BlockMenu
|
||||||
view={this.view}
|
|
||||||
commands={this.commands}
|
|
||||||
dictionary={dictionary}
|
|
||||||
rtl={isRTL}
|
rtl={isRTL}
|
||||||
isActive={this.state.blockMenuOpen}
|
isActive={this.state.blockMenuOpen}
|
||||||
search={this.state.blockMenuSearch}
|
search={this.state.blockMenuSearch}
|
||||||
@@ -813,7 +807,6 @@ export class Editor extends React.PureComponent<
|
|||||||
onLinkToolbarOpen={this.handleOpenLinkMenu}
|
onLinkToolbarOpen={this.handleOpenLinkMenu}
|
||||||
onFileUploadStart={this.props.onFileUploadStart}
|
onFileUploadStart={this.props.onFileUploadStart}
|
||||||
onFileUploadStop={this.props.onFileUploadStop}
|
onFileUploadStop={this.props.onFileUploadStop}
|
||||||
onShowToast={this.props.onShowToast}
|
|
||||||
embeds={this.props.embeds}
|
embeds={this.props.embeds}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user