Add document insights panel (#4418)
* Add document context to allow accessing editor in header, modals, and elsewhere * lint * framework * Hacking together fast * Insights * Spacing tweak, docs
This commit is contained in:
@@ -18,6 +18,8 @@ import {
|
||||
CrossIcon,
|
||||
ArchiveIcon,
|
||||
ShuffleIcon,
|
||||
HistoryIcon,
|
||||
LightBulbIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
@@ -28,7 +30,13 @@ import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, newDocumentPath, searchPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
documentInsightsUrl,
|
||||
documentHistoryUrl,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -571,6 +579,46 @@ export const permanentlyDeleteDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentHistory = createAction({
|
||||
name: ({ t }) => t("History"),
|
||||
section: DocumentSection,
|
||||
icon: <HistoryIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.read && !can.restore;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentHistoryUrl(document));
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInsights = createAction({
|
||||
name: ({ t }) => t("Insights"),
|
||||
section: DocumentSection,
|
||||
icon: <LightBulbIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.read;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentInsightsUrl(document));
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
@@ -590,4 +638,6 @@ export const rootDocumentActions = [
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
export type Props = React.ComponentPropsWithoutRef<"button"> & {
|
||||
/** Show the button in a disabled state */
|
||||
disabled?: boolean;
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
@@ -20,13 +20,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
*/
|
||||
const ActionButton = React.forwardRef(
|
||||
(
|
||||
{
|
||||
action,
|
||||
context,
|
||||
tooltip,
|
||||
hideOnActionDisabled,
|
||||
...rest
|
||||
}: Props & React.HTMLAttributes<HTMLButtonElement>,
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
const disabled = rest.disabled;
|
||||
|
||||
@@ -3,10 +3,13 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import DocumentContext from "~/components/DocumentContext";
|
||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -23,7 +26,14 @@ const DocumentHistory = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "document-history" */
|
||||
"~/components/DocumentHistory"
|
||||
"~/scenes/Document/components/History"
|
||||
)
|
||||
);
|
||||
const DocumentInsights = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "document-insights" */
|
||||
"~/scenes/Document/components/Insights"
|
||||
)
|
||||
);
|
||||
const CommandBar = React.lazy(
|
||||
@@ -39,6 +49,12 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const can = usePolicy(ui.activeCollectionId);
|
||||
const { user, team } = auth;
|
||||
const [documentContext] = React.useState<DocumentContextValue>({
|
||||
editor: null,
|
||||
setEditor: (editor: TEditor) => {
|
||||
documentContext.editor = editor;
|
||||
},
|
||||
});
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
@@ -84,7 +100,7 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
path: matchDocumentHistory,
|
||||
})
|
||||
? "history"
|
||||
: ""
|
||||
: location.pathname
|
||||
}
|
||||
>
|
||||
<Route
|
||||
@@ -92,19 +108,26 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={DocumentHistory}
|
||||
/>
|
||||
<Route
|
||||
key="document-history"
|
||||
path={`/doc/${slug}/insights`}
|
||||
component={DocumentInsights}
|
||||
/>
|
||||
</Switch>
|
||||
</AnimatePresence>
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
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>
|
||||
<DocumentContext.Provider value={documentContext}>
|
||||
<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>
|
||||
</DocumentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -173,10 +173,19 @@ const Button = <T extends React.ElementType = "button">(
|
||||
props: Props<T> & React.ComponentPropsWithoutRef<T>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
const { type, children, value, disclosure, neutral, action, ...rest } = props;
|
||||
const {
|
||||
type,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
neutral,
|
||||
action,
|
||||
icon,
|
||||
...rest
|
||||
} = props;
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const icon = action?.icon ?? rest.icon;
|
||||
const hasIcon = icon !== undefined;
|
||||
const ic = action?.icon ?? icon;
|
||||
const hasIcon = ic !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton
|
||||
@@ -187,7 +196,7 @@ const Button = <T extends React.ElementType = "button">(
|
||||
{...rest}
|
||||
>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasIcon && ic}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon color="currentColor" />}
|
||||
</Inner>
|
||||
|
||||
19
app/components/DocumentContext.ts
Normal file
19
app/components/DocumentContext.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { Editor } from "~/editor";
|
||||
|
||||
export type DocumentContextValue = {
|
||||
/** The current editor instance for this document. */
|
||||
editor: Editor | null;
|
||||
/** Set the current editor instance for this document. */
|
||||
setEditor: (editor: Editor) => void;
|
||||
};
|
||||
|
||||
const DocumentContext = React.createContext<DocumentContextValue>({
|
||||
editor: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
setEditor() {},
|
||||
});
|
||||
|
||||
export const useDocumentContext = () => React.useContext(DocumentContext);
|
||||
|
||||
export default DocumentContext;
|
||||
@@ -1,158 +0,0 @@
|
||||
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, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Event from "~/models/Event";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
|
||||
const EMPTY_ARRAY: Event[] = [];
|
||||
|
||||
function DocumentHistory() {
|
||||
const { events, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const theme = useTheme();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
|
||||
const eventsInDocument = document
|
||||
? events.inDocument(document.id)
|
||||
: EMPTY_ARRAY;
|
||||
|
||||
const onCloseHistory = () => {
|
||||
if (document) {
|
||||
history.push(documentUrl(document));
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (
|
||||
eventsInDocument[0] &&
|
||||
document &&
|
||||
eventsInDocument[0].createdAt !== document.updatedAt
|
||||
) {
|
||||
eventsInDocument.unshift(
|
||||
new Event(
|
||||
{
|
||||
id: "live",
|
||||
name: "documents.live_editing",
|
||||
documentId: document.id,
|
||||
createdAt: document.updatedAt,
|
||||
actor: document.updatedBy,
|
||||
},
|
||||
events
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return eventsInDocument;
|
||||
}, [eventsInDocument, events, document]);
|
||||
|
||||
useKeyDown("Escape", onCloseHistory);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
initial={{
|
||||
width: 0,
|
||||
}}
|
||||
animate={{
|
||||
transition: {
|
||||
type: "spring",
|
||||
bounce: 0.2,
|
||||
duration: 0.6,
|
||||
},
|
||||
width: theme.sidebarWidth,
|
||||
}}
|
||||
exit={{
|
||||
width: 0,
|
||||
}}
|
||||
>
|
||||
{document ? (
|
||||
<Position column>
|
||||
<Header>
|
||||
<Title>{t("History")}</Title>
|
||||
<Button
|
||||
icon={<CloseIcon />}
|
||||
onClick={onCloseHistory}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Header>
|
||||
<Scrollable topShadow>
|
||||
<PaginatedEventList
|
||||
aria-label={t("History")}
|
||||
fetch={events.fetchPage}
|
||||
events={items}
|
||||
options={{
|
||||
documentId: document.id,
|
||||
}}
|
||||
document={document}
|
||||
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
|
||||
/>
|
||||
</Scrollable>
|
||||
</Position>
|
||||
) : null}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const EmptyHistory = styled(Empty)`
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
const Position = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
`;
|
||||
|
||||
const Sidebar = styled(m.div)`
|
||||
display: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: ${(props) => props.theme.background};
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled(Flex)`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 16px 12px;
|
||||
color: ${(props) => props.theme.text};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(DocumentHistory);
|
||||
@@ -46,11 +46,11 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
});
|
||||
|
||||
const StyledPaginatedList = styled(PaginatedList)`
|
||||
padding: 0 8px;
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
const Heading = styled("h3")`
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
padding: 0 4px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const ResizeBorder = styled.div`
|
||||
const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -1px;
|
||||
right: ${(props) => (props.dir !== "right" ? "-1px" : "auto")};
|
||||
left: ${(props) => (props.dir === "right" ? "-1px" : "auto")};
|
||||
width: 2px;
|
||||
cursor: col-resize;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import getHeadings from "@shared/editor/lib/getHeadings";
|
||||
import getTasks from "@shared/editor/lib/getTasks";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
@@ -571,6 +572,9 @@ export class Editor extends React.PureComponent<
|
||||
this.setState({ blockMenuOpen: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the editor at the start of the content.
|
||||
*/
|
||||
public focusAtStart = () => {
|
||||
const selection = Selection.atStart(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
@@ -578,6 +582,9 @@ export class Editor extends React.PureComponent<
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the editor at the end of the content.
|
||||
*/
|
||||
public focusAtEnd = () => {
|
||||
const selection = Selection.atEnd(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
@@ -585,14 +592,40 @@ export class Editor extends React.PureComponent<
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the headings in the current editor.
|
||||
*
|
||||
* @returns A list of headings in the document
|
||||
*/
|
||||
public getHeadings = () => {
|
||||
return getHeadings(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the tasks/checkmarks in the current editor.
|
||||
*
|
||||
* @returns A list of tasks in the document
|
||||
*/
|
||||
public getTasks = () => {
|
||||
return getTasks(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the plain text content of the current editor.
|
||||
*
|
||||
* @returns A string of text
|
||||
*/
|
||||
public getPlainText = () => {
|
||||
const { doc } = this.view.state;
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
|
||||
return textBetween(doc, 0, doc.content.size, textSerializers);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const {
|
||||
dir,
|
||||
|
||||
22
app/hooks/useTextSelection.ts
Normal file
22
app/hooks/useTextSelection.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
/**
|
||||
* A hook that returns the currently selected text.
|
||||
*
|
||||
* @returns The selected text
|
||||
*/
|
||||
export default function useTextSelection() {
|
||||
const [selection, setSelection] = React.useState<string>("");
|
||||
|
||||
const handleMouse = React.useCallback(() => {
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString();
|
||||
setSelection(text ?? "");
|
||||
}, []);
|
||||
|
||||
useEventListener("mousemove", handleMouse);
|
||||
useEventListener("mouseup", handleMouse);
|
||||
|
||||
return selection;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
EditIcon,
|
||||
HistoryIcon,
|
||||
UnpublishIcon,
|
||||
PrintIcon,
|
||||
NewDocumentIcon,
|
||||
@@ -38,6 +37,8 @@ import {
|
||||
unstarDocument,
|
||||
duplicateDocument,
|
||||
archiveDocument,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
@@ -47,12 +48,7 @@ import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { MenuItem } from "~/types";
|
||||
import {
|
||||
documentHistoryUrl,
|
||||
documentUrl,
|
||||
editDocumentUrl,
|
||||
newDocumentPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { editDocumentUrl, newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -70,7 +66,6 @@ type Props = {
|
||||
|
||||
function DocumentMenu({
|
||||
document,
|
||||
isRevision,
|
||||
className,
|
||||
modal = true,
|
||||
showToggleEmbeds,
|
||||
@@ -143,7 +138,6 @@ function DocumentMenu({
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const can = usePolicy(document);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const restoreItems = React.useMemo(
|
||||
() => [
|
||||
...collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
@@ -308,21 +302,9 @@ function DocumentMenu({
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
{
|
||||
type: "route",
|
||||
title: t("History"),
|
||||
to: isRevision
|
||||
? documentUrl(document)
|
||||
: documentHistoryUrl(document),
|
||||
visible: canViewHistory,
|
||||
icon: <HistoryIcon />,
|
||||
},
|
||||
actionToMenuItem(openDocumentHistory, context),
|
||||
actionToMenuItem(openDocumentInsights, context),
|
||||
{
|
||||
type: "button",
|
||||
title: t("Print"),
|
||||
@@ -330,6 +312,11 @@ function DocumentMenu({
|
||||
visible: !!showDisplayOptions,
|
||||
icon: <PrintIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
]}
|
||||
/>
|
||||
{(showDisplayOptions || showToggleEmbeds) && (
|
||||
|
||||
@@ -152,6 +152,13 @@ export default class Document extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get collaborators(): User[] {
|
||||
return this.collaboratorIds
|
||||
.map((id) => this.store.rootStore.users.get(id))
|
||||
.filter(Boolean) as User[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there is a subscription for this document in the store.
|
||||
* Does not consider remote state.
|
||||
|
||||
@@ -106,6 +106,7 @@ function AuthenticatedRoutes() {
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/insights`} component={Document} />
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
|
||||
@@ -54,7 +54,6 @@ function Actions({ collection }: Props) {
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
small
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
import fullPackage from "@shared/editor/packages/full";
|
||||
import Document from "~/models/Document";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
documentUrl,
|
||||
matchDocumentHistory,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { useDocumentContext } from "../../../components/DocumentContext";
|
||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
|
||||
@@ -74,6 +76,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[focusAtStart, ref]
|
||||
);
|
||||
|
||||
const { setEditor } = useDocumentContext();
|
||||
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
|
||||
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
|
||||
return (
|
||||
@@ -103,7 +108,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
/>
|
||||
)}
|
||||
<EditorComponent
|
||||
ref={ref}
|
||||
ref={mergeRefs([ref, handleRefChanged])}
|
||||
autoFocus={!!document.title && !props.defaultValue}
|
||||
placeholder={t("Type '/' to insert, or start writing…")}
|
||||
scrollTo={decodeURIComponent(window.location.hash)}
|
||||
|
||||
82
app/scenes/Document/components/History.tsx
Normal file
82
app/scenes/Document/components/History.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Event from "~/models/Event";
|
||||
import Empty from "~/components/Empty";
|
||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./RightSidebar";
|
||||
|
||||
const EMPTY_ARRAY: Event[] = [];
|
||||
|
||||
function History() {
|
||||
const { events, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
|
||||
const eventsInDocument = document
|
||||
? events.inDocument(document.id)
|
||||
: EMPTY_ARRAY;
|
||||
|
||||
const onCloseHistory = () => {
|
||||
if (document) {
|
||||
history.push(documentUrl(document));
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (
|
||||
eventsInDocument[0] &&
|
||||
document &&
|
||||
eventsInDocument[0].createdAt !== document.updatedAt
|
||||
) {
|
||||
eventsInDocument.unshift(
|
||||
new Event(
|
||||
{
|
||||
id: "live",
|
||||
name: "documents.live_editing",
|
||||
documentId: document.id,
|
||||
createdAt: document.updatedAt,
|
||||
actor: document.updatedBy,
|
||||
},
|
||||
events
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return eventsInDocument;
|
||||
}, [eventsInDocument, events, document]);
|
||||
|
||||
useKeyDown("Escape", onCloseHistory);
|
||||
|
||||
return (
|
||||
<Sidebar title={t("History")} onClose={onCloseHistory}>
|
||||
{document ? (
|
||||
<PaginatedEventList
|
||||
aria-label={t("History")}
|
||||
fetch={events.fetchPage}
|
||||
events={items}
|
||||
options={{
|
||||
documentId: document.id,
|
||||
}}
|
||||
document={document}
|
||||
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
|
||||
/>
|
||||
) : null}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const EmptyHistory = styled(Empty)`
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
export default observer(History);
|
||||
192
app/scenes/Document/components/Insights.tsx
Normal file
192
app/scenes/Document/components/Insights.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useTextSelection from "~/hooks/useTextSelection";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./RightSidebar";
|
||||
|
||||
function Insights() {
|
||||
const { views, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const selectedText = useTextSelection();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const { editor } = useDocumentContext();
|
||||
const text = editor?.getPlainText();
|
||||
const stats = useTextStats(text ?? "", selectedText);
|
||||
const documentViews = document ? views.inDocument(document.id) : [];
|
||||
|
||||
const onCloseInsights = () => {
|
||||
if (document) {
|
||||
history.push(documentUrl(document));
|
||||
}
|
||||
};
|
||||
|
||||
useKeyDown("Escape", onCloseInsights);
|
||||
|
||||
return (
|
||||
<Sidebar title={t("Insights")} onClose={onCloseInsights}>
|
||||
{document ? (
|
||||
<>
|
||||
<Content column>
|
||||
<Heading>{t("Stats")}</Heading>
|
||||
<Text type="secondary" size="small">
|
||||
<List>
|
||||
<li>
|
||||
{t(`{{ count }} minute read`, {
|
||||
count: stats.total.readingTime,
|
||||
})}
|
||||
</li>
|
||||
<li>{t(`{{ count }} words`, { count: stats.total.words })}</li>
|
||||
<li>
|
||||
{t(`{{ count }} characters`, {
|
||||
count: stats.total.characters,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
{t(`{{ number }} emoji`, { number: stats.total.emoji })}
|
||||
</li>
|
||||
{stats.selected.characters === 0 ? (
|
||||
<li>{t("No text selected")}</li>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
{t(`{{ count }} words selected`, {
|
||||
count: stats.selected.words,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
{t(`{{ count }} characters selected`, {
|
||||
count: stats.selected.characters,
|
||||
})}
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Text>
|
||||
</Content>
|
||||
<Content column>
|
||||
<Heading>{t("Views")}</Heading>
|
||||
<Text type="secondary" size="small">
|
||||
{documentViews.length <= 1
|
||||
? t("No one else has viewed yet")
|
||||
: t(`Viewed {{ count }} times by {{ teamMembers }} people`, {
|
||||
count: documentViews.reduce(
|
||||
(memo, view) => memo + view.count,
|
||||
0
|
||||
),
|
||||
teamMembers: documentViews.length,
|
||||
})}
|
||||
.
|
||||
</Text>
|
||||
<ListSpacing>
|
||||
<DocumentViews document={document} isOpen />
|
||||
</ListSpacing>
|
||||
</Content>
|
||||
<Content column>
|
||||
<Heading>{t("Collaborators")}</Heading>
|
||||
<Text type="secondary" size="small">
|
||||
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
|
||||
<br />
|
||||
{t(`Last updated`)}{" "}
|
||||
<Time dateTime={document.updatedAt} addSuffix />.
|
||||
</Text>
|
||||
<ListSpacing>
|
||||
<PaginatedList
|
||||
aria-label={t("Collaborators")}
|
||||
items={document.collaborators}
|
||||
renderItem={(model: User) => (
|
||||
<ListItem
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
image={<Avatar model={model} size={32} />}
|
||||
subtitle={
|
||||
model.id === document.createdBy.id
|
||||
? t("Creator")
|
||||
: model.id === document.updatedBy.id
|
||||
? t("Last edited")
|
||||
: t("Previously edited")
|
||||
}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ListSpacing>
|
||||
</Content>
|
||||
</>
|
||||
) : null}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function useTextStats(text: string, selectedText: string) {
|
||||
const numTotalWords = countWords(text);
|
||||
const regex = emojiRegex();
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
return {
|
||||
total: {
|
||||
words: numTotalWords,
|
||||
characters: text.length,
|
||||
emoji: matches.length ?? 0,
|
||||
readingTime: Math.floor(numTotalWords / 200),
|
||||
},
|
||||
selected: {
|
||||
words: countWords(selectedText),
|
||||
characters: selectedText.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
|
||||
// Hyphenated words are counted as two words
|
||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||
}
|
||||
|
||||
const ListSpacing = styled("div")`
|
||||
margin-top: -0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
`;
|
||||
|
||||
const List = styled("ul")`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li:before {
|
||||
content: "·";
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
width: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
padding: 0 16px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const Heading = styled("h3")`
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
export default observer(Insights);
|
||||
169
app/scenes/Document/components/RightSidebar.tsx
Normal file
169
app/scenes/Document/components/RightSidebar.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onClose: React.MouseEventHandler;
|
||||
border?: boolean;
|
||||
};
|
||||
|
||||
function RightSidebar({ title, onClose, children, border, className }: Props) {
|
||||
const theme = useTheme();
|
||||
const [width, setWidth] = usePersistedState(
|
||||
"rightSidebarWidth",
|
||||
theme.sidebarWidth
|
||||
);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
const width = Math.max(
|
||||
Math.min(window.innerWidth - event.pageX, maxWidth),
|
||||
minWidth
|
||||
);
|
||||
setWidth(width);
|
||||
},
|
||||
[minWidth, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
setWidth(theme.sidebarWidth);
|
||||
}, [setWidth, theme.sidebarWidth]);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = React.useCallback(() => {
|
||||
setResizing(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
}),
|
||||
[width]
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
initial={{
|
||||
width: 0,
|
||||
}}
|
||||
animate={{
|
||||
transition: isResizing
|
||||
? { duration: 0 }
|
||||
: {
|
||||
type: "spring",
|
||||
bounce: 0.2,
|
||||
duration: 0.6,
|
||||
},
|
||||
width,
|
||||
}}
|
||||
exit={{
|
||||
width: 0,
|
||||
}}
|
||||
$border={border}
|
||||
className={className}
|
||||
>
|
||||
<Position style={style} column>
|
||||
<Header>
|
||||
<Title>{title}</Title>
|
||||
<Button
|
||||
icon={<ForwardIcon />}
|
||||
onClick={onClose}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Header>
|
||||
<Scrollable topShadow>{children}</Scrollable>
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleReset}
|
||||
dir="right"
|
||||
/>
|
||||
</Position>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const ForwardIcon = styled(BackIcon)`
|
||||
transform: rotate(180deg);
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Position = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
`;
|
||||
|
||||
const Sidebar = styled(m.div)<{ $border?: boolean }>`
|
||||
display: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: ${(props) => props.theme.background};
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
transition: border-left 100ms ease-in-out;
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled(Flex)`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 16px 12px 16px 16px;
|
||||
color: ${(props) => props.theme.text};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(RightSidebar);
|
||||
@@ -72,6 +72,10 @@ export function documentMoveUrl(doc: Document): string {
|
||||
return `${doc.url}/move`;
|
||||
}
|
||||
|
||||
export function documentInsightsUrl(doc: Document): string {
|
||||
return `${doc.url}/insights`;
|
||||
}
|
||||
|
||||
export function documentHistoryUrl(doc: Document, revisionId?: string): string {
|
||||
let base = `${doc.url}/history`;
|
||||
if (revisionId) {
|
||||
|
||||
@@ -13,6 +13,7 @@ export default class Placeholder extends Mark {
|
||||
return {
|
||||
parseDOM: [{ tag: "span.template-placeholder" }],
|
||||
toDOM: () => ["span", { class: "template-placeholder" }],
|
||||
toPlainText: () => "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"Delete {{ documentName }}": "Delete {{ documentName }}",
|
||||
"Permanently delete": "Permanently delete",
|
||||
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
|
||||
"History": "History",
|
||||
"Insights": "Insights",
|
||||
"Home": "Home",
|
||||
"Drafts": "Drafts",
|
||||
"Templates": "Templates",
|
||||
@@ -102,8 +104,6 @@
|
||||
"Default collection": "Default collection",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Unpin": "Unpin",
|
||||
"History": "History",
|
||||
"No history yet": "No history yet",
|
||||
"New": "New",
|
||||
"Only visible to you": "Only visible to you",
|
||||
"Draft": "Draft",
|
||||
@@ -431,6 +431,30 @@
|
||||
"Restore version": "Restore version",
|
||||
"Publish": "Publish",
|
||||
"Publishing": "Publishing",
|
||||
"No history yet": "No history yet",
|
||||
"Stats": "Stats",
|
||||
"{{ count }} minute read": "{{ count }} minute read",
|
||||
"{{ count }} minute read_plural": "{{ count }} minute read",
|
||||
"{{ count }} words": "{{ count }} word",
|
||||
"{{ count }} words_plural": "{{ count }} words",
|
||||
"{{ count }} characters": "{{ count }} character",
|
||||
"{{ count }} characters_plural": "{{ count }} characters",
|
||||
"{{ number }} emoji": "{{ number }} emoji",
|
||||
"No text selected": "No text selected",
|
||||
"{{ count }} words selected": "{{ count }} word selected",
|
||||
"{{ count }} words selected_plural": "{{ count }} words selected",
|
||||
"{{ count }} characters selected": "{{ count }} character selected",
|
||||
"{{ count }} characters selected_plural": "{{ count }} characters selected",
|
||||
"Views": "Views",
|
||||
"No one else has viewed yet": "No one else has viewed yet",
|
||||
"Viewed {{ count }} times by {{ teamMembers }} people": "Viewed {{ count }} time by {{ teamMembers }} people",
|
||||
"Viewed {{ count }} times by {{ teamMembers }} people_plural": "Viewed {{ count }} times by {{ teamMembers }} people",
|
||||
"Collaborators": "Collaborators",
|
||||
"Created": "Created",
|
||||
"Last updated": "Last updated",
|
||||
"Creator": "Creator",
|
||||
"Last edited": "Last edited",
|
||||
"Previously edited": "Previously edited",
|
||||
"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",
|
||||
"Sorry, the last change could not be persisted – please reload the page": "Sorry, the last change could not be persisted – please reload the page",
|
||||
"This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
|
||||
@@ -606,7 +630,6 @@
|
||||
"Last accessed": "Last accessed",
|
||||
"Date shared": "Date shared",
|
||||
"Shared nested": "Shared nested",
|
||||
"Views": "Views",
|
||||
"Add to Slack": "Add to Slack",
|
||||
"Settings saved": "Settings saved",
|
||||
"document published": "document published",
|
||||
|
||||
Reference in New Issue
Block a user