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

@@ -1,4 +1,4 @@
import { FindOptions } from "sequelize";
import { FindOptions, Op } from "sequelize";
import {
DataType,
BelongsTo,
@@ -97,6 +97,20 @@ class Revision extends IdModel {
);
}
// instance methods
previous(): Promise<Revision | null> {
return (this.constructor as typeof Revision).findOne({
where: {
documentId: this.documentId,
createdAt: {
[Op.lt]: this.createdAt,
},
},
order: [["createdAt", "DESC"]],
});
}
migrateVersion = function () {
let migrated = false;

View File

@@ -3,6 +3,7 @@ import {
yDocToProsemirrorJSON,
} from "@getoutline/y-prosemirror";
import { JSDOM } from "jsdom";
import diff from "node-htmldiff";
import { Node, DOMSerializer } from "prosemirror-model";
import * as React from "react";
import { renderToString } from "react-dom/server";
@@ -11,21 +12,30 @@ import * as Y from "yjs";
import EditorContainer from "@shared/editor/components/Styles";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
import { isRTL } from "@shared/utils/rtl";
import unescape from "@shared/utils/unescape";
import { parser, schema } from "@server/editor";
import Logger from "@server/logging/Logger";
import type Document from "@server/models/Document";
import Document from "@server/models/Document";
import type Revision from "@server/models/Revision";
type HTMLOptions = {
/** Whether to include the document title in the generated HTML (defaults to true) */
includeTitle?: boolean;
/** Whether to include style tags in the generated HTML (defaults to true) */
includeStyles?: boolean;
};
export default class DocumentHelper {
/**
* Returns the document as a Prosemirror Node. This method uses the
* collaborative state if available, otherwise it falls back to Markdown->HTML.
*
* @param document The document to convert
* @param document The document or revision to convert
* @returns The document content as a Prosemirror Node
*/
static toProsemirror(document: Document) {
if (document.state) {
static toProsemirror(document: Document | Revision) {
if ("state" in document && document.state) {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, document.state);
return Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
@@ -37,10 +47,10 @@ export default class DocumentHelper {
* Returns the document as Markdown. This is a lossy conversion and should
* only be used for export.
*
* @param document The document to convert
* @param document The document or revision to convert
* @returns The document title and content as a Markdown string
*/
static toMarkdown(document: Document) {
static toMarkdown(document: Document | Revision) {
const text = unescape(document.text);
if (document.version) {
@@ -54,10 +64,11 @@ export default class DocumentHelper {
* Returns the document as plain HTML. This is a lossy conversion and should
* only be used for export.
*
* @param document The document to convert
* @param document The document or revision to convert
* @param options Options for the HTML output
* @returns The document title and content as a HTML string
*/
static toHTML(document: Document) {
static toHTML(document: Document | Revision, options?: HTMLOptions) {
const node = DocumentHelper.toProsemirror(document);
const sheet = new ServerStyleSheet();
let html, styleTags;
@@ -68,6 +79,18 @@ export default class DocumentHelper {
padding: 0 1em;
`;
const rtl = isRTL(document.title);
const children = (
<>
{options?.includeTitle !== false && (
<h1 dir={rtl ? "rtl" : "ltr"}>{document.title}</h1>
)}
<EditorContainer dir={rtl ? "rtl" : "ltr"} rtl={rtl}>
<div id="content" className="ProseMirror"></div>
</EditorContainer>
</>
);
// First render the containing document which has all the editor styles,
// global styles, layout and title.
try {
@@ -75,13 +98,14 @@ export default class DocumentHelper {
sheet.collectStyles(
<ThemeProvider theme={light}>
<>
<GlobalStyles />
<Centered>
<h1>{document.title}</h1>
<EditorContainer rtl={false}>
<div id="content" className="ProseMirror"></div>
</EditorContainer>
</Centered>
{options?.includeStyles === false ? (
<article>{children}</article>
) : (
<>
<GlobalStyles />
<Centered>{children}</Centered>
</>
)}
</>
</ThemeProvider>
)
@@ -97,7 +121,11 @@ export default class DocumentHelper {
// Render the Prosemirror document using virtual DOM and serialize the
// result to a string
const dom = new JSDOM(`<!DOCTYPE html>${styleTags}${html}`);
const dom = new JSDOM(
`<!DOCTYPE html>${
options?.includeStyles === false ? "" : styleTags
}${html}`
);
const doc = dom.window.document;
const target = doc.getElementById("content");
@@ -113,6 +141,43 @@ export default class DocumentHelper {
return dom.serialize();
}
/**
* Generates a HTML diff between after documents or revisions.
*
* @param before The before document
* @param after The after document
* @param options Options passed to HTML generation
* @returns The diff as a HTML string
*/
static diff(
before: Document | Revision | null,
after: Revision,
options?: HTMLOptions
) {
if (!before) {
return DocumentHelper.toHTML(after, options);
}
const beforeHTML = DocumentHelper.toHTML(before, options);
const afterHTML = DocumentHelper.toHTML(after, options);
const beforeDOM = new JSDOM(beforeHTML);
const afterDOM = new JSDOM(afterHTML);
// Extract the content from the article tag and diff the HTML, we don't
// care about the surrounding layout and stylesheets.
const diffedContentAsHTML = diff(
beforeDOM.window.document.getElementsByTagName("article")[0].innerHTML,
afterDOM.window.document.getElementsByTagName("article")[0].innerHTML
);
// Inject the diffed content into the original document with styling and
// serialize back to a string.
beforeDOM.window.document.getElementsByTagName(
"article"
)[0].innerHTML = diffedContentAsHTML;
return beforeDOM.serialize();
}
/**
* Applies the given Markdown to the document, this essentially creates a
* single change in the collaborative state that makes all the edits to get