feat: Show diff when navigating revision history (#4069)

* tidy

* Add title to HTML export

* fix: Add compatability for documents without collab state

* Add HTML download option to UI

* docs

* fix nodes that required document to render

* Refactor to allow for styling of HTML export

* div>article for easier programatic content extraction

* Allow DocumentHelper to be used with Revisions

* Add revisions.diff endpoint, first version

* Allow arbitrary revisions to be compared

* test

* HTML driven revision viewer

* fix: Dark mode styles for document diffs

* Add revision restore button to header

* test

* Support RTL languages in revision history viewer

* fix: RTL support
Remove unneccessary API requests

* Prefetch revision data

* Animate history sidebar

* fix: Cannot toggle history from timestamp
fix: Animation on each revision click

* Clarify currently editing history item
This commit is contained in:
Tom Moor
2022-09-08 11:17:52 +02:00
committed by GitHub
parent 97f70edd93
commit fa75d5585f
36 changed files with 2075 additions and 1610 deletions

View File

@@ -42,7 +42,15 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
};
function DataLoader({ match, children }: Props) {
const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
const {
ui,
views,
shares,
documents,
auth,
revisions,
subscriptions,
} = useStores();
const { team } = auth;
const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params;
@@ -89,7 +97,7 @@ function DataLoader({ match, children }: Props) {
React.useEffect(() => {
async function fetchSubscription() {
if (document?.id) {
if (document?.id && !revisionId) {
try {
await subscriptions.fetchPage({
documentId: document.id,
@@ -101,7 +109,22 @@ function DataLoader({ match, children }: Props) {
}
}
fetchSubscription();
}, [document?.id, subscriptions]);
}, [document?.id, subscriptions, revisionId]);
React.useEffect(() => {
async function fetchViews() {
if (document?.id && !document?.isDeleted && !revisionId) {
try {
await views.fetchPage({
documentId: document.id,
});
} catch (err) {
Logger.error("Failed to fetch views", err);
}
}
}
fetchViews();
}, [document?.id, document?.isDeleted, revisionId, views]);
const onCreateLink = React.useCallback(
async (title: string) => {

View File

@@ -52,6 +52,7 @@ import MarkAsViewed from "./MarkAsViewed";
import Notices from "./Notices";
import PublicReferences from "./PublicReferences";
import References from "./References";
import RevisionViewer from "./RevisionViewer";
const AUTOSAVE_DELAY = 3000;
@@ -431,7 +432,6 @@ class DocumentScene extends React.Component<Props> {
} = this.props;
const team = auth.team;
const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const embedsDisabled =
(team && team.documentEmbeds === false) || document.embedsDisabled;
@@ -563,57 +563,69 @@ class DocumentScene extends React.Component<Props> {
<Notices document={document} readOnly={readOnly} />
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly}>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
{revision ? (
<RevisionViewer
isDraft={document.isDraft}
document={document}
revision={revision}
id={revision.id}
/>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
ref={this.editor}
multiplayer={collaborativeEditing}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
title={revision ? revision.title : document.title}
document={document}
value={readOnly ? value : undefined}
defaultValue={value}
embedsDisabled={embedsDisabled}
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
>
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
) : (
<>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<References document={document} />
</ReferencesWrapper>
</>
)}
</Editor>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
ref={this.editor}
multiplayer={collaborativeEditing}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
document={document}
value={readOnly ? document.text : undefined}
defaultValue={document.text}
embedsDisabled={embedsDisabled}
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
>
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper
isOnlyTitle={document.isOnlyTitle}
>
<References document={document} />
</ReferencesWrapper>
</>
)}
</Editor>
</>
)}
</Flex>
</React.Suspense>
</MaxWidth>

View File

@@ -16,9 +16,9 @@ import useEmojiWidth from "~/hooks/useEmojiWidth";
import { isModKey } from "~/utils/keyboard";
type Props = {
value: string;
placeholder: string;
document: Document;
/** Placeholder to display when the document has no title */
placeholder: string;
/** Should the title be editable, policies will also be considered separately */
readOnly?: boolean;
/** Whether the title show the option to star, policies will also be considered separately (defaults to true) */
@@ -39,7 +39,6 @@ const fontSize = "2.25em";
const EditableTitle = React.forwardRef(
(
{
value,
document,
readOnly,
onChange,
@@ -51,9 +50,6 @@ const EditableTitle = React.forwardRef(
}: Props,
ref: React.RefObject<RefHandle>
) => {
const normalizedTitle =
!value && readOnly ? document.titleWithDefault : value;
const handleClick = React.useCallback(() => {
ref.current?.focus();
}, [ref]);
@@ -128,10 +124,10 @@ const EditableTitle = React.forwardRef(
onKeyDown={handleKeyDown}
onBlur={onBlur}
placeholder={placeholder}
value={normalizedTitle}
value={document.titleWithDefault}
$emojiWidth={emojiWidth}
$isStarred={document.isStarred}
autoFocus={!value}
autoFocus={!document.title}
maxLength={DocumentValidation.maxTitleLength}
readOnly={readOnly}
dir="auto"

View File

@@ -18,7 +18,6 @@ import EditableTitle from "./EditableTitle";
type Props = Omit<EditorProps, "extensions"> & {
onChangeTitle: (text: string) => void;
title: string;
id: string;
document: Document;
isDraft: boolean;
@@ -41,7 +40,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const match = useRouteMatch();
const {
document,
title,
onChangeTitle,
isDraft,
shareId,
@@ -82,7 +80,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
<Flex auto column>
<EditableTitle
ref={titleRef}
value={title}
readOnly={readOnly}
document={document}
onGoToNextInput={handleGoToNextInput}
@@ -107,7 +104,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
)}
<EditorComponent
ref={ref}
autoFocus={!!title && !props.defaultValue}
autoFocus={!!document.title && !props.defaultValue}
placeholder={t("Type '/' to insert, or start writing…")}
scrollTo={window.location.hash}
readOnly={readOnly}

View File

@@ -20,6 +20,8 @@ import Collaborators from "~/components/Collaborators";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Header from "~/components/Header";
import Tooltip from "~/components/Tooltip";
import { restoreRevision } from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -99,6 +101,10 @@ function DocumentHeader({
});
}, [onSave]);
const context = useActionContext({
activeDocumentId: document?.id,
});
const { isDeleted, isTemplate } = document;
const can = usePolicy(document?.id);
const canToggleEmbeds = team?.documentEmbeds;
@@ -217,7 +223,7 @@ function DocumentHeader({
{!isPublishing && isSaving && !team?.collaborativeEditing && (
<Status>{t("Saving")}</Status>
)}
{!isDeleted && <Collaborators document={document} />}
{!isDeleted && !isRevision && <Collaborators document={document} />}
{(isEditing || team?.collaborativeEditing) && !isTemplate && isNew && (
<Action>
<TemplatesMenu
@@ -226,11 +232,14 @@ function DocumentHeader({
/>
</Action>
)}
{!isEditing && !isDeleted && (!isMobile || !isTemplate) && (
<Action>
<ShareButton document={document} />
</Action>
)}
{!isEditing &&
!isDeleted &&
!isRevision &&
(!isMobile || !isTemplate) && (
<Action>
<ShareButton document={document} />
</Action>
)}
{isEditing && (
<>
<Action>
@@ -251,8 +260,11 @@ function DocumentHeader({
</Action>
</>
)}
{canEdit && !team?.collaborativeEditing && editAction}
{canEdit && can.createChildDocument && !isMobile && (
{canEdit &&
!team?.collaborativeEditing &&
!isRevision &&
editAction}
{canEdit && can.createChildDocument && !isRevision && !isMobile && (
<Action>
<NewChildDocumentMenu
document={document}
@@ -285,6 +297,24 @@ function DocumentHeader({
</Button>
</Action>
)}
{isRevision && (
<Action>
<Tooltip
tooltip={t("Restore version")}
delay={500}
placement="bottom"
>
<Button
action={restoreRevision}
context={context}
neutral
hideOnActionDisabled
>
{t("Restore")}
</Button>
</Tooltip>
</Action>
)}
{can.update && isDraft && !isRevision && (
<Action>
<Tooltip

View File

@@ -0,0 +1,46 @@
import { observer } from "mobx-react";
import * as React from "react";
import EditorContainer from "@shared/editor/components/Styles";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
import { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import { documentUrl } from "~/utils/routeHelpers";
type Props = Omit<EditorProps, "extensions"> & {
id: string;
document: Document;
revision: Revision;
isDraft: boolean;
children?: React.ReactNode;
};
/**
* Displays revision HTML pre-rendered on the server.
*/
function RevisionViewer(props: Props) {
const { document, isDraft, shareId, children, revision } = props;
return (
<Flex auto column>
<h1 dir={revision.dir}>{revision.title}</h1>
{!shareId && (
<DocumentMetaWithViews
isDraft={isDraft}
document={document}
to={documentUrl(document)}
rtl={revision.rtl}
/>
)}
<EditorContainer
dangerouslySetInnerHTML={{ __html: revision.html }}
dir={revision.dir}
rtl={revision.rtl}
/>
{children}
</Flex>
);
}
export default observer(RevisionViewer);