import { debounce } from "lodash"; import { action, observable } from "mobx"; import { observer } from "mobx-react"; import { InputIcon } from "outline-icons"; import { AllSelection } from "prosemirror-state"; import * as React from "react"; import { WithTranslation, Trans, withTranslation } from "react-i18next"; import { Prompt, Route, RouteComponentProps, StaticContext, withRouter, Redirect, } from "react-router"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; 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 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 Modal from "~/components/Modal"; import Notice from "~/components/Notice"; import PageTitle from "~/components/PageTitle"; import PlaceholderDocument from "~/components/PlaceholderDocument"; import RegisterKeyDown from "~/components/RegisterKeyDown"; import Time from "~/components/Time"; import withStores from "~/components/withStores"; import { NavigationNode } from "~/types"; import { client } from "~/utils/ApiClient"; import { isCustomDomain } from "~/utils/domains"; import { emojiToUrl } from "~/utils/emoji"; import { isModKey } from "~/utils/keyboard"; import { documentMoveUrl, documentHistoryUrl, editDocumentUrl, documentUrl, updateDocumentUrl, } 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 PublicReferences from "./PublicReferences"; import References from "./References"; const AUTOSAVE_DELAY = 3000; type Props = WithTranslation & RootStore & RouteComponentProps< Record, StaticContext, { restore?: boolean; revisionId?: string } > & { 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 lastRevision: number = this.props.document.revision; @observable title: string = this.props.document.title; getEditorText: () => string = () => this.props.document.text; componentDidMount() { this.updateIsDirty(); } componentDidUpdate(prevProps: Props) { const { auth, document, t } = this.props; if (prevProps.readOnly && !this.props.readOnly) { this.updateIsDirty(); } if (this.props.readOnly || auth.team?.collaborativeEditing) { this.lastRevision = document.revision; } if ( !this.props.readOnly && !auth.team?.collaborativeEditing && prevProps.document.revision !== this.lastRevision ) { if (auth.user && document.updatedBy.id !== auth.user.id) { this.props.toasts.showToast( t(`Document updated by {{userName}}`, { userName: document.updatedBy.name, }), { timeout: 30 * 1000, type: "warning", action: { text: "Reload", onClick: () => { window.location.href = documentUrl(document); }, }, } ); } } } replaceDocument = (template: Document | Revision) => { const editorRef = this.editor.current; if (!editorRef) { return; } // @ts-expect-error ts-migrate(2339) FIXME: Property 'view' does not exist on type 'unknown'. const { view, parser } = editorRef; view.dispatch( view.state.tr .setSelection(new AllSelection(view.state.doc)) .replaceSelectionWith(parser.parse(template.text)) ); this.isEditorDirty = true; if (template instanceof Document) { this.props.document.templateId = template.id; } this.props.document.title = template.title; this.props.document.text = template.text; this.updateIsDirty(); }; 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) { this.replaceDocument(response.data); toasts.showToast(t("Document restored")); history.replace(this.props.document.url); } }; goToMove = (ev: KeyboardEvent) => { if (!this.props.readOnly) return; ev.preventDefault(); const { document, abilities } = this.props; if (abilities.move) { this.props.history.push(documentMoveUrl(document)); } }; goToEdit = (ev: KeyboardEvent) => { if (!this.props.readOnly) return; ev.preventDefault(); const { document, abilities } = this.props; if (abilities.update) { this.props.history.push(editDocumentUrl(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(documentHistoryUrl(document)); } }; onPublish = (ev: React.MouseEvent | KeyboardEvent) => { ev.preventDefault(); const { document } = this.props; if (document.publishedAt) return; this.onSave({ publish: true, done: true, }); }; 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, auth } = 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 { let savedDocument = document; if (auth.team?.collaborativeEditing) { // update does not send "text" field to the API, this is a workaround // while the multiplayer editor is toggleable. Once it's finalized // this can be cleaned up to single code path savedDocument = await document.update({ ...options, lastRevision: this.lastRevision, }); } else { savedDocument = await document.save({ ...options, lastRevision: this.lastRevision, }); } this.isEditorDirty = false; this.lastRevision = savedDocument.revision; if (options.done) { this.props.history.push(savedDocument.url); this.props.ui.setActiveDocument(savedDocument); } else if (document.isNew) { this.props.history.push(editDocumentUrl(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 === "#") && !this.title; }; updateIsDirtyDebounced = debounce(this.updateIsDirty, 500); onImageUploadStart = () => { this.isUploading = true; }; onImageUploadStop = () => { this.isUploading = false; }; onChange = (getEditorText: () => string) => { const { document, auth } = this.props; this.getEditorText = getEditorText; // If the multiplayer editor is enabled then we still want to keep the local // text value in sync as it is used as a cache. if (auth.team?.collaborativeEditing) { action(() => { document.text = this.getEditorText(); document.tasks = getTasks(document.text); })(); return; } // document change while read only is presumed to be a checkbox edit, // in that case we don't delay in saving for a better user experience. if (this.props.readOnly) { this.updateIsDirty(); this.onSave({ done: false, autosave: true, }); } else { this.updateIsDirtyDebounced(); this.autosave(); } }; onChangeTitle = action((value: string) => { this.props.document.title = value; this.updateIsDirty(); this.autosave(); }); goBack = () => { 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 value = revision ? revision.text : document.text; const disableEmbeds = (team && team.documentEmbeds === false) || document.embedsDisabled; const headings = this.editor.current ? // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. this.editor.current.getHeadings() : []; const showContents = ui.tocVisible && (readOnly || team?.collaborativeEditing); const collaborativeEditing = team?.collaborativeEditing && !document.isArchived && !document.isDeleted && !revision && !isShare; const canonicalUrl = updateDocumentUrl(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 && ( <> )}
{document.isTemplate && !readOnly && ( You’re editing a template. Highlight some text and use the{" "} control to add placeholders that can be filled out when creating new documents from this template. )} {document.archivedAt && !document.deletedAt && ( {t("Archived by {{userName}}", { userName: document.updatedBy.name, })}{" "} )} {document.deletedAt && ( {t("Deleted by {{userName}}", { userName: document.updatedBy.name, })}{" "} {document.permanentlyDeletedAt && ( <>
{document.template ? ( This template will be permanently deleted in{" "} ) : ( This document will be permanently deleted in{" "} )} )}
)} }> {showContents && } {shareId && ( )} {!isShare && !revision && ( <> )}
{isShare && !isCustomDomain() && ( )} {!isShare && ( <> )} ); } } const PlaceholderIcon = styled(InputIcon)` position: relative; top: 6px; `; const Background = styled(Container)` background: ${(props) => props.theme.background}; transition: ${(props) => props.theme.backgroundTransition}; `; const ReferencesWrapper = styled.div<{ isOnlyTitle?: boolean }>` margin-top: ${(props) => (props.isOnlyTitle ? -45 : 16)}px; @media print { display: none; } `; type MaxWidthProps = { isEditing?: boolean; archived?: boolean; showContents?: boolean; }; const MaxWidth = styled(Flex)` ${(props) => props.archived && `* { color: ${props.theme.textSecondary} !important; } `}; // Adds space to the left gutter to make room for heading annotations on mobile padding: ${(props) => (props.isEditing ? "0 12px 0 32px" : "0 12px")}; transition: padding 100ms; max-width: 100vw; width: 100%; ${breakpoint("tablet")` padding: 0 24px; margin: 4px auto 12px; max-width: calc(48px + ${(props: MaxWidthProps) => props.showContents ? "64em" : "46em"}); `}; ${breakpoint("desktopLarge")` max-width: calc(48px + 52em); `}; `; export default withTranslation()(withStores(withRouter(DocumentScene)));