import { observer } from "mobx-react"; import { AllSelection } from "prosemirror-state"; import { useRef, useCallback } from "react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Prompt, useHistory, useLocation } from "react-router-dom"; import { toast } from "sonner"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper"; import { s } from "@shared/styles"; import type { NavigationNode } from "@shared/types"; import { IconType, TOCPosition, TeamPreference } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; import { isModKey } from "@shared/utils/keyboard"; import type Document from "~/models/Document"; import type Revision from "~/models/Revision"; import DocumentMove from "~/components/DocumentExplorer/DocumentMove"; import DocumentPublish from "~/scenes/DocumentPublish"; import ErrorBoundary from "~/components/ErrorBoundary"; import LoadingIndicator from "~/components/LoadingIndicator"; import PageTitle from "~/components/PageTitle"; import PlaceholderDocument from "~/components/PlaceholderDocument"; import RegisterKeyDown from "~/components/RegisterKeyDown"; import { MeasuredContainer } from "~/components/MeasuredContainer"; import type { Editor as TEditor } from "~/editor"; import type { Properties } from "~/types"; import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; import useStores from "~/hooks/useStores"; import { client } from "~/utils/ApiClient"; import { emojiToUrl } from "~/utils/emoji"; import { documentHistoryPath, documentEditPath } from "~/utils/routeHelpers"; import { useDocumentSave } from "../hooks/useDocumentSave"; import Container from "./Container"; import Contents from "./Contents"; import Editor from "./Editor"; import Header from "./Header"; import Notices from "./Notices"; import References from "./References"; import RevisionViewer from "./RevisionViewer"; import SharedHeader from "./SharedHeader"; type LocationState = { title?: string; restore?: boolean; revisionId?: string; }; interface Props { /** Tree of navigation nodes for shared documents. */ sharedTree?: NavigationNode; /** Map of ability names to booleans representing current user permissions. */ abilities: Record; /** The document model being viewed or edited. */ document: Document; /** An optional revision to display instead of the live document. */ revision?: Revision; /** Whether the document is in read-only mode. */ readOnly: boolean; /** The share ID when viewing a publicly shared document. */ shareId?: string; /** Override for the table of contents position, or false to hide it. */ tocPosition?: TOCPosition | false; /** Callback to create a linked document from the editor. */ onCreateLink?: ( params: Properties, nested?: boolean ) => Promise; /** Optional children rendered after the main document content. */ children?: React.ReactNode; } /** Scene component responsible for rendering and interacting with a document. */ function DocumentScene({ document, revision, readOnly, abilities, shareId, tocPosition, onCreateLink, children, }: Props) { const { auth, ui, dialogs } = useStores(); const { t } = useTranslation(); const history = useHistory(); const location = useLocation(); const sidebarContext = useLocationSidebarContext(); const { team, user } = auth; const editorRef = useRef(null); const { isUploading, isSaving, isPublishing, isEditorDirty, isEmpty, onSave, replaceSelection, handleSelectTemplate, handleChangeTitle, handleChangeIcon, onFileUploadStart, onFileUploadStop, } = useDocumentSave({ document, editorRef, readOnly }); const onSynced = useCallback(async () => { const restore = location.state?.restore; const revisionId = location.state?.revisionId; const editor = editorRef.current; if (!editor) { return; } // Highlight search term when navigating from search results const params = new URLSearchParams(location.search); const searchTerm = params.get("q"); if (searchTerm) { editor.commands.find({ text: searchTerm }); } if (!restore) { return; } history.replace(document.url, { ...location.state, restore: undefined, revisionId: undefined, }); if (!revisionId) { return; } const response = await client.post("/revisions.info", { id: revisionId, }); if (response) { await replaceSelection( response.data, new AllSelection(editor.view.state.doc) ); toast.success(t("Document restored")); } }, [location, replaceSelection, t, history, document.url]); const onUndoRedo = useCallback( (event: KeyboardEvent) => { if (isModKey(event)) { event.preventDefault(); if (event.shiftKey) { if (!readOnly) { editorRef.current?.commands.redo(); } } else { if (!readOnly) { editorRef.current?.commands.undo(); } } } }, [readOnly] ); const onMove = useCallback( (ev: React.MouseEvent | KeyboardEvent) => { ev.preventDefault(); if (abilities.move) { dialogs.openModal({ title: t("Move document"), content: , }); } }, [document, dialogs, t, abilities.move] ); const goToEdit = useCallback( (ev: KeyboardEvent) => { if (readOnly) { ev.preventDefault(); if (abilities.update) { history.push({ pathname: documentEditPath(document), state: { sidebarContext }, }); } } else if (editorRef.current?.isBlurred) { ev.preventDefault(); editorRef.current?.focus(); } }, [readOnly, abilities.update, history, document, sidebarContext] ); const goToHistory = useCallback( (ev: KeyboardEvent) => { if (!readOnly) { return; } if (ev.ctrlKey) { return; } ev.preventDefault(); if (location.pathname.endsWith("history")) { history.push({ pathname: document.path, state: { sidebarContext }, }); } else { history.push({ pathname: documentHistoryPath(document), state: { sidebarContext }, }); } }, [readOnly, location.pathname, history, document, sidebarContext] ); const onPublish = useCallback( (ev: React.MouseEvent | KeyboardEvent) => { ev.preventDefault(); ev.stopPropagation(); if (document.publishedAt) { return; } if (document?.collectionId) { void onSave({ publish: true, done: true, }); } else { dialogs.openModal({ title: t("Publish document"), content: , }); } }, [document, dialogs, t, onSave] ); const handlePublishShortcut = useCallback( (event: KeyboardEvent) => { if (isModKey(event) && event.shiftKey) { onPublish(event); } }, [onPublish] ); const goBack = useCallback(() => { if (!readOnly) { history.push({ pathname: document.url, state: { sidebarContext }, }); } }, [readOnly, history, document, sidebarContext]); // Render const isShare = !!shareId; const embedsDisabled = (team && team.documentEmbeds === false) || document.embedsDisabled; const tocPos = tocPosition ?? ((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) || TOCPosition.Left); const showContents = tocPos && (isShare ? ui.tocVisible !== false : ui.tocVisible === true); const tocOffset = tocPos === TOCPosition.Left ? EditorStyleHelper.tocWidth / -2 : EditorStyleHelper.tocWidth / 2; const multiplayerEditor = !document.isArchived && !document.isDeleted && !revision && !isShare; const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji; const pageTitle = hasEmojiInTitle ? document.titleWithDefault.replace(document.icon!, "") : document.titleWithDefault; const favicon = hasEmojiInTitle ? emojiToUrl(document.icon!) : undefined; const fullWidthTransformOffsetStyle = { ["--full-width-transform-offset"]: `${document.fullWidth && showContents ? tocOffset : 0}px`, } as React.CSSProperties; return ( {(isUploading || isSaving) && } {!readOnly && ( )} {isShare ? ( ) : (
)}
} > {revision ? ( ) : ( <> {showContents && ( )} )} {showContents && ( )}
{children} ); } type MainProps = { fullWidth: boolean; tocPosition: TOCPosition | false; }; const Main = styled.div` margin-top: 4px; ${breakpoint("tablet")` display: grid; grid-template-columns: ${({ fullWidth, tocPosition }: MainProps) => fullWidth ? tocPosition === TOCPosition.Left ? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)` : `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px` : `1fr minmax(0, ${`calc(46em + ${EditorStyleHelper.documentGutter})`}) 1fr`}; `}; ${breakpoint("desktopLarge")` grid-template-columns: ${({ fullWidth, tocPosition }: MainProps) => fullWidth ? tocPosition === TOCPosition.Left ? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)` : `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px` : `1fr minmax(0, ${`calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`}) 1fr`}; `}; @media print { display: block; max-width: ${({ fullWidth }: MainProps) => fullWidth ? `100%` : `calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`}; } `; type ContentsContainerProps = { docFullWidth: boolean; position: TOCPosition | false; }; const ContentsContainer = styled.div` ${breakpoint("tablet")` margin-top: calc(44px + 6vh); grid-row: 1; grid-column: ${({ docFullWidth, position }: ContentsContainerProps) => position === TOCPosition.Left ? 1 : docFullWidth ? 2 : 3}; justify-self: ${({ position }: ContentsContainerProps) => position === TOCPosition.Left ? "end" : "start"}; `}; @media print { display: none; } `; const PrintContentsContainer = styled.div` display: none; margin: 0 -12px; @media print { display: block; } `; type EditorContainerProps = { docFullWidth: boolean; showContents: boolean; tocPosition: TOCPosition | false; }; const EditorContainer = styled.div` // Adds space to the gutter to make room for icon & heading annotations padding: 0 44px; ${breakpoint("tablet")` grid-row: 1; // Decides the editor column position & span grid-column: ${({ docFullWidth, showContents, tocPosition, }: EditorContainerProps) => docFullWidth ? showContents ? tocPosition === TOCPosition.Left ? 2 : 1 : "1 / -1" : 2}; `}; `; const Background = styled(Container)` position: relative; background: ${s("background")}; `; const ReferencesWrapper = styled.div` margin: 12px 0 60px; ${breakpoint("tablet")` margin-bottom: 12px; `} @media print { display: none; } `; export default observer(DocumentScene);