mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
548 lines
16 KiB
TypeScript
548 lines
16 KiB
TypeScript
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<string, boolean>;
|
|
/** 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<Document>,
|
|
nested?: boolean
|
|
) => Promise<string>;
|
|
/** 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<LocationState>();
|
|
const sidebarContext = useLocationSidebarContext();
|
|
const { team, user } = auth;
|
|
|
|
const editorRef = useRef<TEditor>(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: <DocumentMove document={document} />,
|
|
});
|
|
}
|
|
},
|
|
[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: <DocumentPublish document={document} />,
|
|
});
|
|
}
|
|
},
|
|
[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 (
|
|
<ErrorBoundary showTitle>
|
|
<RegisterKeyDown trigger="m" handler={onMove} />
|
|
<RegisterKeyDown trigger="z" handler={onUndoRedo} />
|
|
<RegisterKeyDown trigger="e" handler={goToEdit} />
|
|
<RegisterKeyDown trigger="Escape" handler={goBack} />
|
|
<RegisterKeyDown trigger="h" handler={goToHistory} />
|
|
<RegisterKeyDown
|
|
trigger="p"
|
|
options={{
|
|
allowInInput: true,
|
|
}}
|
|
handler={handlePublishShortcut}
|
|
/>
|
|
<MeasuredContainer
|
|
as={Background}
|
|
name="container"
|
|
key={revision ? revision.id : document.id}
|
|
column
|
|
auto
|
|
>
|
|
<PageTitle title={pageTitle} favicon={favicon} />
|
|
{(isUploading || isSaving) && <LoadingIndicator />}
|
|
<Container column>
|
|
{!readOnly && (
|
|
<Prompt
|
|
when={isUploading && !isEditorDirty}
|
|
message={t(
|
|
`Images are still uploading.\nAre you sure you want to discard them?`
|
|
)}
|
|
/>
|
|
)}
|
|
{isShare ? (
|
|
<SharedHeader document={document} />
|
|
) : (
|
|
<Header
|
|
editorRef={editorRef}
|
|
document={document}
|
|
revision={revision}
|
|
isDraft={document.isDraft}
|
|
isEditing={!readOnly && !!user?.separateEditMode}
|
|
isSaving={isSaving}
|
|
isPublishing={isPublishing}
|
|
publishingIsDisabled={
|
|
document.isSaving || isPublishing || isEmpty
|
|
}
|
|
savingIsDisabled={document.isSaving || isEmpty}
|
|
onSelectTemplate={handleSelectTemplate}
|
|
onSave={onSave}
|
|
/>
|
|
)}
|
|
<Main
|
|
fullWidth={document.fullWidth}
|
|
tocPosition={tocPos}
|
|
style={fullWidthTransformOffsetStyle}
|
|
>
|
|
<React.Suspense
|
|
fallback={
|
|
<EditorContainer
|
|
docFullWidth={document.fullWidth}
|
|
showContents={showContents}
|
|
tocPosition={tocPos}
|
|
>
|
|
<PlaceholderDocument />
|
|
</EditorContainer>
|
|
}
|
|
>
|
|
<MeasuredContainer
|
|
name="document"
|
|
as={EditorContainer}
|
|
docFullWidth={document.fullWidth}
|
|
showContents={showContents}
|
|
tocPosition={tocPos}
|
|
>
|
|
{revision ? (
|
|
<RevisionViewer
|
|
ref={editorRef}
|
|
document={document}
|
|
revision={revision}
|
|
id={revision.id}
|
|
/>
|
|
) : (
|
|
<>
|
|
<Notices document={document} readOnly={readOnly} />
|
|
|
|
{showContents && (
|
|
<PrintContentsContainer>
|
|
<Contents />
|
|
</PrintContentsContainer>
|
|
)}
|
|
<Editor
|
|
id={document.id}
|
|
key={embedsDisabled ? "disabled" : "enabled"}
|
|
ref={editorRef}
|
|
multiplayer={multiplayerEditor}
|
|
isDraft={document.isDraft}
|
|
document={document}
|
|
value={readOnly ? document.data : undefined}
|
|
defaultValue={document.data}
|
|
embedsDisabled={embedsDisabled}
|
|
onSynced={onSynced}
|
|
onFileUploadStart={onFileUploadStart}
|
|
onFileUploadStop={onFileUploadStop}
|
|
onCreateLink={onCreateLink}
|
|
onChangeTitle={handleChangeTitle}
|
|
onChangeIcon={handleChangeIcon}
|
|
onSave={onSave}
|
|
onPublish={onPublish}
|
|
onCancel={goBack}
|
|
readOnly={readOnly}
|
|
canUpdate={abilities.update}
|
|
canComment={abilities.comment}
|
|
autoFocus={document.createdAt === document.updatedAt}
|
|
>
|
|
<ReferencesWrapper>
|
|
<References document={document} />
|
|
</ReferencesWrapper>
|
|
</Editor>
|
|
</>
|
|
)}
|
|
</MeasuredContainer>
|
|
{showContents && (
|
|
<ContentsContainer
|
|
docFullWidth={document.fullWidth}
|
|
position={tocPos}
|
|
>
|
|
<Contents />
|
|
</ContentsContainer>
|
|
)}
|
|
</React.Suspense>
|
|
</Main>
|
|
{children}
|
|
</Container>
|
|
</MeasuredContainer>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
type MainProps = {
|
|
fullWidth: boolean;
|
|
tocPosition: TOCPosition | false;
|
|
};
|
|
|
|
const Main = styled.div<MainProps>`
|
|
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<ContentsContainerProps>`
|
|
${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<EditorContainerProps>`
|
|
// 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);
|