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:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
46
app/scenes/Document/components/RevisionViewer.tsx
Normal file
46
app/scenes/Document/components/RevisionViewer.tsx
Normal 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);
|
||||
Reference in New Issue
Block a user