Files
outline/app/menus/DocumentMenu.tsx
Tom Moor 1da18c3101 chore: Refactor useActionContext to use React context (#10140)
* chore: Refactor useActionContext to use React context

* Self review

* PR feedback
2025-09-09 23:22:46 +00:00

246 lines
6.5 KiB
TypeScript

import noop from "lodash/noop";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { SubscriptionType, UserPreference } from "@shared/types";
import Document from "~/models/Document";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import Switch from "~/components/Switch";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuSeparator } from "~/components/primitives/components/Menu";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
type Props = {
/** Document for which the menu is to be shown */
document: Document;
/** Alignment w.r.t trigger - defaults to start */
align?: "start" | "end";
/** Trigger's variant - renders nude variant if unset */
neutral?: boolean;
/** Pass true if the document is currently being displayed */
showDisplayOptions?: boolean;
/** Whether to include the option of toggling embeds as menu item */
showToggleEmbeds?: boolean;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Invoked when menu is opened */
onOpen?: () => void;
/** Invoked when menu is closed */
onClose?: () => void;
};
function DocumentMenu({
document,
align,
neutral,
showToggleEmbeds,
showDisplayOptions,
onSelectTemplate,
onRename,
onOpen,
onClose,
onFindAndReplace,
}: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const isMobile = useMobile();
const can = usePolicy(document);
const { userMemberships, groupMemberships, subscriptions, pins } =
useStores();
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
groupMemberships.getByDocumentId(document.id)
);
const {
loading: auxDataLoading,
loaded: auxDataLoaded,
request: auxDataRequest,
} = useRequest(() =>
Promise.all([
subscriptions.fetchOne({
documentId: document.id,
event: SubscriptionType.Document,
}),
document.collectionId
? subscriptions.fetchOne({
collectionId: document.collectionId,
event: SubscriptionType.Document,
})
: noop,
pins.fetchOne({
documentId: document.id,
collectionId: document.collectionId ?? null,
}),
])
);
const handlePointerEnter = React.useCallback(() => {
if (!auxDataLoading && !auxDataLoaded) {
void auxDataRequest();
void document.loadRelations();
}
}, [auxDataLoading, auxDataLoaded, auxDataRequest, document]);
const handleEmbedsToggle = React.useCallback(
(checked: boolean) => {
if (checked) {
document.enableEmbeds();
} else {
document.disableEmbeds();
}
},
[document]
);
const handleFullWidthToggle = React.useCallback(
(checked: boolean) => {
user.setPreference(UserPreference.FullWidthDocuments, checked);
void user.save();
document.fullWidth = checked;
void document.save({ fullWidth: checked });
},
[user, document]
);
const handleInsightsToggle = React.useCallback(
(checked: boolean) => {
void document.save({ insightsEnabled: checked });
},
[document]
);
const rootAction = useDocumentMenuAction({
document,
onFindAndReplace,
onRename,
onSelectTemplate,
});
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
if (!can.update || !(showDisplayOptions || showToggleEmbeds)) {
return;
}
return (
<>
<MenuSeparator />
<DisplayOptions>
{can.updateInsights && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Enable viewer insights")}
labelPosition="left"
checked={document.insightsEnabled}
onChange={handleInsightsToggle}
/>
</Style>
)}
{showToggleEmbeds && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Enable embeds")}
labelPosition="left"
checked={!document.embedsDisabled}
onChange={handleEmbedsToggle}
/>
</Style>
)}
{showDisplayOptions && !isMobile && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Full width")}
labelPosition="left"
checked={document.fullWidth}
onChange={handleFullWidthToggle}
/>
</Style>
)}
</DisplayOptions>
</>
);
}, [
t,
can.update,
can.updateInsights,
document.embedsDisabled,
document.fullWidth,
document.insightsEnabled,
isMobile,
showDisplayOptions,
showToggleEmbeds,
handleEmbedsToggle,
handleFullWidthToggle,
handleInsightsToggle,
]);
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
>
<DropdownMenu
action={rootAction}
align={align}
onOpen={onOpen}
onClose={onClose}
ariaLabel={t("Document options")}
append={toggleSwitches}
>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
</ActionContextProvider>
);
}
const ToggleMenuItem = styled(Switch)`
* {
font-weight: normal;
color: ${s("textSecondary")};
}
`;
const DisplayOptions = styled.div`
padding: 8px 0 0;
`;
const Style = styled.div`
padding: 12px;
${breakpoint("tablet")`
padding: 4px 12px;
font-size: 14px;
`};
`;
export default observer(DocumentMenu);