From 3880a956a312d0be7b3fedb0aace8250cf146b89 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 13 Nov 2022 10:19:09 -0800 Subject: [PATCH] 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 --- app/actions/definitions/documents.tsx | 52 ++++- app/components/ActionButton.tsx | 10 +- app/components/AuthenticatedLayout.tsx | 41 +++- app/components/Button.tsx | 17 +- app/components/DocumentContext.ts | 19 ++ app/components/DocumentHistory.tsx | 158 -------------- app/components/PaginatedEventList.tsx | 4 +- .../Sidebar/components/ResizeBorder.ts | 5 +- app/editor/index.tsx | 33 +++ app/hooks/useTextSelection.ts | 22 ++ app/menus/DocumentMenu.tsx | 33 +-- app/models/Document.ts | 7 + app/routes/authenticated.tsx | 1 + app/scenes/Collection/Actions.tsx | 1 - app/scenes/Document/components/Editor.tsx | 7 +- app/scenes/Document/components/History.tsx | 82 ++++++++ app/scenes/Document/components/Insights.tsx | 192 ++++++++++++++++++ .../Document/components/RightSidebar.tsx | 169 +++++++++++++++ app/utils/routeHelpers.ts | 4 + shared/editor/marks/Placeholder.ts | 1 + shared/i18n/locales/en_US/translation.json | 29 ++- 21 files changed, 675 insertions(+), 212 deletions(-) create mode 100644 app/components/DocumentContext.ts delete mode 100644 app/components/DocumentHistory.tsx create mode 100644 app/hooks/useTextSelection.ts create mode 100644 app/scenes/Document/components/History.tsx create mode 100644 app/scenes/Document/components/Insights.tsx create mode 100644 app/scenes/Document/components/RightSidebar.tsx diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 56c858505..5b15dbe70 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -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: , + 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: , + 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, ]; diff --git a/app/components/ActionButton.tsx b/app/components/ActionButton.tsx index 863eef5c8..f3fd358de 100644 --- a/app/components/ActionButton.tsx +++ b/app/components/ActionButton.tsx @@ -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 & { +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 & { */ const ActionButton = React.forwardRef( ( - { - action, - context, - tooltip, - hideOnActionDisabled, - ...rest - }: Props & React.HTMLAttributes, + { action, context, tooltip, hideOnActionDisabled, ...rest }: Props, ref: React.Ref ) => { const disabled = rest.disabled; diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index 1d2a6bb10..c4bfa368d 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -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({ + 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 } > { path={`/doc/${slug}/history/:revisionId?`} component={DocumentHistory} /> + ); return ( - - - - - {children} - - + + + + + + {children} + + + ); }; diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 1289b6e5c..e404217da 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -173,10 +173,19 @@ const Button = ( props: Props & React.ComponentPropsWithoutRef, ref: React.Ref ) => { - 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 ( ( {...rest} > - {hasIcon && icon} + {hasIcon && ic} {hasText && } {disclosure && } diff --git a/app/components/DocumentContext.ts b/app/components/DocumentContext.ts new file mode 100644 index 000000000..dcbf2fed8 --- /dev/null +++ b/app/components/DocumentContext.ts @@ -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({ + editor: null, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setEditor() {}, +}); + +export const useDocumentContext = () => React.useContext(DocumentContext); + +export default DocumentContext; diff --git a/app/components/DocumentHistory.tsx b/app/components/DocumentHistory.tsx deleted file mode 100644 index e3a4d1604..000000000 --- a/app/components/DocumentHistory.tsx +++ /dev/null @@ -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 ( - - {document ? ( - -
- {t("History")} -
- - {t("No history yet")}} - /> - -
- ) : null} -
- ); -} - -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); diff --git a/app/components/PaginatedEventList.tsx b/app/components/PaginatedEventList.tsx index b86c41042..dfc46adbf 100644 --- a/app/components/PaginatedEventList.tsx +++ b/app/components/PaginatedEventList.tsx @@ -46,11 +46,11 @@ const PaginatedEventList = React.memo(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; `; diff --git a/app/components/Sidebar/components/ResizeBorder.ts b/app/components/Sidebar/components/ResizeBorder.ts index b73a475f4..ec3bec89f 100644 --- a/app/components/Sidebar/components/ResizeBorder.ts +++ b/app/components/Sidebar/components/ResizeBorder.ts @@ -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; diff --git a/app/editor/index.tsx b/app/editor/index.tsx index e07f2615e..bf787c3bc 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -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, diff --git a/app/hooks/useTextSelection.ts b/app/hooks/useTextSelection.ts new file mode 100644 index 000000000..85ec0ebe3 --- /dev/null +++ b/app/hooks/useTextSelection.ts @@ -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(""); + + const handleMouse = React.useCallback(() => { + const selection = window.getSelection(); + const text = selection?.toString(); + setSelection(text ?? ""); + }, []); + + useEventListener("mousemove", handleMouse); + useEventListener("mouseup", handleMouse); + + return selection; +} diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index bcff282ae..6ec7c5ac0 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -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((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: , - }, + actionToMenuItem(openDocumentHistory, context), + actionToMenuItem(openDocumentInsights, context), { type: "button", title: t("Print"), @@ -330,6 +312,11 @@ function DocumentMenu({ visible: !!showDisplayOptions, icon: , }, + { + type: "separator", + }, + actionToMenuItem(deleteDocument, context), + actionToMenuItem(permanentlyDeleteDocument, context), ]} /> {(showDisplayOptions || showToggleEmbeds) && ( diff --git a/app/models/Document.ts b/app/models/Document.ts index ad132fd6b..8c37451be 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -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. diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx index dbacc77da..c270c9ce2 100644 --- a/app/routes/authenticated.tsx +++ b/app/routes/authenticated.tsx @@ -106,6 +106,7 @@ function AuthenticatedRoutes() { path={`/doc/${slug}/history/:revisionId?`} component={Document} /> + diff --git a/app/scenes/Collection/Actions.tsx b/app/scenes/Collection/Actions.tsx index 93d6cf078..e14293b18 100644 --- a/app/scenes/Collection/Actions.tsx +++ b/app/scenes/Collection/Actions.tsx @@ -54,7 +54,6 @@ function Actions({ collection }: Props) { {...props} borderOnHover neutral - small /> )} /> diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 372d5e7b7..f0cf9c5d7 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -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) { [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) { /> )} (); + 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 ( + + {document ? ( + {t("No history yet")}} + /> + ) : null} + + ); +} + +const EmptyHistory = styled(Empty)` + padding: 0 12px; +`; + +export default observer(History); diff --git a/app/scenes/Document/components/Insights.tsx b/app/scenes/Document/components/Insights.tsx new file mode 100644 index 000000000..77ae8a552 --- /dev/null +++ b/app/scenes/Document/components/Insights.tsx @@ -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 ( + + {document ? ( + <> + + {t("Stats")} + + +
  • + {t(`{{ count }} minute read`, { + count: stats.total.readingTime, + })} +
  • +
  • {t(`{{ count }} words`, { count: stats.total.words })}
  • +
  • + {t(`{{ count }} characters`, { + count: stats.total.characters, + })} +
  • +
  • + {t(`{{ number }} emoji`, { number: stats.total.emoji })} +
  • + {stats.selected.characters === 0 ? ( +
  • {t("No text selected")}
  • + ) : ( + <> +
  • + {t(`{{ count }} words selected`, { + count: stats.selected.words, + })} +
  • +
  • + {t(`{{ count }} characters selected`, { + count: stats.selected.characters, + })} +
  • + + )} +
    +
    +
    + + {t("Views")} + + {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, + })} + . + + + + + + + {t("Collaborators")} + + {t(`Created`)} + + ( + } + subtitle={ + model.id === document.createdBy.id + ? t("Creator") + : model.id === document.updatedBy.id + ? t("Last edited") + : t("Previously edited") + } + border={false} + small + /> + )} + /> + + + + ) : null} +
    + ); +} + +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); diff --git a/app/scenes/Document/components/RightSidebar.tsx b/app/scenes/Document/components/RightSidebar.tsx new file mode 100644 index 000000000..cafd35cfe --- /dev/null +++ b/app/scenes/Document/components/RightSidebar.tsx @@ -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 & { + 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 ( + + +
    + {title} +
    + {children} + +
    +
    + ); +} + +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); diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index fa7722054..a190dc2ee 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -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) { diff --git a/shared/editor/marks/Placeholder.ts b/shared/editor/marks/Placeholder.ts index 84591b229..bacc1e56f 100644 --- a/shared/editor/marks/Placeholder.ts +++ b/shared/editor/marks/Placeholder.ts @@ -13,6 +13,7 @@ export default class Placeholder extends Mark { return { parseDOM: [{ tag: "span.template-placeholder" }], toDOM: () => ["span", { class: "template-placeholder" }], + toPlainText: () => "", }; } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 51aa05cf0..ab499e8f9 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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> unless restored.": "This template will be permanently deleted in <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",