Files
outline/app/scenes/Document/components/Header.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

356 lines
11 KiB
TypeScript

import { observer } from "mobx-react";
import { TableOfContentsIcon, EditIcon } from "outline-icons";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import useMeasure from "react-use-measure";
import { NavigationNode } from "@shared/types";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { Action, Separator } from "~/components/Actions";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import Collaborators from "~/components/Collaborators";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import Header from "~/components/Header";
import Star from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents";
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
import { restoreRevision } from "~/actions/definitions/revisions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useEditingFocus from "~/hooks/useEditingFocus";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
import TemplatesMenu from "~/menus/TemplatesMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
import { AppearanceAction } from "~/components/Sharing/components/Actions";
type Props = {
document: Document;
revision: Revision | undefined;
sharedTree: NavigationNode | undefined;
shareId: string | null | undefined;
isDraft: boolean;
isEditing: boolean;
isSaving: boolean;
isPublishing: boolean;
publishingIsDisabled: boolean;
savingIsDisabled: boolean;
onSelectTemplate: (template: Document) => void;
onSave: (options: {
done?: boolean;
publish?: boolean;
autosave?: boolean;
}) => void;
};
function DocumentHeader({
document,
revision,
shareId,
isEditing,
isDraft,
isPublishing,
isSaving,
savingIsDisabled,
publishingIsDisabled,
sharedTree,
onSelectTemplate,
onSave,
}: Props) {
const { t } = useTranslation();
const { ui } = useStores();
const theme = useTheme();
const team = useCurrentTeam({ rejectOnEmpty: false });
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobileMedia = useMobile();
const isRevision = !!revision;
const isEditingFocus = useEditingFocus();
const { hasHeadings, editor } = useDocumentContext();
const sidebarContext = useLocationSidebarContext();
const [measureRef, size] = useMeasure();
const isMobile = isMobileMedia || size.width < 700;
const isShare = !!shareId;
// We cache this value for as long as the component is mounted so that if you
// apply a template there is still the option to replace it until the user
// navigates away from the doc
const [isNew] = useState(document.isPersistedOnce);
const handleSave = useCallback(() => {
onSave({
done: true,
});
}, [onSave]);
const handleToggle = useCallback(() => {
// Public shares, by default, show ToC on load.
if (isShare && ui.tocVisible === undefined) {
ui.set({ tocVisible: false });
} else {
ui.set({ tocVisible: !ui.tocVisible });
}
}, [ui, isShare]);
const can = usePolicy(document);
const { isDeleted, isTemplate } = document;
const isTemplateEditable = can.update && isTemplate;
const canToggleEmbeds = team?.documentEmbeds;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const toc = (
<Tooltip
content={
showContents
? t("Hide contents")
: hasHeadings
? t("Show contents")
: `${t("Show contents")} (${t("available when headings are added")})`
}
shortcut={`Ctrl+${altDisplay}+h`}
placement="bottom"
>
<Button
aria-label={t("Show contents")}
onClick={handleToggle}
icon={<TableOfContentsIcon />}
borderOnHover
neutral
/>
</Tooltip>
);
const editAction = (
<Action>
<Tooltip
content={t("Edit {{noun}}", {
noun: document.noun,
})}
shortcut="e"
placement="bottom"
>
<Button
as={Link}
icon={<EditIcon />}
to={{
pathname: documentEditPath(document),
state: { sidebarContext },
}}
neutral
>
{isMobile ? null : t("Edit")}
</Button>
</Tooltip>
</Action>
);
useKeyDown(
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
handleToggle,
{
allowInInput: true,
}
);
if (shareId) {
return (
<StyledHeader
ref={measureRef}
$hidden={isEditingFocus}
title={
<Flex gap={4}>
{document.icon && (
<Icon value={document.icon} color={document.color ?? undefined} />
)}
{document.title}
</Flex>
}
hasSidebar={sharedTree && sharedTree.children?.length > 0}
left={
isMobile ? (
hasHeadings ? (
<TableOfContentsMenu />
) : null
) : (
<PublicBreadcrumb
documentId={document.id}
shareId={shareId}
sharedTree={sharedTree}
>
{hasHeadings ? toc : null}
</PublicBreadcrumb>
)
}
actions={
<>
<AppearanceAction />
{can.update && !isEditing ? editAction : <div />}
</>
}
/>
);
}
return (
<>
<StyledHeader
ref={measureRef}
$hidden={isEditingFocus}
hasSidebar
left={
isMobile ? (
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{document.isTemplate ? null : (
<>
{toc} <Star document={document} color={theme.textSecondary} />
</>
)}
</DocumentBreadcrumb>
)
}
title={
<Flex gap={4} align="center">
{document.icon && (
<Icon value={document.icon} color={document.color ?? undefined} />
)}
{document.title}
{document.isArchived && <Badge>{t("Archived")}</Badge>}
</Flex>
}
actions={({ isCompact }) => (
<>
<ObservingBanner />
{!isPublishing && isSaving && user?.separateEditMode && (
<Status>{t("Saving")}</Status>
)}
{!isDeleted && !isRevision && can.listViews && (
<Collaborators
document={document}
limit={isCompact ? 3 : undefined}
/>
)}
{(isEditing || !user?.separateEditMode) &&
!isTemplate &&
isNew &&
can.update && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && !isRevision && !isTemplate && can.update && (
<Action>
<ShareButton document={document} />
</Action>
)}
{(isEditing || isTemplateEditable) && (
<Action>
<Tooltip
content={t("Save")}
shortcut={`${metaDisplay}+enter`}
placement="bottom"
>
<Button
action={isTemplate ? navigateToTemplateSettings : undefined}
onClick={isTemplate ? undefined : handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
hideIcon
>
{isDraft ? t("Save draft") : t("Done editing")}
</Button>
</Tooltip>
</Action>
)}
{can.update &&
!isEditing &&
user?.separateEditMode &&
!isRevision &&
editAction}
{can.update &&
can.createChildDocument &&
!isRevision &&
!isCompact &&
!isMobile && (
<Action>
<NewChildDocumentMenu document={document} />
</Action>
)}
{revision && revision.createdAt !== document.updatedAt && (
<Action>
<Tooltip content={t("Restore version")} placement="bottom">
<Button action={restoreRevision} neutral hideOnActionDisabled>
{t("Restore")}
</Button>
</Tooltip>
</Action>
)}
{can.publish && (
<Action>
<Button
action={publishDocument}
disabled={publishingIsDisabled}
hideOnActionDisabled
hideIcon
>
{document.collectionId || document.isWorkspaceTemplate
? t("Publish")
: `${t("Publish")}`}
</Button>
</Action>
)}
{!isDeleted && <Separator />}
<Action>
<DocumentMenu
document={document}
align="end"
neutral
onSelectTemplate={onSelectTemplate}
onFindAndReplace={editor?.commands.openFindAndReplace}
showToggleEmbeds={canToggleEmbeds}
showDisplayOptions
/>
</Action>
</>
)}
/>
</>
);
}
const StyledHeader = styled(Header)<{ $hidden: boolean }>`
transition: opacity 500ms ease-in-out;
${(props) => props.$hidden && "opacity: 0;"}
`;
const Status = styled(Action)`
padding-left: 0;
padding-right: 4px;
color: ${(props) => props.theme.slate};
`;
export default observer(DocumentHeader);