This PR moves the entire project to Typescript. Due to the ~1000 ignores this will lead to a messy codebase for a while, but the churn is worth it – all of those ignore comments are places that were never type-safe previously. closes #1282
226 lines
5.5 KiB
TypeScript
226 lines
5.5 KiB
TypeScript
import { ExpandedIcon } from "outline-icons";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link, useLocation } from "react-router-dom";
|
|
import {
|
|
useMenuState,
|
|
MenuButton,
|
|
MenuItem as BaseMenuItem,
|
|
} from "reakit/Menu";
|
|
import styled from "styled-components";
|
|
import { $Shape } from "utility-types";
|
|
import Flex from "~/components/Flex";
|
|
import MenuIconWrapper from "~/components/MenuIconWrapper";
|
|
import { actionToMenuItem } from "~/actions";
|
|
import useStores from "~/hooks/useStores";
|
|
import {
|
|
Action,
|
|
ActionContext,
|
|
MenuSeparator,
|
|
MenuHeading,
|
|
MenuItem as TMenuItem,
|
|
} from "~/types";
|
|
import Header from "./Header";
|
|
import MenuItem, { MenuAnchor } from "./MenuItem";
|
|
import Separator from "./Separator";
|
|
import ContextMenu from ".";
|
|
|
|
type Props = {
|
|
actions?: (Action | MenuSeparator | MenuHeading)[];
|
|
context?: $Shape<ActionContext>;
|
|
items?: TMenuItem[];
|
|
};
|
|
|
|
const Disclosure = styled(ExpandedIcon)`
|
|
transform: rotate(270deg);
|
|
position: absolute;
|
|
right: 8px;
|
|
`;
|
|
|
|
const Submenu = React.forwardRef(
|
|
(
|
|
{
|
|
templateItems,
|
|
title,
|
|
...rest
|
|
}: { templateItems: TMenuItem[]; title: React.ReactNode },
|
|
ref: React.LegacyRef<HTMLButtonElement>
|
|
) => {
|
|
const { t } = useTranslation();
|
|
const menu = useMenuState({
|
|
modal: true,
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<MenuButton ref={ref} {...menu} {...rest}>
|
|
{(props) => (
|
|
<MenuAnchor {...props}>
|
|
{title} <Disclosure color="currentColor" />
|
|
</MenuAnchor>
|
|
)}
|
|
</MenuButton>
|
|
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
|
<Template {...menu} items={templateItems} />
|
|
</ContextMenu>
|
|
</>
|
|
);
|
|
}
|
|
);
|
|
|
|
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
|
let filtered = items.filter((item) => item.visible !== false);
|
|
|
|
// this block literally just trims unnecessary separators
|
|
filtered = filtered.reduce((acc, item, index) => {
|
|
// trim separators from start / end
|
|
if (item.type === "separator" && index === 0) return acc;
|
|
if (item.type === "separator" && index === filtered.length - 1) return acc;
|
|
|
|
// trim double separators looking ahead / behind
|
|
const prev = filtered[index - 1];
|
|
if (prev && prev.type === "separator" && item.type === "separator")
|
|
return acc;
|
|
|
|
// otherwise, continue
|
|
return [...acc, item];
|
|
}, []);
|
|
|
|
return filtered;
|
|
}
|
|
|
|
function Template({ items, actions, context, ...menu }: Props) {
|
|
const { t } = useTranslation();
|
|
const location = useLocation();
|
|
const stores = useStores();
|
|
const { ui } = stores;
|
|
const ctx = {
|
|
t,
|
|
isCommandBar: false,
|
|
isContextMenu: true,
|
|
activeCollectionId: ui.activeCollectionId,
|
|
activeDocumentId: ui.activeDocumentId,
|
|
location,
|
|
stores,
|
|
...context,
|
|
};
|
|
|
|
const templateItems = actions
|
|
? actions.map((item) =>
|
|
item.type === "separator" || item.type === "heading"
|
|
? item
|
|
: actionToMenuItem(item, ctx)
|
|
)
|
|
: items || [];
|
|
|
|
const filteredTemplates = filterTemplateItems(templateItems);
|
|
|
|
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
|
|
(item) =>
|
|
item.type !== "separator" && item.type !== "heading" && !!item.icon
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{filteredTemplates.map((item, index) => {
|
|
if (
|
|
iconIsPresentInAnyMenuItem &&
|
|
item.type !== "separator" &&
|
|
item.type !== "heading"
|
|
) {
|
|
item.icon = item.icon || <MenuIconWrapper />;
|
|
}
|
|
|
|
if (item.type === "route") {
|
|
return (
|
|
<MenuItem
|
|
as={Link}
|
|
to={item.to}
|
|
key={index}
|
|
disabled={item.disabled}
|
|
selected={item.selected}
|
|
icon={item.icon}
|
|
{...menu}
|
|
>
|
|
{item.title}
|
|
</MenuItem>
|
|
);
|
|
}
|
|
|
|
if (item.type === "link") {
|
|
return (
|
|
<MenuItem
|
|
href={item.href}
|
|
key={index}
|
|
disabled={item.disabled}
|
|
selected={item.selected}
|
|
level={item.level}
|
|
target={item.href.startsWith("#") ? undefined : "_blank"}
|
|
icon={item.icon}
|
|
{...menu}
|
|
>
|
|
{item.title}
|
|
</MenuItem>
|
|
);
|
|
}
|
|
|
|
if (item.type === "button") {
|
|
return (
|
|
<MenuItem
|
|
as="button"
|
|
onClick={item.onClick}
|
|
disabled={item.disabled}
|
|
selected={item.selected}
|
|
key={index}
|
|
icon={item.icon}
|
|
{...menu}
|
|
>
|
|
{item.title}
|
|
</MenuItem>
|
|
);
|
|
}
|
|
|
|
if (item.type === "submenu") {
|
|
return (
|
|
<BaseMenuItem
|
|
key={index}
|
|
as={Submenu}
|
|
templateItems={item.items}
|
|
title={<Title title={item.title} icon={item.icon} />}
|
|
{...menu}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (item.type === "separator") {
|
|
return <Separator key={index} />;
|
|
}
|
|
|
|
if (item.type === "heading") {
|
|
return <Header>{item.title}</Header>;
|
|
}
|
|
|
|
const _exhaustiveCheck: never = item;
|
|
return _exhaustiveCheck;
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Title({
|
|
title,
|
|
icon,
|
|
}: {
|
|
title: React.ReactNode;
|
|
icon?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Flex align="center">
|
|
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
|
{title}
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
export default React.memo<Props>(Template);
|