diff --git a/app/actions/definitions/revisions.tsx b/app/actions/definitions/revisions.tsx
new file mode 100644
index 000000000..ed41ffea7
--- /dev/null
+++ b/app/actions/definitions/revisions.tsx
@@ -0,0 +1,85 @@
+import copy from "copy-to-clipboard";
+import { LinkIcon, RestoreIcon } from "outline-icons";
+import * as React from "react";
+import { matchPath } from "react-router-dom";
+import stores from "~/stores";
+import { createAction } from "~/actions";
+import { RevisionSection } from "~/actions/sections";
+import history from "~/utils/history";
+import { documentHistoryUrl, matchDocumentHistory } from "~/utils/routeHelpers";
+
+export const restoreRevision = createAction({
+ name: ({ t }) => t("Restore revision"),
+ icon: ,
+ section: RevisionSection,
+ visible: ({ activeDocumentId, stores }) =>
+ !!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
+ perform: async ({ t, event, location, activeDocumentId }) => {
+ event?.preventDefault();
+ if (!activeDocumentId) {
+ return;
+ }
+
+ const match = matchPath<{ revisionId: string }>(location.pathname, {
+ path: matchDocumentHistory,
+ });
+ const revisionId = match?.params.revisionId;
+
+ const { team } = stores.auth;
+ const document = stores.documents.get(activeDocumentId);
+ if (!document) {
+ return;
+ }
+
+ if (team?.collaborativeEditing) {
+ history.push(document.url, {
+ restore: true,
+ revisionId,
+ });
+ } else {
+ await document.restore({
+ revisionId,
+ });
+ stores.toasts.showToast(t("Document restored"), {
+ type: "success",
+ });
+ history.push(document.url);
+ }
+ },
+});
+
+export const copyLinkToRevision = createAction({
+ name: ({ t }) => t("Copy link"),
+ icon: ,
+ section: RevisionSection,
+ perform: async ({ activeDocumentId, stores, t }) => {
+ if (!activeDocumentId) {
+ return;
+ }
+
+ const match = matchPath<{ revisionId: string }>(location.pathname, {
+ path: matchDocumentHistory,
+ });
+ const revisionId = match?.params.revisionId;
+ const document = stores.documents.get(activeDocumentId);
+ if (!document) {
+ return;
+ }
+
+ const url = `${window.location.origin}${documentHistoryUrl(
+ document,
+ revisionId
+ )}`;
+
+ copy(url, {
+ format: "text/plain",
+ onCopy: () => {
+ stores.toasts.showToast(t("Link copied"), {
+ type: "info",
+ });
+ },
+ });
+ },
+});
+
+export const rootRevisionActions = [];
diff --git a/app/actions/root.ts b/app/actions/root.ts
index db4ee3746..ec04d162a 100644
--- a/app/actions/root.ts
+++ b/app/actions/root.ts
@@ -2,6 +2,7 @@ import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
+import { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
@@ -11,6 +12,7 @@ export default [
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
+ ...rootRevisionActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
diff --git a/app/actions/sections.ts b/app/actions/sections.ts
index 00a69c4d5..fb1dee52a 100644
--- a/app/actions/sections.ts
+++ b/app/actions/sections.ts
@@ -6,6 +6,8 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
+export const RevisionSection = ({ t }: ActionContext) => t("Revision");
+
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx
index a591804bf..de1be87a7 100644
--- a/app/components/AuthenticatedLayout.tsx
+++ b/app/components/AuthenticatedLayout.tsx
@@ -1,23 +1,23 @@
-import { observable } from "mobx";
+import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
-import { withTranslation, WithTranslation } from "react-i18next";
-import { Switch, Route } from "react-router-dom";
-import RootStore from "~/stores/RootStore";
+import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
+import usePolicy from "~/hooks/usePolicy";
+import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import {
searchPath,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
+ matchDocumentHistory,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
-import withStores from "./withStores";
const DocumentHistory = React.lazy(
() =>
@@ -34,16 +34,13 @@ const CommandBar = React.lazy(
)
);
-type Props = WithTranslation & RootStore;
+const AuthenticatedLayout: React.FC = ({ children }) => {
+ const { ui, auth } = useStores();
+ const location = useLocation();
+ const can = usePolicy(ui.activeCollectionId);
+ const { user, team } = auth;
-@observer
-class AuthenticatedLayout extends React.Component {
- scrollable: HTMLDivElement | null | undefined;
-
- @observable
- keyboardShortcutsOpen = false;
-
- goToSearch = (ev: KeyboardEvent) => {
+ const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
@@ -51,60 +48,64 @@ class AuthenticatedLayout extends React.Component {
}
};
- goToNewDocument = (event: KeyboardEvent) => {
+ const goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
-
- const { activeCollectionId } = this.props.ui;
- if (!activeCollectionId) {
- return;
- }
- const can = this.props.policies.abilities(activeCollectionId);
- if (!can.update) {
+ const { activeCollectionId } = ui;
+ if (!activeCollectionId || !can.update) {
return;
}
history.push(newDocumentPath(activeCollectionId));
};
- render() {
- const { auth } = this.props;
- const { user, team } = auth;
- const showSidebar = auth.authenticated && user && team;
- if (auth.isSuspended) {
- return ;
- }
+ if (auth.isSuspended) {
+ return ;
+ }
- const sidebar = showSidebar ? (
-
-
-
-
-
-
- ) : undefined;
+ const showSidebar = auth.authenticated && user && team;
- const rightRail = (
-
-
+ const sidebar = showSidebar ? (
+
+
+
+
+
+
+ ) : undefined;
+
+ const sidebarRight = (
+
+
+
-
- );
+
+
+ );
- return (
-
-
-
-
- {this.props.children}
-
-
- );
- }
-}
+ return (
+
+
+
+
+ {children}
+
+
+ );
+};
-export default withTranslation()(withStores(AuthenticatedLayout));
+export default observer(AuthenticatedLayout);
diff --git a/app/components/Button.tsx b/app/components/Button.tsx
index 556d31b63..02515f101 100644
--- a/app/components/Button.tsx
+++ b/app/components/Button.tsx
@@ -3,14 +3,19 @@ import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
+import ActionButton, {
+ Props as ActionButtonProps,
+} from "~/components/ActionButton";
-const RealButton = styled.button<{
+type RealProps = {
fullwidth?: boolean;
borderOnHover?: boolean;
$neutral?: boolean;
danger?: boolean;
iconColor?: string;
-}>`
+};
+
+const RealButton = styled(ActionButton)`
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
margin: 0;
@@ -146,7 +151,7 @@ export const Inner = styled.span<{
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
-export type Props = {
+export type Props = ActionButtonProps & {
icon?: React.ReactNode;
iconColor?: string;
children?: React.ReactNode;
@@ -168,12 +173,19 @@ const Button = (
props: Props & React.ComponentPropsWithoutRef,
ref: React.Ref
) => {
- const { type, icon, children, value, disclosure, neutral, ...rest } = props;
+ const { type, children, value, disclosure, neutral, action, ...rest } = props;
const hasText = children !== undefined || value !== undefined;
+ const icon = action?.icon ?? rest.icon;
const hasIcon = icon !== undefined;
return (
-
+
{hasIcon && icon}
{hasText && }
diff --git a/app/components/DocumentHistory.tsx b/app/components/DocumentHistory.tsx
index 2323d2b77..163774be4 100644
--- a/app/components/DocumentHistory.tsx
+++ b/app/components/DocumentHistory.tsx
@@ -1,9 +1,10 @@
+import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
-import styled from "styled-components";
+import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "~/models/Event";
import Button from "~/components/Button";
@@ -21,6 +22,7 @@ function DocumentHistory() {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
+ const theme = useTheme();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
@@ -44,7 +46,8 @@ function DocumentHistory() {
eventsInDocument.unshift(
new Event(
{
- name: "documents.latest_version",
+ id: "live",
+ name: "documents.live_editing",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
@@ -58,7 +61,22 @@ function DocumentHistory() {
}, [eventsInDocument, events, document]);
return (
-
+
{document ? (
@@ -95,7 +113,7 @@ const Position = styled(Flex)`
width: ${(props) => props.theme.sidebarWidth}px;
`;
-const Sidebar = styled(Flex)`
+const Sidebar = styled(m.div)`
display: none;
position: relative;
flex-shrink: 0;
@@ -125,7 +143,7 @@ const Title = styled(Flex)`
const Header = styled(Flex)`
align-items: center;
position: relative;
- padding: 12px;
+ padding: 16px 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
diff --git a/app/components/DocumentMeta.tsx b/app/components/DocumentMeta.tsx
index c3fcba22f..9216d2b6c 100644
--- a/app/components/DocumentMeta.tsx
+++ b/app/components/DocumentMeta.tsx
@@ -12,24 +12,6 @@ import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
-const Container = styled(Flex)<{ rtl?: boolean }>`
- justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
- color: ${(props) => props.theme.textTertiary};
- font-size: 13px;
- white-space: nowrap;
- overflow: hidden;
- min-width: 0;
-`;
-
-const Viewed = styled.span`
- text-overflow: ellipsis;
- overflow: hidden;
-`;
-
-const Modified = styled.span<{ highlight?: boolean }>`
- font-weight: ${(props) => (props.highlight ? "600" : "400")};
-`;
-
type Props = {
showCollection?: boolean;
showPublished?: boolean;
@@ -192,4 +174,22 @@ const DocumentMeta: React.FC = ({
);
};
+const Container = styled(Flex)<{ rtl?: boolean }>`
+ justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
+ color: ${(props) => props.theme.textTertiary};
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ min-width: 0;
+`;
+
+const Viewed = styled.span`
+ text-overflow: ellipsis;
+ overflow: hidden;
+`;
+
+const Modified = styled.span<{ highlight?: boolean }>`
+ font-weight: ${(props) => (props.highlight ? "600" : "400")};
+`;
+
export default observer(DocumentMeta);
diff --git a/app/components/DocumentMetaWithViews.tsx b/app/components/DocumentMetaWithViews.tsx
index 79c820f64..8c23bd334 100644
--- a/app/components/DocumentMetaWithViews.tsx
+++ b/app/components/DocumentMetaWithViews.tsx
@@ -24,14 +24,6 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
- React.useEffect(() => {
- if (!document.isDeleted) {
- views.fetchPage({
- documentId: document.id,
- });
- }
- }, [views, document.id, document.isDeleted]);
-
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
diff --git a/app/components/EventListItem.tsx b/app/components/EventListItem.tsx
index 0627439c7..8a540c597 100644
--- a/app/components/EventListItem.tsx
+++ b/app/components/EventListItem.tsx
@@ -1,11 +1,12 @@
+import { observer } from "mobx-react";
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
- CheckboxIcon,
UnpublishIcon,
+ LightningIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -20,7 +21,7 @@ import CompositeItem, {
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
-import usePolicy from "~/hooks/usePolicy";
+import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryUrl } from "~/utils/routeHelpers";
@@ -32,8 +33,8 @@ type Props = {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
+ const { revisions } = useStores();
const location = useLocation();
- const can = usePolicy(document);
const opts = {
userName: event.actor.name,
};
@@ -43,25 +44,28 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const ref = React.useRef(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
- const handleTimeClick = React.useCallback(() => {
+ const handleTimeClick = () => {
ref.current?.focus();
- }, [ref]);
+ };
+
+ const prefetchRevision = () => {
+ if (event.name === "revisions.create" && event.modelId) {
+ revisions.fetch(event.modelId);
+ }
+ };
switch (event.name) {
case "revisions.create":
- case "documents.latest_version": {
- if (latest) {
- icon = ;
- meta = t("Latest version");
- to = documentHistoryUrl(document);
- break;
- } else {
- icon = ;
- meta = t("{{userName}} edited", opts);
- to = documentHistoryUrl(document, event.modelId || "");
- break;
- }
- }
+ icon = ;
+ meta = t("{{userName}} edited", opts);
+ to = documentHistoryUrl(document, event.modelId || "");
+ break;
+
+ case "documents.live_editing":
+ icon = ;
+ meta = t("Live editing");
+ to = documentHistoryUrl(document);
+ break;
case "documents.archive":
icon = ;
@@ -136,10 +140,11 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
}
actions={
- isRevision && isActive && event.modelId && can.update ? (
+ isRevision && isActive && event.modelId ? (
) : undefined
}
+ onMouseEnter={prefetchRevision}
ref={ref}
{...rest}
/>
@@ -217,4 +222,4 @@ const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
-export default EventListItem;
+export default observer(EventListItem);
diff --git a/app/components/Layout.tsx b/app/components/Layout.tsx
index ae85ceddb..f93c6fc01 100644
--- a/app/components/Layout.tsx
+++ b/app/components/Layout.tsx
@@ -15,10 +15,15 @@ import { isModKey } from "~/utils/keyboard";
type Props = {
title?: string;
sidebar?: React.ReactNode;
- rightRail?: React.ReactNode;
+ sidebarRight?: React.ReactNode;
};
-const Layout: React.FC = ({ title, children, sidebar, rightRail }) => {
+const Layout: React.FC = ({
+ title,
+ children,
+ sidebar,
+ sidebarRight,
+}) => {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
@@ -60,7 +65,7 @@ const Layout: React.FC = ({ title, children, sidebar, rightRail }) => {
{children}
- {rightRail}
+ {sidebarRight}
);
diff --git a/app/components/List/Item.tsx b/app/components/List/Item.tsx
index 6a5973585..708e7d016 100644
--- a/app/components/List/Item.tsx
+++ b/app/components/List/Item.tsx
@@ -3,7 +3,7 @@ import styled, { useTheme } from "styled-components";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
-export type Props = {
+export type Props = Omit, "title"> & {
image?: React.ReactNode;
to?: string;
exact?: boolean;
diff --git a/app/components/NudeButton.tsx b/app/components/NudeButton.tsx
index 8412c2bf5..9459c0b4f 100644
--- a/app/components/NudeButton.tsx
+++ b/app/components/NudeButton.tsx
@@ -10,7 +10,7 @@ type Props = ActionButtonProps & {
type?: "button" | "submit" | "reset";
};
-const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({
+const NudeButton = styled(ActionButton).attrs((props: Props) => ({
type: "type" in props ? props.type : "button",
}))`
width: ${(props) => props.width || props.size || 24}px;
@@ -26,4 +26,4 @@ const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({
color: inherit;
`;
-export default StyledNudeButton;
+export default NudeButton;
diff --git a/app/components/PaginatedEventList.tsx b/app/components/PaginatedEventList.tsx
index de31df948..0ec42ca48 100644
--- a/app/components/PaginatedEventList.tsx
+++ b/app/components/PaginatedEventList.tsx
@@ -29,17 +29,15 @@ const PaginatedEventList = React.memo(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
- renderItem={(item: Event, index, compositeProps) => {
- return (
-
- );
- }}
+ renderItem={(item: Event, index, compositeProps) => (
+
+ )}
renderHeading={(name) => {name}}
{...rest}
/>
diff --git a/app/menus/RevisionMenu.tsx b/app/menus/RevisionMenu.tsx
index b499c41fb..e7f7903f4 100644
--- a/app/menus/RevisionMenu.tsx
+++ b/app/menus/RevisionMenu.tsx
@@ -1,19 +1,18 @@
import { observer } from "mobx-react";
-import { RestoreIcon, LinkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
-import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
-import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
-import Separator from "~/components/ContextMenu/Separator";
-import CopyToClipboard from "~/components/CopyToClipboard";
-import MenuIconWrapper from "~/components/MenuIconWrapper";
-import useCurrentTeam from "~/hooks/useCurrentTeam";
-import useToasts from "~/hooks/useToasts";
-import { documentHistoryUrl } from "~/utils/routeHelpers";
+import Template from "~/components/ContextMenu/Template";
+import { actionToMenuItem } from "~/actions";
+import {
+ copyLinkToRevision,
+ restoreRevision,
+} from "~/actions/definitions/revisions";
+import useActionContext from "~/hooks/useActionContext";
+import separator from "./separator";
type Props = {
document: Document;
@@ -21,47 +20,14 @@ type Props = {
className?: string;
};
-function RevisionMenu({ document, revisionId, className }: Props) {
- const { showToast } = useToasts();
- const team = useCurrentTeam();
+function RevisionMenu({ document, className }: Props) {
const menu = useMenuState({
modal: true,
});
const { t } = useTranslation();
- const history = useHistory();
-
- const handleRestore = React.useCallback(
- async (ev: React.SyntheticEvent) => {
- ev.preventDefault();
-
- if (team.collaborativeEditing) {
- history.push(document.url, {
- restore: true,
- revisionId,
- });
- } else {
- await document.restore({
- revisionId,
- });
- showToast(t("Document restored"), {
- type: "success",
- });
- history.push(document.url);
- }
- },
- [history, showToast, t, team.collaborativeEditing, document, revisionId]
- );
-
- const handleCopy = React.useCallback(() => {
- showToast(t("Link copied"), {
- type: "info",
- });
- }, [showToast, t]);
-
- const url = `${window.location.origin}${documentHistoryUrl(
- document,
- revisionId
- )}`;
+ const context = useActionContext({
+ activeDocumentId: document.id,
+ });
return (
<>
@@ -72,21 +38,13 @@ function RevisionMenu({ document, revisionId, className }: Props) {
{...menu}
/>
-
-
-
-
-
+
>
);
diff --git a/app/models/Document.ts b/app/models/Document.ts
index 323556b20..ad132fd6b 100644
--- a/app/models/Document.ts
+++ b/app/models/Document.ts
@@ -2,6 +2,7 @@ import { addDays, differenceInDays } from "date-fns";
import { floor } from "lodash";
import { action, autorun, computed, observable, set } from "mobx";
import parseTitle from "@shared/utils/parseTitle";
+import { isRTL } from "@shared/utils/rtl";
import DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import type { NavigationNode } from "~/types";
@@ -106,23 +107,19 @@ export default class Document extends ParanoidModel {
}
/**
- * Best-guess the text direction of the document based on the language the
- * title is written in. Note: wrapping as a computed getter means that it will
- * only be called directly when the title changes.
+ * Returns the direction of the document text, either "rtl" or "ltr"
*/
@computed
get dir(): "rtl" | "ltr" {
- const element = document.createElement("p");
- element.innerText = this.title;
- element.style.visibility = "hidden";
- element.dir = "auto";
+ return this.rtl ? "rtl" : "ltr";
+ }
- // element must appear in body for direction to be computed
- document.body?.appendChild(element);
- const direction = window.getComputedStyle(element).direction;
- document.body?.removeChild(element);
-
- return direction === "rtl" ? "rtl" : "ltr";
+ /**
+ * Returns true if the document text is right-to-left
+ */
+ @computed
+ get rtl() {
+ return isRTL(this.title);
}
@computed
diff --git a/app/models/Revision.ts b/app/models/Revision.ts
index b5243e846..c1c7a7854 100644
--- a/app/models/Revision.ts
+++ b/app/models/Revision.ts
@@ -1,3 +1,5 @@
+import { computed } from "mobx";
+import { isRTL } from "@shared/utils/rtl";
import BaseModel from "./BaseModel";
import User from "./User";
@@ -6,13 +8,34 @@ class Revision extends BaseModel {
documentId: string;
+ /** The document title when the revision was created */
title: string;
+ /** Markdown string of the content when revision was created */
text: string;
+ /** HTML string representing the revision as a diff from the previous version */
+ html: string;
+
createdAt: string;
createdBy: User;
+
+ /**
+ * Returns the direction of the revision text, either "rtl" or "ltr"
+ */
+ @computed
+ get dir(): "rtl" | "ltr" {
+ return this.rtl ? "rtl" : "ltr";
+ }
+
+ /**
+ * Returns true if the revision text is right-to-left
+ */
+ @computed
+ get rtl() {
+ return isRTL(this.title);
+ }
}
export default Revision;
diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx
index 043eea54e..ebf4f5e3d 100644
--- a/app/routes/authenticated.tsx
+++ b/app/routes/authenticated.tsx
@@ -7,7 +7,7 @@ import Drafts from "~/scenes/Drafts";
import Error404 from "~/scenes/Error404";
import Templates from "~/scenes/Templates";
import Trash from "~/scenes/Trash";
-import Layout from "~/components/AuthenticatedLayout";
+import AuthenticatedLayout from "~/components/AuthenticatedLayout";
import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import Route from "~/components/ProfiledRoute";
@@ -68,7 +68,7 @@ function AuthenticatedRoutes() {
return (
-
+
@@ -115,7 +115,7 @@ function AuthenticatedRoutes() {
-
+
);
}
diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx
index b0db96afe..819fb4b7b 100644
--- a/app/scenes/Document/components/DataLoader.tsx
+++ b/app/scenes/Document/components/DataLoader.tsx
@@ -42,7 +42,15 @@ type Props = RouteComponentProps & {
};
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(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) => {
diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx
index 9d86f4782..d8cb9c17b 100644
--- a/app/scenes/Document/components/Document.tsx
+++ b/app/scenes/Document/components/Document.tsx
@@ -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 {
} = 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 {
}>
- {showContents && (
-
- )}
-
- {shareId && (
-
-
+ {showContents && (
+
-
- )}
- {!isShare && !revision && (
- <>
-
-
-
-
- >
- )}
-
+ )}
+
+ {shareId && (
+
+
+
+ )}
+ {!isShare && !revision && (
+ <>
+
+
+
+
+ >
+ )}
+
+ >
+ )}
diff --git a/app/scenes/Document/components/EditableTitle.tsx b/app/scenes/Document/components/EditableTitle.tsx
index e70829364..97c70b202 100644
--- a/app/scenes/Document/components/EditableTitle.tsx
+++ b/app/scenes/Document/components/EditableTitle.tsx
@@ -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
) => {
- 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"
diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx
index 0401a355b..d20c3416f 100644
--- a/app/scenes/Document/components/Editor.tsx
+++ b/app/scenes/Document/components/Editor.tsx
@@ -18,7 +18,6 @@ import EditableTitle from "./EditableTitle";
type Props = Omit & {
onChangeTitle: (text: string) => void;
- title: string;
id: string;
document: Document;
isDraft: boolean;
@@ -41,7 +40,6 @@ function DocumentEditor(props: Props, ref: React.RefObject) {
const match = useRouteMatch();
const {
document,
- title,
onChangeTitle,
isDraft,
shareId,
@@ -82,7 +80,6 @@ function DocumentEditor(props: Props, ref: React.RefObject) {
) {
)}
{t("Saving")}…
)}
- {!isDeleted && }
+ {!isDeleted && !isRevision && }
{(isEditing || team?.collaborativeEditing) && !isTemplate && isNew && (
)}
- {!isEditing && !isDeleted && (!isMobile || !isTemplate) && (
-
-
-
- )}
+ {!isEditing &&
+ !isDeleted &&
+ !isRevision &&
+ (!isMobile || !isTemplate) && (
+
+
+
+ )}
{isEditing && (
<>
@@ -251,8 +260,11 @@ function DocumentHeader({
>
)}
- {canEdit && !team?.collaborativeEditing && editAction}
- {canEdit && can.createChildDocument && !isMobile && (
+ {canEdit &&
+ !team?.collaborativeEditing &&
+ !isRevision &&
+ editAction}
+ {canEdit && can.createChildDocument && !isRevision && !isMobile && (
)}
+ {isRevision && (
+
+
+
+
+
+ )}
{can.update && isDraft && !isRevision && (
& {
+ 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 (
+
+
{revision.title}
+ {!shareId && (
+
+ )}
+
+ {children}
+
+ );
+}
+
+export default observer(RevisionViewer);
diff --git a/package.json b/package.json
index 2dc785ca6..c6bc115b9 100644
--- a/package.json
+++ b/package.json
@@ -137,6 +137,7 @@
"mobx-react": "^6.3.1",
"natural-sort": "^1.0.0",
"node-fetch": "2.6.7",
+ "node-htmldiff": "^0.9.4",
"nodemailer": "^6.6.1",
"outline-icons": "^1.45.1",
"oy-vey": "^0.11.2",
diff --git a/server/models/Revision.ts b/server/models/Revision.ts
index c43183267..e275e03b1 100644
--- a/server/models/Revision.ts
+++ b/server/models/Revision.ts
@@ -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 {
+ return (this.constructor as typeof Revision).findOne({
+ where: {
+ documentId: this.documentId,
+ createdAt: {
+ [Op.lt]: this.createdAt,
+ },
+ },
+ order: [["createdAt", "DESC"]],
+ });
+ }
+
migrateVersion = function () {
let migrated = false;
diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx
index e26175ce8..efc0688eb 100644
--- a/server/models/helpers/DocumentHelper.tsx
+++ b/server/models/helpers/DocumentHelper.tsx
@@ -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 && (
+
{document.title}
+ )}
+
+
+
+ >
+ );
+
// 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(
<>
-
-
-