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:
Tom Moor
2022-11-13 10:19:09 -08:00
committed by GitHub
parent 762341a4ec
commit 3880a956a3
21 changed files with 675 additions and 212 deletions

View File

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

View File

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

View File

@@ -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,12 +108,18 @@ 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 (
<DocumentContext.Provider value={documentContext}>
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
@@ -105,6 +127,7 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
{children}
<CommandBar />
</Layout>
</DocumentContext.Provider>
);
};

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,6 @@ function Actions({ collection }: Props) {
{...props}
borderOnHover
neutral
small
/>
)}
/>

View File

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

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

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

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

View File

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

View File

@@ -13,6 +13,7 @@ export default class Placeholder extends Mark {
return {
parseDOM: [{ tag: "span.template-placeholder" }],
toDOM: () => ["span", { class: "template-placeholder" }],
toPlainText: () => "",
};
}

View File

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