Files
outline/app/editor/components/SuggestionsMenu.tsx
2023-05-24 19:24:05 -07:00

690 lines
17 KiB
TypeScript

import commandScore from "command-score";
import { capitalize } from "lodash";
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 { findParentNode } from "@shared/editor/queries/findParentNode";
import { MenuItem } from "@shared/editor/types";
import { depths, s } 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 Logger from "~/utils/Logger";
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: -10000,
right: undefined,
isAbove: false,
};
export type Props<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
search: string;
trigger: string;
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onLinkToolbarOpen?: () => void;
onClose: (insertNewLine?: boolean) => 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) {
Logger.warn("Unable to calculate caret position", 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]
);
const handleClearSearch = React.useCallback(() => {
const { state, dispatch } = view;
const poss = state.doc.cut(
state.selection.from - (props.search ?? "").length - 1,
state.selection.from
);
const trimTrigger = poss.textContent.startsWith(props.trigger);
if (!props.search && !trimTrigger) {
return;
}
// clear search input
dispatch(
state.tr.insertText(
"",
state.selection.from -
(props.search ?? "").length -
(trimTrigger ? 1 : 0),
state.selection.to
)
);
}, [props.search, props.trigger, 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) => {
handleClearSearch();
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, handleClearSearch, 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": {
handleClearSearch();
props.onClose();
props.onLinkToolbarOpen?.();
return;
}
default:
insertNode(item);
}
},
[insertNode, handleClearSearch, 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);
handleClearSearch();
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) =>
searchInput && item.title ? commandScore(item.title, searchInput) : 0
)
);
}, [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, {
capture: true,
});
return () => {
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
});
};
}, [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: ${s("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: ${s("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: ${s("textSecondary")};
font-family: ${s("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: ${s("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 ${s("divider")};
}
${({ active, isAbove }) =>
active &&
`
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
pointer-events: all;
opacity: 1;
`};
@media print {
display: none;
}
`;
export default SuggestionsMenu;