diff --git a/app/components/Sharing/Collection/PublicAccess.tsx b/app/components/Sharing/Collection/PublicAccess.tsx index 98a7173866..a489baaa74 100644 --- a/app/components/Sharing/Collection/PublicAccess.tsx +++ b/app/components/Sharing/Collection/PublicAccess.tsx @@ -1,7 +1,8 @@ +import copy from "copy-to-clipboard"; import debounce from "lodash/debounce"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; -import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons"; +import { CopyIcon, GlobeIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -22,6 +23,7 @@ import env from "~/env"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { ListItem } from "../components/ListItem"; +import ShareSettingsPopover from "../components/ShareSettingsPopover"; import { DomainPrefix, ShareLinkInput, StyledInfoIcon } from "../components"; type Props = { @@ -50,70 +52,24 @@ function InnerPublicAccess( setUrlId(share?.urlId); }, [share?.urlId]); - const handleIndexingChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - allowIndexing: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleSubscriptionsChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - allowSubscriptions: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleShowLastModifiedChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - showLastUpdated: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleShowTOCChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - showTOC: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - const handlePublishedChange = React.useCallback( async (checked: boolean) => { try { if (checked && !share) { setCreating(true); - await shares.create({ + const newShare = await shares.create({ type: "collection", collectionId: collection.id, published: true, }); + copy(newShare.url); + toast.success(t("Public link copied to clipboard")); } else if (share) { await share.save({ published: checked }); + if (checked) { + copy(share.url); + toast.success(t("Public link copied to clipboard")); + } } } catch (err) { toast.error(err.message); @@ -121,7 +77,7 @@ function InnerPublicAccess( setCreating(false); } }, - [share, shares, collection] + [t, share, shares, collection] ); const handleUrlChange = React.useMemo( @@ -172,7 +128,7 @@ function InnerPublicAccess( return (
{t("Allow anyone with the link to access")}} image={ @@ -194,123 +150,24 @@ function InnerPublicAccess( {!!share?.published && ( <> - - {t("Search engine indexing")}  - - - - - - - } - actions={ - - } - /> - {env.EMAIL_ENABLED && ( - - {t("Email subscriptions")}  - - - - - - + + inputRef.current?.focus()}> + {env.URL.replace(/https?:\/\//, "") + "/s/"} + } - actions={ - - } - /> - )} - - {t("Show last modified")}  - - - - - - - } - actions={ - - } - /> - - {t("Show table of contents")}  - - - - - - - } - actions={ - - } - /> - inputRef.current?.focus()}> - {env.URL.replace(/https?:\/\//, "") + "/s/"} - - } - > - {copyButton} - + > + {copyButton} + + + diff --git a/app/components/Sharing/Document/PublicAccess.tsx b/app/components/Sharing/Document/PublicAccess.tsx index 59dcdd29d6..5b5812c157 100644 --- a/app/components/Sharing/Document/PublicAccess.tsx +++ b/app/components/Sharing/Document/PublicAccess.tsx @@ -1,7 +1,8 @@ +import copy from "copy-to-clipboard"; import debounce from "lodash/debounce"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; -import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons"; +import { CopyIcon, GlobeIcon } from "outline-icons"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -22,6 +23,7 @@ import { ResizingHeightContainer } from "../../ResizingHeightContainer"; import Text from "../../Text"; import Tooltip from "../../Tooltip"; import { ListItem } from "../components/ListItem"; +import ShareSettingsPopover from "../components/ShareSettingsPopover"; import { DomainPrefix, ShareLinkInput, @@ -60,70 +62,24 @@ function PublicAccess( setUrlId(share?.urlId); }, [share?.urlId]); - const handleIndexingChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - allowIndexing: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleSubscriptionsChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - allowSubscriptions: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleShowLastModifiedChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - showLastUpdated: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleShowTOCChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - showTOC: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - const handlePublishedChange = React.useCallback( async (checked: boolean) => { try { if (checked && !share) { setCreating(true); - await shares.create({ + const newShare = await shares.create({ type: "document", documentId: document.id, published: true, }); + copy(newShare.url); + toast.success(t("Public link copied to clipboard")); } else if (share) { await share.save({ published: checked }); + if (checked) { + copy(share.url); + toast.success(t("Public link copied to clipboard")); + } } } catch (err) { toast.error(err.message); @@ -131,7 +87,7 @@ function PublicAccess( setCreating(false); } }, - [share, shares, document] + [t, share, shares, document] ); const handleUrlChange = React.useMemo( @@ -187,7 +143,7 @@ function PublicAccess( return (
{sharedParent && !document.isDraft ? ( @@ -236,133 +192,29 @@ function PublicAccess( /> - {share?.published && !sharedParent?.published && ( - <> - - {t("Search engine indexing")}  - - - - - - - } - actions={ - - } - /> - {env.EMAIL_ENABLED && ( - - {t("Email subscriptions")}  - - - - - - - } - actions={ - - } - /> - )} - - {t("Show last modified")}  - - - - - - - } - actions={ - - } - /> - - {t("Show table of contents")}  - - - - - - - } - actions={ - - } - /> - - )} - {sharedParent?.published && !document.isDraft ? ( {copyButton} ) : share?.published ? ( - inputRef.current?.focus()}> - {env.URL.replace(/https?:\/\//, "") + "/s/"} - - } - > - {copyButton} - + + inputRef.current?.focus()}> + {env.URL.replace(/https?:\/\//, "") + "/s/"} + + } + > + {copyButton} + + + ) : null} {share?.published && !share.includeChildDocuments ? ( diff --git a/app/components/Sharing/components/HeaderBranding.tsx b/app/components/Sharing/components/HeaderBranding.tsx new file mode 100644 index 0000000000..958c8fb06e --- /dev/null +++ b/app/components/Sharing/components/HeaderBranding.tsx @@ -0,0 +1,52 @@ +import { observer } from "mobx-react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import { ellipsis, s } from "@shared/styles"; +import { AvatarSize } from "~/components/Avatar"; +import Flex from "~/components/Flex"; +import TeamLogo from "~/components/TeamLogo"; +import useShareBranding from "~/hooks/useShareBranding"; +import type Share from "~/models/Share"; + +type Props = { + share: Share; +}; + +/** + * Renders the team or share-customized branding (logo + name) for shared + * documents that do not have a sidebar. + */ +function HeaderBranding({ share }: Props) { + const { t } = useTranslation(); + const { displayName, displayLogoUrl, displayLogoModel, brandingAvailable } = + useShareBranding(share); + + if (!brandingAvailable) { + return null; + } + + return ( + + + {displayName && {displayName}} + + ); +} + +const Wrapper = styled(Flex)` + min-width: 0; + color: ${s("text")}; +`; + +const Name = styled.span` + ${ellipsis()} + font-size: 15px; + font-weight: 500; +`; + +export default observer(HeaderBranding); diff --git a/app/components/Sharing/components/ShareSettingsPopover.tsx b/app/components/Sharing/components/ShareSettingsPopover.tsx new file mode 100644 index 0000000000..6cf32c8ed5 --- /dev/null +++ b/app/components/Sharing/components/ShareSettingsPopover.tsx @@ -0,0 +1,428 @@ +import debounce from "lodash/debounce"; +import uniqueId from "lodash/uniqueId"; +import { observer } from "mobx-react"; +import { + ImageIcon, + QuestionMarkIcon, + SettingsIcon, + TrashIcon, +} from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import styled, { useTheme } from "styled-components"; +import { s } from "@shared/styles"; +import { HStack } from "~/components/primitives/HStack"; +import { AttachmentPreset } from "@shared/types"; +import { AttachmentValidation } from "@shared/validations"; +import type Share from "~/models/Share"; +import { createAction } from "~/actions"; +import { ShareSection } from "~/actions/sections"; +import { AvatarSize } from "~/components/Avatar"; +import Input from "~/components/Input"; +import { DropdownMenu } from "~/components/Menu/DropdownMenu"; +import NudeButton from "~/components/NudeButton"; +import Switch from "~/components/Switch"; +import TeamLogo from "~/components/TeamLogo"; +import Text from "~/components/Text"; +import Tooltip from "~/components/Tooltip"; +import env from "~/env"; +import { useMenuAction } from "~/hooks/useMenuAction"; +import useStores from "~/hooks/useStores"; +import { compressImage } from "~/utils/compressImage"; +import { uploadFile } from "~/utils/files"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "~/components/primitives/Popover"; +import { ListItem } from "./ListItem"; + +type Props = { + /** The share model to configure settings for. */ + share: Share; + /** Custom trigger element. If not provided, a default settings icon button is rendered. */ + children?: React.ReactElement; +}; + +/** + * A popover triggered by a settings icon that contains toggle options + * for configuring a published share link (indexing, subscriptions, etc.), + * as well as custom title and logo branding. + */ +function ShareSettingsPopover({ share, children }: Props) { + const { t } = useTranslation(); + const { auth } = useStores(); + const theme = useTheme(); + const fileInputRef = React.useRef(null); + const hasChangesRef = React.useRef(false); + const [isUploading, setIsUploading] = React.useState(false); + const idPrefix = React.useMemo(() => uniqueId("share-settings-"), []); + const showLastUpdatedId = `${idPrefix}-show-last-updated`; + const showTOCId = `${idPrefix}-show-toc`; + const indexingId = `${idPrefix}-indexing`; + const subscriptionsId = `${idPrefix}-subscriptions`; + + const handleTitleChange = React.useMemo( + () => + debounce(async (ev: React.ChangeEvent) => { + const val = ev.target.value; + try { + await share.save({ title: val || null }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, 500), + [share] + ); + + const triggerUpload = React.useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleLogoUpload = React.useCallback( + async (ev: React.ChangeEvent) => { + const file = ev.target.files?.[0]; + if (!file) { + return; + } + + setIsUploading(true); + try { + const compressed = await compressImage(file, { + maxHeight: 512, + maxWidth: 512, + }); + const attachment = await uploadFile(compressed, { + name: file.name, + preset: AttachmentPreset.Avatar, + }); + await share.save({ iconUrl: attachment.url }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } finally { + setIsUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }, + [share] + ); + + const handleLogoRemove = React.useCallback(async () => { + try { + await share.save({ iconUrl: null }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, [share]); + + const handleIndexingChanged = React.useCallback( + async (checked: boolean) => { + try { + await share.save({ allowIndexing: checked }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + + const handleSubscriptionsChanged = React.useCallback( + async (checked: boolean) => { + try { + await share.save({ allowSubscriptions: checked }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + + const handleShowLastModifiedChanged = React.useCallback( + async (checked: boolean) => { + try { + await share.save({ showLastUpdated: checked }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + + const handleShowTOCChanged = React.useCallback( + async (checked: boolean) => { + try { + await share.save({ showTOC: checked }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + + const flushChangeToast = React.useCallback(() => { + if (hasChangesRef.current) { + toast.success(t("Sharing settings updated")); + hasChangesRef.current = false; + } + }, [t]); + + const handleOpenChange = React.useCallback( + (open: boolean) => { + if (!open) { + flushChangeToast(); + } + }, + [flushChangeToast] + ); + + // Also flush on unmount in case the parent popover closes us before + // onOpenChange fires. + React.useEffect( + () => () => { + flushChangeToast(); + }, + [flushChangeToast] + ); + + const iconActions = React.useMemo( + () => [ + createAction({ + name: ({ t: translate }) => translate("Upload image"), + analyticsName: "Upload share icon", + section: ShareSection, + icon: , + perform: triggerUpload, + }), + createAction({ + name: ({ t: translate }) => translate("Remove image"), + analyticsName: "Remove share icon", + section: ShareSection, + icon: , + dangerous: true, + perform: handleLogoRemove, + }), + ], + [triggerUpload, handleLogoRemove] + ); + const iconRootAction = useMenuAction(iconActions); + + return ( + + + + {children ?? ( + + + + )} + + + + + {t("Display settings")} + + + {t("Customize how the published document is displayed")} + + + + {share.iconUrl ? ( + + + + + + ) : ( + + + + )} + + + + {t("Show last modified")}  + + + + + + + } + actions={ + + } + /> + + {t("Show table of contents")}  + + + + + + + } + actions={ + + } + /> + + {t("Behavior")} + + + {t("Search engine indexing")}  + + + + + + + } + actions={ + + } + /> + {env.EMAIL_ENABLED && ( + + {t("Email subscriptions")}  + + + + + + + } + actions={ + + } + /> + )} + + + ); +} + +const SwitchLabel = styled.label` + display: flex; + align-items: center; + color: ${s("textSecondary")}; + cursor: var(--pointer); +`; + +const SettingsTrigger = styled(NudeButton)` + width: 32px; + height: 32px; + flex-shrink: 0; + position: relative; + top: -2px; + right: -4px; +`; + +const LogoButton = styled.button` + background: none; + border: 0; + padding: 0; + cursor: var(--pointer); + flex-shrink: 0; + + &:disabled { + opacity: 0.5; + cursor: default; + } +`; + +export default observer(ShareSettingsPopover); diff --git a/app/components/Sidebar/Shared.tsx b/app/components/Sidebar/Shared.tsx index aa778b73d5..e6266d9afa 100644 --- a/app/components/Sidebar/Shared.tsx +++ b/app/components/Sidebar/Shared.tsx @@ -11,11 +11,11 @@ import type Share from "~/models/Share"; import Flex from "~/components/Flex"; import Scrollable from "~/components/Scrollable"; import useCurrentUser from "~/hooks/useCurrentUser"; +import useShareBranding from "~/hooks/useShareBranding"; import useStores from "~/hooks/useStores"; import history from "~/utils/history"; import { homePath, sharedModelPath } from "~/utils/routeHelpers"; import { AvatarSize } from "../Avatar"; -import { useTeamContext } from "../TeamContext"; import TeamLogo from "../TeamLogo"; import Sidebar from "./Sidebar"; import Section from "./components/Section"; @@ -28,13 +28,13 @@ type Props = { }; function SharedSidebar({ share }: Props) { - const team = useTeamContext(); const user = useCurrentUser({ rejectOnEmpty: false }); const { ui, documents, collections } = useStores(); const { t } = useTranslation(); const { query } = useKBar(); - const teamAvailable = !!team?.name; + const { displayName, displayLogoUrl, displayLogoModel, brandingAvailable } = + useShareBranding(share); const rootNode = share.tree; const shareId = share.urlId || share.id; const collection = collections.get(rootNode?.id); @@ -56,11 +56,16 @@ function SharedSidebar({ share }: Props) { return ( - {teamAvailable && ( + {brandingAvailable && ( + } disabled={hideRootNode} onClick={ diff --git a/app/components/primitives/Popover.tsx b/app/components/primitives/Popover.tsx index cca40e4947..c1b4510127 100644 --- a/app/components/primitives/Popover.tsx +++ b/app/components/primitives/Popover.tsx @@ -44,7 +44,7 @@ const PopoverContent = React.forwardRef< const timeoutRef = React.useRef(); const container = usePortalContext(); const { - width = 380, + width, minWidth, minHeight, scrollable = true, @@ -53,6 +53,7 @@ const PopoverContent = React.forwardRef< children, ...rest } = props; + const resolvedWidth = width ?? (minWidth ? undefined : 380); const enablePointerEvents = React.useCallback(() => { if (timeoutRef.current) { @@ -78,7 +79,7 @@ const PopoverContent = React.forwardRef< User, { onDelete: "null" }) createdBy: User; - @computed - get title(): string { - return this.sourceTitle ?? this.documentTitle; - } - @computed get sourcePathWithFallback(): string { return this.sourcePath ?? this.documentUrl; @@ -101,7 +106,7 @@ class Share extends Model implements Searchable { @computed get searchContent(): string[] { - return [this.title]; + return [this.sourceTitle ?? this.documentTitle]; } @computed diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index fb21a1481f..f2273cbe69 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -38,6 +38,7 @@ import Header from "./Header"; import Notices from "./Notices"; import References from "./References"; import RevisionViewer from "./RevisionViewer"; +import SharedHeader from "./SharedHeader"; type LocationState = { title?: string; @@ -316,19 +317,25 @@ function DocumentScene({ )} /> )} -
+ {isShare ? ( + + ) : ( +
+ )}
0 && size.width < 700); // We cache this value for as long as the component is mounted so that if you @@ -112,19 +104,13 @@ function DocumentHeader({ }, [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]); + ui.set({ tocVisible: !ui.tocVisible }); + }, [ui]); const can = usePolicy(document); const { isDeleted } = document; const canToggleEmbeds = team?.documentEmbeds; - const showContents = - ui.tocVisible === true || (isShare && ui.tocVisible !== false); + const showContents = ui.tocVisible === true; useEffect(() => { if (isMobile && showContents) { @@ -186,192 +172,137 @@ function DocumentHeader({ } ); - if (shareId) { - return ( - - {document.icon && ( - - )} - {document.title} - - } - hasSidebar={sharedTree && sharedTree.children?.length > 0} - left={ - isMobile ? ( - hasHeadings ? ( - - ) : null - ) : ( - - {hasHeadings ? toc : null} - - ) - } - actions={ - <> - {allowSubscriptions !== false && !user && env.EMAIL_ENABLED && ( - - )} - - {can.update && !isEditing ? editAction :
} - - } - /> - ); - } - return ( - <> - - ) : ( - - {toc}{" "} - - - ) - } - title={ - - {document.icon && ( - - )} - {document.title} - {document.isArchived && {t("Archived")}} - {document.isDraft && {t("Draft")}} - - } - actions={({ isCompact }) => ( - <> - - - {!isDeleted && !isRevision && can.listViews && ( - + ) : ( + + {toc} + + ) + } + title={ + + {document.icon && ( + + )} + {document.title} + {document.isArchived && {t("Archived")}} + {document.isDraft && {t("Draft")}} + + } + actions={({ isCompact }) => ( + <> + + + {!isDeleted && !isRevision && can.listViews && ( + + )} + {(isEditing || !user?.separateEditMode) && wasNew && can.update && ( + + - )} - {(isEditing || !user?.separateEditMode) && wasNew && can.update && ( - - - - )} - {!isEditing && !isRevision && can.update && ( - - - - )} - {isEditing && ( - - + )} + {!isEditing && !isRevision && can.update && ( + + + + )} + {isEditing && ( + + + + + + )} + {can.update && + !isEditing && + user?.separateEditMode && + !isRevision && + editAction} + {can.update && + can.createChildDocument && + !isRevision && + !isCompact && + !isMobile && ( + + + + )} + {revision && ( + <> + + + + + - )} - {can.update && - !isEditing && - user?.separateEditMode && - !isRevision && - editAction} - {can.update && - can.createChildDocument && - !isRevision && - !isCompact && - !isMobile && ( - - - - )} - {revision && ( - <> - - - - - - - - - - )} - {can.publish && ( - - - - )} - {!isDeleted && } + + )} + {can.publish && ( - + - - )} - /> - + )} + {!isDeleted && } + + + + + )} + /> ); } diff --git a/app/scenes/Document/components/SharedHeader.tsx b/app/scenes/Document/components/SharedHeader.tsx new file mode 100644 index 0000000000..41916f2e18 --- /dev/null +++ b/app/scenes/Document/components/SharedHeader.tsx @@ -0,0 +1,208 @@ +import { observer } from "mobx-react"; +import { TableOfContentsIcon, EditIcon, SettingsIcon } from "outline-icons"; +import { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import useMeasure from "react-use-measure"; +import styled from "styled-components"; +import Icon from "@shared/components/Icon"; +import useShare from "@shared/hooks/useShare"; +import { altDisplay } from "@shared/utils/keyboard"; +import { Action } from "~/components/Actions"; +import Button from "~/components/Button"; +import { useDocumentContext } from "~/components/DocumentContext"; +import Flex from "~/components/Flex"; +import Header from "~/components/Header"; +import { + AppearanceAction, + SubscribeAction, +} from "~/components/Sharing/components/Actions"; +import HeaderBranding from "~/components/Sharing/components/HeaderBranding"; +import ShareSettingsPopover from "~/components/Sharing/components/ShareSettingsPopover"; +import Tooltip from "~/components/Tooltip"; +import env from "~/env"; +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 TableOfContentsMenu from "~/menus/TableOfContentsMenu"; +import type Document from "~/models/Document"; +import { documentEditPath } from "~/utils/routeHelpers"; +import PublicBreadcrumb from "./PublicBreadcrumb"; + +type Props = { + document: Document; +}; + +function SharedDocumentHeader({ document }: Props) { + const { t } = useTranslation(); + const { ui, shares } = useStores(); + const user = useCurrentUser({ rejectOnEmpty: false }); + const isMobileMedia = useMobile(); + const isEditingFocus = useEditingFocus(); + + // Set CSS variable for header offset (used by sticky table headers) + useEffect(() => { + window.document.documentElement.style.setProperty( + "--header-offset", + isEditingFocus ? "0px" : "64px" + ); + }, [isEditingFocus]); + + const { hasHeadings } = useDocumentContext(); + const sidebarContext = useLocationSidebarContext(); + const [measureRef, size] = useMeasure(); + const { shareId, sharedTree, allowSubscriptions } = useShare(); + const share = shareId ? shares.get(shareId) : undefined; + const isMobile = isMobileMedia || (size.width > 0 && size.width < 700); + + const handleToggle = useCallback(() => { + // Public shares, by default, show ToC on load. + if (ui.tocVisible === undefined) { + ui.set({ tocVisible: false }); + } else { + ui.set({ tocVisible: !ui.tocVisible }); + } + }, [ui]); + + const can = usePolicy(document); + const showContents = ui.tocVisible !== false; + + useEffect(() => { + if (isMobile && showContents) { + ui.set({ tocVisible: false }); + } + }, [isMobile, showContents, ui]); + + useKeyDown( + (event) => event.ctrlKey && event.altKey && event.code === "KeyH", + handleToggle, + { + allowInInput: true, + } + ); + + if (!shareId) { + return null; + } + + const toc = ( + + + + + ) : ( +
+ ); + + const hasSidebar = !!(sharedTree && sharedTree.children?.length); + + return ( + + {document.icon && ( + + )} + {document.title} + + } + hasSidebar={hasSidebar} + left={ + isMobile ? ( + hasHeadings ? ( + + ) : null + ) : hasSidebar ? ( + + {hasHeadings ? toc : null} + + ) : ( + + {share && } + {hasHeadings ? toc : null} + + ) + } + actions={ + <> + {allowSubscriptions !== false && !user && env.EMAIL_ENABLED && ( + + )} + + {can.update && share && ( + + +