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")}
- }
- onClick={onCloseHistory}
- borderOnHover
- neutral
- />
-
-
- {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