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

* tidy

* Add title to HTML export

* fix: Add compatability for documents without collab state

* Add HTML download option to UI

* docs

* fix nodes that required document to render

* Refactor to allow for styling of HTML export

* div>article for easier programatic content extraction

* Allow DocumentHelper to be used with Revisions

* Add revisions.diff endpoint, first version

* Allow arbitrary revisions to be compared

* test

* HTML driven revision viewer

* fix: Dark mode styles for document diffs

* Add revision restore button to header

* test

* Support RTL languages in revision history viewer

* fix: RTL support
Remove unneccessary API requests

* Prefetch revision data

* Animate history sidebar

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

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

View File

@@ -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 = [];

View File

@@ -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,

View File

@@ -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");

View File

@@ -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);

View File

@@ -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>}

View File

@@ -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;
`;

View File

@@ -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);

View File

@@ -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",

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}
/>

View File

@@ -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>
</>
);

View File

@@ -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

View File

@@ -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;

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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),
};

View File

@@ -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;

View File

@@ -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
)}"`
);

View File

@@ -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();

View File

@@ -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

View File

@@ -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 dont have permission to access the document": "Sorry, it looks like you dont have permission to access the document",

File diff suppressed because it is too large Load Diff

15
shared/utils/rtl.ts Normal file
View 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);
}

View File

@@ -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"