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:
85
app/actions/definitions/revisions.tsx
Normal file
85
app/actions/definitions/revisions.tsx
Normal file
@@ -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: <RestoreIcon />,
|
||||
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: <LinkIcon />,
|
||||
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 = [];
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Props> {
|
||||
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<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
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 <ErrorSuspended />;
|
||||
}
|
||||
if (auth.isSuspended) {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const sidebar = showSidebar ? (
|
||||
<Fade>
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
</Fade>
|
||||
) : undefined;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
|
||||
const rightRail = (
|
||||
<React.Suspense fallback={null}>
|
||||
<Switch>
|
||||
const sidebar = showSidebar ? (
|
||||
<Fade>
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
</Fade>
|
||||
) : undefined;
|
||||
|
||||
const sidebarRight = (
|
||||
<React.Suspense fallback={null}>
|
||||
<AnimatePresence>
|
||||
<Switch
|
||||
location={location}
|
||||
key={
|
||||
matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
})
|
||||
? "history"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Route
|
||||
key="document-history"
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={DocumentHistory}
|
||||
/>
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
);
|
||||
</AnimatePresence>
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout title={team?.name} sidebar={sidebar} rightRail={rightRail}>
|
||||
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
|
||||
{this.props.children}
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default withTranslation()(withStores(AuthenticatedLayout));
|
||||
export default observer(AuthenticatedLayout);
|
||||
|
||||
@@ -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)<RealProps>`
|
||||
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<T> = {
|
||||
export type Props<T> = ActionButtonProps & {
|
||||
icon?: React.ReactNode;
|
||||
iconColor?: string;
|
||||
children?: React.ReactNode;
|
||||
@@ -168,12 +173,19 @@ const Button = <T extends React.ElementType = "button">(
|
||||
props: Props<T> & React.ComponentPropsWithoutRef<T>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
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 (
|
||||
<RealButton type={type || "button"} ref={ref} $neutral={neutral} {...rest}>
|
||||
<RealButton
|
||||
type={type || "button"}
|
||||
ref={ref}
|
||||
$neutral={neutral}
|
||||
action={action}
|
||||
{...rest}
|
||||
>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
|
||||
@@ -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 (
|
||||
<Sidebar>
|
||||
<Sidebar
|
||||
initial={{
|
||||
width: 0,
|
||||
}}
|
||||
animate={{
|
||||
transition: {
|
||||
type: "spring",
|
||||
bounce: 0.2,
|
||||
duration: 0.6,
|
||||
},
|
||||
width: theme.sidebarWidth,
|
||||
}}
|
||||
exit={{
|
||||
width: 0,
|
||||
}}
|
||||
>
|
||||
{document ? (
|
||||
<Position column>
|
||||
<Header>
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLAnchorElement>(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 = <CheckboxIcon color="currentColor" size={16} checked />;
|
||||
meta = t("Latest version");
|
||||
to = documentHistoryUrl(document);
|
||||
break;
|
||||
} else {
|
||||
icon = <EditIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} edited", opts);
|
||||
to = documentHistoryUrl(document, event.modelId || "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
icon = <EditIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} edited", opts);
|
||||
to = documentHistoryUrl(document, event.modelId || "");
|
||||
break;
|
||||
|
||||
case "documents.live_editing":
|
||||
icon = <LightningIcon color="currentColor" size={16} />;
|
||||
meta = t("Live editing");
|
||||
to = documentHistoryUrl(document);
|
||||
break;
|
||||
|
||||
case "documents.archive":
|
||||
icon = <ArchiveIcon color="currentColor" size={16} />;
|
||||
@@ -136,10 +140,11 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
</Subtitle>
|
||||
}
|
||||
actions={
|
||||
isRevision && isActive && event.modelId && can.update ? (
|
||||
isRevision && isActive && event.modelId ? (
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
) : undefined
|
||||
}
|
||||
onMouseEnter={prefetchRevision}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -217,4 +222,4 @@ const CompositeListItem = styled(CompositeItem)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export default EventListItem;
|
||||
export default observer(EventListItem);
|
||||
|
||||
@@ -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<Props> = ({ title, children, sidebar, rightRail }) => {
|
||||
const Layout: React.FC<Props> = ({
|
||||
title,
|
||||
children,
|
||||
sidebar,
|
||||
sidebarRight,
|
||||
}) => {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
|
||||
|
||||
@@ -60,7 +65,7 @@ const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{rightRail}
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -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<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
|
||||
image?: React.ReactNode;
|
||||
to?: string;
|
||||
exact?: boolean;
|
||||
|
||||
@@ -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",
|
||||
}))<Props>`
|
||||
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;
|
||||
|
||||
@@ -29,17 +29,15 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event, index, compositeProps) => {
|
||||
return (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
{...compositeProps}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderItem={(item: Event, index, compositeProps) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
{...compositeProps}
|
||||
/>
|
||||
)}
|
||||
renderHeading={(name) => <Heading>{name}</Heading>}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<ContextMenu {...menu} aria-label={t("Revision options")}>
|
||||
<MenuItem {...menu} onClick={handleRestore}>
|
||||
<MenuIconWrapper>
|
||||
<RestoreIcon />
|
||||
</MenuIconWrapper>
|
||||
{t("Restore version")}
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<CopyToClipboard text={url} onCopy={handleCopy}>
|
||||
<MenuItem {...menu}>
|
||||
<MenuIconWrapper>
|
||||
<LinkIcon />
|
||||
</MenuIconWrapper>
|
||||
{t("Copy link")}
|
||||
</MenuItem>
|
||||
</CopyToClipboard>
|
||||
<Template
|
||||
items={[
|
||||
actionToMenuItem(restoreRevision, context),
|
||||
separator(),
|
||||
actionToMenuItem(copyLinkToRevision, context),
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<WebsocketProvider>
|
||||
<Layout>
|
||||
<AuthenticatedLayout>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
@@ -115,7 +115,7 @@ function AuthenticatedRoutes() {
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</AuthenticatedLayout>
|
||||
</WebsocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Revision } from "@server/models";
|
||||
import presentUser from "./user";
|
||||
|
||||
export default async function present(revision: Revision) {
|
||||
export default async function present(revision: Revision, diff?: string) {
|
||||
await revision.migrateVersion();
|
||||
return {
|
||||
id: revision.id,
|
||||
documentId: revision.documentId,
|
||||
title: revision.title,
|
||||
text: revision.text,
|
||||
html: diff,
|
||||
createdAt: revision.createdAt,
|
||||
createdBy: presentUser(revision.user),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import env from "@server/env";
|
||||
import Document from "@server/models/Document";
|
||||
import { Event } from "@server/types";
|
||||
import { globalEventQueue } from "..";
|
||||
@@ -15,7 +16,9 @@ export default class DebounceProcessor extends BaseProcessor {
|
||||
globalEventQueue.add(
|
||||
{ ...event, name: "documents.update.delayed" },
|
||||
{
|
||||
delay: 5 * 60 * 1000,
|
||||
// speed up revision creation in development, we don't have all the
|
||||
// time in the world.
|
||||
delay: (env.ENVIRONMENT === "development" ? 1 : 5) * 60 * 1000,
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
presentDocument,
|
||||
presentPolicies,
|
||||
} from "@server/presenters";
|
||||
import slugify from "@server/utils/slugify";
|
||||
import {
|
||||
assertUuid,
|
||||
assertSort,
|
||||
@@ -471,7 +472,7 @@ router.post(
|
||||
ctx.set("Content-Type", contentType);
|
||||
ctx.set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${document.title}.${mime.extension(
|
||||
`attachment; filename="${slugify(document.title)}.${mime.extension(
|
||||
contentType
|
||||
)}"`
|
||||
);
|
||||
|
||||
@@ -39,6 +39,91 @@ describe("#revisions.info", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#revisions.diff", () => {
|
||||
it("should return the document HTML if no previous revision", async () => {
|
||||
const { user, document } = await seed();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const res = await server.post("/api/revisions.diff", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// Can't compare entire HTML output due to generated class names
|
||||
expect(body.data).toContain("<html");
|
||||
expect(body.data).toContain("<style");
|
||||
expect(body.data).toContain("<h1");
|
||||
expect(body.data).not.toContain("<ins");
|
||||
expect(body.data).not.toContain("<del");
|
||||
expect(body.data).toContain(document.title);
|
||||
});
|
||||
|
||||
it("should allow returning HTML directly with accept header", async () => {
|
||||
const { user, document } = await seed();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const res = await server.post("/api/revisions.diff", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
},
|
||||
headers: {
|
||||
accept: "text/html",
|
||||
},
|
||||
});
|
||||
const body = await res.text();
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// Can't compare entire HTML output due to generated class names
|
||||
expect(body).toContain("<html");
|
||||
expect(body).toContain("<style");
|
||||
expect(body).toContain("<h1");
|
||||
expect(body).not.toContain("<ins");
|
||||
expect(body).not.toContain("<del");
|
||||
expect(body).toContain(document.title);
|
||||
});
|
||||
|
||||
it("should compare to previous revision by default", async () => {
|
||||
const { user, document } = await seed();
|
||||
await Revision.createFromDocument(document);
|
||||
|
||||
await document.update({ text: "New text" });
|
||||
const revision1 = await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.diff", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision1.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// Can't compare entire HTML output due to generated class names
|
||||
expect(body.data).toContain("<html");
|
||||
expect(body.data).toContain("<style");
|
||||
expect(body.data).toContain("<h1");
|
||||
expect(body.data).toContain("<ins");
|
||||
expect(body.data).toContain("<del");
|
||||
expect(body.data).toContain(document.title);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/revisions.diff", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#revisions.list", () => {
|
||||
it("should return a document's revisions", async () => {
|
||||
const { user, document } = await seed();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import Router from "koa-router";
|
||||
import { Op } from "sequelize";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { Document, Revision } from "@server/models";
|
||||
import DocumentHelper from "@server/models/helpers/DocumentHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentRevision } from "@server/presenters";
|
||||
import slugify from "@server/utils/slugify";
|
||||
import { assertPresent, assertSort, assertUuid } from "@server/validation";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
@@ -21,9 +25,68 @@ router.post("revisions.info", auth(), async (ctx) => {
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
const before = await revision.previous();
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: await presentRevision(revision),
|
||||
data: await presentRevision(
|
||||
revision,
|
||||
DocumentHelper.diff(before, revision, {
|
||||
includeTitle: false,
|
||||
includeStyles: false,
|
||||
})
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("revisions.diff", auth(), async (ctx) => {
|
||||
const { id, compareToId } = ctx.body;
|
||||
assertUuid(id, "id is required");
|
||||
|
||||
const { user } = ctx.state;
|
||||
const revision = await Revision.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
const document = await Document.findByPk(revision.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
let before;
|
||||
if (compareToId) {
|
||||
assertUuid(compareToId, "compareToId must be a UUID");
|
||||
before = await Revision.findOne({
|
||||
where: {
|
||||
id: compareToId,
|
||||
documentId: revision.documentId,
|
||||
createdAt: {
|
||||
[Op.lt]: revision.createdAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!before) {
|
||||
throw ValidationError(
|
||||
"Revision could not be found, compareToId must be a revision of the same document before the provided revision"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
before = await revision.previous();
|
||||
}
|
||||
|
||||
const accept = ctx.request.headers["accept"];
|
||||
const content = DocumentHelper.diff(before, revision);
|
||||
|
||||
if (accept?.includes("text/html")) {
|
||||
ctx.set("Content-Type", "text/html");
|
||||
ctx.set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${slugify(document.title)}.html"`
|
||||
);
|
||||
ctx.body = content;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: content,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,10 @@
|
||||
"Changelog": "Changelog",
|
||||
"Keyboard shortcuts": "Keyboard shortcuts",
|
||||
"Log out": "Log out",
|
||||
"Restore revision": "Restore revision",
|
||||
"Document restored": "Document restored",
|
||||
"Copy link": "Copy link",
|
||||
"Link copied": "Link copied",
|
||||
"Dark": "Dark",
|
||||
"Light": "Light",
|
||||
"System": "System",
|
||||
@@ -65,6 +69,7 @@
|
||||
"Collection": "Collection",
|
||||
"Debug": "Debug",
|
||||
"Document": "Document",
|
||||
"Revision": "Revision",
|
||||
"Navigation": "Navigation",
|
||||
"People": "People",
|
||||
"Recent searches": "Recent searches",
|
||||
@@ -138,8 +143,8 @@
|
||||
"our engineers have been notified": "our engineers have been notified",
|
||||
"Report a Bug": "Report a Bug",
|
||||
"Show Detail": "Show Detail",
|
||||
"Latest version": "Latest version",
|
||||
"{{userName}} edited": "{{userName}} edited",
|
||||
"Live editing": "Live editing",
|
||||
"{{userName}} archived": "{{userName}} archived",
|
||||
"{{userName}} restored": "{{userName}} restored",
|
||||
"{{userName}} deleted": "{{userName}} deleted",
|
||||
@@ -287,7 +292,6 @@
|
||||
"Manual sort": "Manual sort",
|
||||
"Edit": "Edit",
|
||||
"Permissions": "Permissions",
|
||||
"Document restored": "Document restored",
|
||||
"Document unpublished": "Document unpublished",
|
||||
"Document options": "Document options",
|
||||
"Restore": "Restore",
|
||||
@@ -304,10 +308,7 @@
|
||||
"New child document": "New child document",
|
||||
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
|
||||
"New template": "New template",
|
||||
"Link copied": "Link copied",
|
||||
"Revision options": "Revision options",
|
||||
"Restore version": "Restore version",
|
||||
"Copy link": "Copy link",
|
||||
"Share link revoked": "Share link revoked",
|
||||
"Share link copied": "Share link copied",
|
||||
"Share options": "Share options",
|
||||
@@ -415,6 +416,7 @@
|
||||
"Save Draft": "Save Draft",
|
||||
"Done Editing": "Done Editing",
|
||||
"New from template": "New from template",
|
||||
"Restore version": "Restore version",
|
||||
"Publish": "Publish",
|
||||
"Publishing": "Publishing",
|
||||
"Sorry, it looks like you don’t have permission to access the document": "Sorry, it looks like you don’t have permission to access the document",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
15
shared/utils/rtl.ts
Normal file
15
shared/utils/rtl.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const ltrChars =
|
||||
"A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF";
|
||||
const rtlChars = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC";
|
||||
// eslint-disable-next-line no-misleading-character-class
|
||||
const rtlDirCheck = new RegExp("^[^" + ltrChars + "]*[" + rtlChars + "]");
|
||||
|
||||
/**
|
||||
* Returns true if the text is likely written in an RTL language.
|
||||
*
|
||||
* @param text The text to check
|
||||
* @returns True if the text is RTL
|
||||
*/
|
||||
export function isRTL(text: string) {
|
||||
return rtlDirCheck.test(text);
|
||||
}
|
||||
@@ -11064,6 +11064,11 @@ node-gyp-build@^3.9.0:
|
||||
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-3.9.0.tgz#53a350187dd4d5276750da21605d1cb681d09e25"
|
||||
integrity sha512-zLcTg6P4AbcHPq465ZMFNXx7XpKKJh+7kkN699NiQWisR2uWYOWNWqRHAmbnmKiL4e9aLSlmy5U7rEMUXV59+A==
|
||||
|
||||
node-htmldiff@^0.9.4:
|
||||
version "0.9.4"
|
||||
resolved "https://registry.yarnpkg.com/node-htmldiff/-/node-htmldiff-0.9.4.tgz#d8fec52fbe736780afff28d2c8476ec106520887"
|
||||
integrity sha512-Nvnv0bcehOFsH/TD+bi4ls3iWTRQiytqII5+I1iBUypO+GFMYLcyBJfS2U3DMRSIYzfZHysaYLYoCXx6Q148Hg==
|
||||
|
||||
node-int64@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
|
||||
|
||||
Reference in New Issue
Block a user