import debounce from "lodash/debounce"; import { action, observable } from "mobx"; import { observer } from "mobx-react"; import { AllSelection } from "prosemirror-state"; import * as React from "react"; import { WithTranslation, withTranslation } from "react-i18next"; import { Prompt, RouteComponentProps, StaticContext, withRouter, Redirect, } from "react-router"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { s } from "@shared/styles"; import { NavigationNode } from "@shared/types"; import { Heading } from "@shared/utils/ProsemirrorHelper"; import { parseDomain } from "@shared/utils/domains"; import getTasks from "@shared/utils/getTasks"; import RootStore from "~/stores/RootStore"; import Document from "~/models/Document"; import Revision from "~/models/Revision"; import DocumentMove from "~/scenes/DocumentMove"; import DocumentPublish from "~/scenes/DocumentPublish"; import Branding from "~/components/Branding"; import ConnectionStatus from "~/components/ConnectionStatus"; import ErrorBoundary from "~/components/ErrorBoundary"; import Flex from "~/components/Flex"; import LoadingIndicator from "~/components/LoadingIndicator"; import PageTitle from "~/components/PageTitle"; import PlaceholderDocument from "~/components/PlaceholderDocument"; import RegisterKeyDown from "~/components/RegisterKeyDown"; import withStores from "~/components/withStores"; import type { Editor as TEditor } from "~/editor"; import { client } from "~/utils/ApiClient"; import { replaceTitleVariables } from "~/utils/date"; import { emojiToUrl } from "~/utils/emoji"; import { isModKey } from "~/utils/keyboard"; import { documentHistoryPath, documentEditPath, updateDocumentPath, } from "~/utils/routeHelpers"; import Container from "./Container"; import Contents from "./Contents"; import Editor from "./Editor"; import Header from "./Header"; import KeyboardShortcutsButton from "./KeyboardShortcutsButton"; import MarkAsViewed from "./MarkAsViewed"; import Notices from "./Notices"; import PublicReferences from "./PublicReferences"; import References from "./References"; import RevisionViewer from "./RevisionViewer"; const AUTOSAVE_DELAY = 3000; type Params = { documentSlug: string; revisionId?: string; shareId?: string; }; type LocationState = { title?: string; restore?: boolean; revisionId?: string; }; type Props = WithTranslation & RootStore & RouteComponentProps & { sharedTree?: NavigationNode; abilities: Record; document: Document; revision?: Revision; readOnly: boolean; shareId?: string; onCreateLink?: (title: string) => Promise; onSearchLink?: (term: string) => any; }; @observer class DocumentScene extends React.Component { @observable editor = React.createRef(); @observable isUploading = false; @observable isSaving = false; @observable isPublishing = false; @observable isEditorDirty = false; @observable isEmpty = true; @observable title: string = this.props.document.title; @observable headings: Heading[] = []; getEditorText: () => string = () => this.props.document.text; componentDidMount() { this.updateIsDirty(); } componentDidUpdate(prevProps: Props) { if (prevProps.readOnly && !this.props.readOnly) { this.updateIsDirty(); } } componentWillUnmount() { if ( this.isEmpty && this.props.document.createdBy.id === this.props.auth.user?.id && this.props.document.isDraft && this.props.document.isActive && this.props.document.hasEmptyTitle && this.props.document.isPersistedOnce ) { void this.props.document.delete(); } } replaceDocument = (template: Document | Revision) => { const editorRef = this.editor.current; if (!editorRef) { return; } const { view, parser } = editorRef; const doc = parser.parse(template.text); if (doc) { view.dispatch( view.state.tr .setSelection(new AllSelection(view.state.doc)) .replaceSelectionWith(doc) ); } this.isEditorDirty = true; if (template instanceof Document) { this.props.document.templateId = template.id; } if (!this.title) { const title = replaceTitleVariables( template.title, this.props.auth.user || undefined ); this.title = title; this.props.document.title = title; } this.props.document.text = template.text; this.updateIsDirty(); return this.onSave({ autosave: true, publish: false, done: false, }); }; onSynced = async () => { const { toasts, history, location, t } = this.props; const restore = location.state?.restore; const revisionId = location.state?.revisionId; const editorRef = this.editor.current; if (!editorRef || !restore) { return; } const response = await client.post("/revisions.info", { id: revisionId, }); if (response) { await this.replaceDocument(response.data); toasts.showToast(t("Document restored")); history.replace(this.props.document.url, history.location.state); } }; onMove = (ev: React.MouseEvent | KeyboardEvent) => { ev.preventDefault(); const { document, dialogs, t, abilities } = this.props; if (abilities.move) { dialogs.openModal({ title: t("Move document"), isCentered: true, content: , }); } }; goToEdit = (ev: KeyboardEvent) => { if (!this.props.readOnly) { return; } ev.preventDefault(); const { document, abilities } = this.props; if (abilities.update) { this.props.history.push(documentEditPath(document)); } }; goToHistory = (ev: KeyboardEvent) => { if (!this.props.readOnly) { return; } if (ev.ctrlKey) { return; } ev.preventDefault(); const { document, location } = this.props; if (location.pathname.endsWith("history")) { this.props.history.push(document.url); } else { this.props.history.push(documentHistoryPath(document)); } }; onPublish = (ev: React.MouseEvent | KeyboardEvent) => { ev.preventDefault(); const { document, dialogs, t } = this.props; if (document.publishedAt) { return; } if (document?.collectionId) { void this.onSave({ publish: true, done: true, }); } else { dialogs.openModal({ title: t("Publish document"), isCentered: true, content: , }); } }; onToggleTableOfContents = (ev: KeyboardEvent) => { if (!this.props.readOnly) { return; } ev.preventDefault(); const { ui } = this.props; if (ui.tocVisible) { ui.hideTableOfContents(); } else { ui.showTableOfContents(); } }; onSave = async ( options: { done?: boolean; publish?: boolean; autosave?: boolean; } = {} ) => { const { document } = this.props; // prevent saves when we are already saving if (document.isSaving) { return; } // get the latest version of the editor text value const text = this.getEditorText ? this.getEditorText() : document.text; // prevent save before anything has been written (single hash is empty doc) if (text.trim() === "" && document.title.trim() === "") { return; } document.text = text; document.tasks = getTasks(document.text); // prevent autosave if nothing has changed if (options.autosave && !this.isEditorDirty && !document.isDirty()) { return; } this.isSaving = true; this.isPublishing = !!options.publish; try { const savedDocument = await document.save(undefined, options); this.isEditorDirty = false; if (options.done) { this.props.history.push(savedDocument.url); this.props.ui.setActiveDocument(savedDocument); } else if (document.isNew) { this.props.history.push(documentEditPath(savedDocument)); this.props.ui.setActiveDocument(savedDocument); } } catch (err) { this.props.toasts.showToast(err.message, { type: "error", }); } finally { this.isSaving = false; this.isPublishing = false; } }; autosave = debounce( () => this.onSave({ done: false, autosave: true, }), AUTOSAVE_DELAY ); updateIsDirty = () => { const { document } = this.props; const editorText = this.getEditorText().trim(); this.isEditorDirty = editorText !== document.text.trim(); // a single hash is a doc with just an empty title this.isEmpty = (!editorText || editorText === "#" || editorText === "\\") && !this.title; }; updateIsDirtyDebounced = debounce(this.updateIsDirty, 500); onFileUploadStart = () => { this.isUploading = true; }; onFileUploadStop = () => { this.isUploading = false; }; handleChange = (getEditorText: () => string) => { const { document } = this.props; this.getEditorText = getEditorText; // Keep derived task list in sync const tasks = this.editor.current?.getTasks(); const total = tasks?.length ?? 0; const completed = tasks?.filter((t) => t.completed).length ?? 0; document.updateTasks(total, completed); }; onHeadingsChange = (headings: Heading[]) => { this.headings = headings; }; handleChangeTitle = action((value: string) => { this.title = value; this.props.document.title = value; this.updateIsDirty(); void this.autosave(); }); handleChangeEmoji = action((value: string) => { this.props.document.emoji = value; this.updateIsDirty(); void this.autosave(); }); goBack = () => { if (!this.props.readOnly) { this.props.history.push(this.props.document.url); } }; render() { const { document, revision, readOnly, abilities, auth, ui, shareId, t } = this.props; const team = auth.team; const isShare = !!shareId; const embedsDisabled = (team && team.documentEmbeds === false) || document.embedsDisabled; const hasHeadings = this.headings.length > 0; const showContents = ui.tocVisible && ((readOnly && hasHeadings) || !readOnly); const multiplayerEditor = !document.isArchived && !document.isDeleted && !revision && !isShare; const canonicalUrl = shareId ? this.props.match.url : updateDocumentPath(this.props.match.url, document); return ( {this.props.location.pathname !== canonicalUrl && ( )} { if (isModKey(event) && event.shiftKey) { this.onPublish(event); } }} /> { if (event.ctrlKey && event.altKey) { this.onToggleTableOfContents(event); } }} /> {(this.isUploading || this.isSaving) && } {!readOnly && ( )}
}> {revision ? ( ) : ( <> {shareId && ( )} {!isShare && !revision && ( <> )} {showContents && ( )} )} {isShare && !parseDomain(window.location.origin).custom && !auth.user && ( )} {!isShare && (
)} ); } } const Footer = styled.div` position: absolute; width: 100%; text-align: right; display: flex; justify-content: flex-end; `; const Background = styled(Container)` position: relative; background: ${s("background")}; transition: ${s("backgroundTransition")}; `; const ReferencesWrapper = styled.div` margin-top: 16px; @media print { display: none; } `; type MaxWidthProps = { isEditing?: boolean; isFullWidth?: boolean; archived?: boolean; showContents?: boolean; }; const MaxWidth = styled(Flex)` // Adds space to the gutter to make room for heading annotations padding: 0 32px; transition: padding 100ms; max-width: 100vw; width: 100%; padding-bottom: 16px; ${breakpoint("tablet")` margin: 4px auto 12px; max-width: ${(props: MaxWidthProps) => props.isFullWidth ? "100vw" : `calc(64px + 46em + ${props.showContents ? "256px" : "0px"});`} `}; ${breakpoint("desktopLarge")` max-width: ${(props: MaxWidthProps) => props.isFullWidth ? "100vw" : `calc(64px + 52em);`} `}; `; export default withTranslation()(withStores(withRouter(DocumentScene)));