mirror of
https://github.com/outline/outline.git
synced 2025-12-21 10:39:41 -06:00
perf: Reduce upfront component loading (#10285)
* Reducing loading on first open, closes #10263 * perf: Prosemirror deps loaded with Document model * More initial component reduction * more * refactor
This commit is contained in:
@@ -22,7 +22,6 @@ import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
@@ -37,10 +36,14 @@ import {
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
);
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Collection/SharePopover")
|
||||
);
|
||||
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
|
||||
@@ -50,7 +50,6 @@ import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInT
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import {
|
||||
@@ -82,7 +81,14 @@ import {
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
|
||||
import Insights from "~/scenes/Document/components/Insights";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Document/SharePopover")
|
||||
);
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -593,12 +599,15 @@ export const copyDocumentAsMarkdown = createActionV2({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
perform: async ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toMarkdown());
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toMarkdown(document));
|
||||
toast.success(t("Markdown copied to clipboard"));
|
||||
}
|
||||
},
|
||||
@@ -612,12 +621,15 @@ export const copyDocumentAsPlainText = createActionV2({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
perform: async ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toPlainText());
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toPlainText(document));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
@@ -37,8 +37,9 @@ const DocumentComments = lazyWithRetry(
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -130,9 +131,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<React.Suspense fallback={null}>
|
||||
<CommandBar />
|
||||
</React.Suspense>
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
@@ -44,4 +45,4 @@ const Link = styled.a`
|
||||
}
|
||||
`;
|
||||
|
||||
export default Branding;
|
||||
export default React.memo(Branding);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Guide from "~/components/Guide";
|
||||
import Modal from "~/components/Modal";
|
||||
import { Suspense } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Guide = lazyWithRetry(() => import("~/components/Guide"));
|
||||
const Modal = lazyWithRetry(() => import("~/components/Modal"));
|
||||
|
||||
function Dialogs() {
|
||||
const { dialogs } = useStores();
|
||||
@@ -9,7 +12,7 @@ function Dialogs() {
|
||||
const modals = [...modalStack];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
@@ -33,7 +36,7 @@ function Dialogs() {
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import { useRef, useCallback, useMemo } from "react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import { useRef, useCallback, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
@@ -19,10 +19,12 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const ReadingTime = lazyWithRetry(() => import("./ReadingTime"));
|
||||
|
||||
type Props = {
|
||||
/** The pin record */
|
||||
@@ -76,6 +78,13 @@ function DocumentCard(props: Props) {
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
const updatedAt = (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -150,12 +159,11 @@ function DocumentCard(props: Props) {
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
updatedAt
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
<Suspense fallback={updatedAt}>
|
||||
<ReadingTime document={document} />
|
||||
</Suspense>
|
||||
)}
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
@@ -177,21 +185,6 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -39,6 +39,7 @@ function DocumentTasks({ document }: Props) {
|
||||
const done = completed === total;
|
||||
const previousDone = usePrevious(done);
|
||||
const message = getMessage(t, total, completed);
|
||||
|
||||
return (
|
||||
<>
|
||||
{completed === total ? (
|
||||
|
||||
@@ -6,13 +6,17 @@ import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, hover, truncateMultiline } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import { UnreadBadge } from "../UnreadBadge";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const CommentEditor = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/CommentEditor")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
notification: Notification;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Popover,
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Notifications from "./Notifications";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Notifications = lazyWithRetry(() => import("./Notifications"));
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -16,18 +18,18 @@ type Props = {
|
||||
const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { notifications } = useStores();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const scrollableRef = React.useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
void notifications.fetchPage({ archived: false });
|
||||
}, [notifications]);
|
||||
|
||||
const handleRequestClose = React.useCallback(() => {
|
||||
const handleRequestClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleAutoFocus = React.useCallback((event: Event) => {
|
||||
const handleAutoFocus = useCallback((event: Event) => {
|
||||
// Prevent focus from moving to the popover content
|
||||
event.preventDefault();
|
||||
|
||||
@@ -48,10 +50,12 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
onOpenAutoFocus={handleAutoFocus}
|
||||
shrink
|
||||
>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
26
app/components/ReadingTime.tsx
Normal file
26
app/components/ReadingTime.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { EyeIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import type Document from "~/models/Document";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = useMemo(
|
||||
() => ProsemirrorHelper.toMarkdown(document),
|
||||
[document]
|
||||
);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingTime;
|
||||
@@ -5,7 +5,6 @@ import GlobalStyles from "@shared/styles/globals";
|
||||
import { TeamPreference, UserPreference } from "@shared/types";
|
||||
import useBuildTheme from "~/hooks/useBuildTheme";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { TooltipStyles } from "./Tooltip";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -30,7 +29,6 @@ const Theme: React.FC = ({ children }: Props) => {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<>
|
||||
<TooltipStyles />
|
||||
<GlobalStyles
|
||||
useCursorPointer={auth.user?.getPreference(
|
||||
UserPreference.UseCursorPointer
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { createGlobalStyle, keyframes } from "styled-components";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useTooltipContext } from "./TooltipContext";
|
||||
@@ -285,8 +285,4 @@ const StyledContent = styled(TooltipPrimitive.Content)`
|
||||
}
|
||||
`;
|
||||
|
||||
export const TooltipStyles = createGlobalStyle`
|
||||
/* Legacy styles for backward compatibility - can be removed after migration */
|
||||
`;
|
||||
|
||||
export default Tooltip;
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { ComponentProps, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
import { Integrations } from "~/scenes/Settings/Integrations";
|
||||
import { createLazyComponent as lazy } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
@@ -37,6 +36,7 @@ const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
const Features = lazy(() => import("~/scenes/Settings/Features"));
|
||||
const Groups = lazy(() => import("~/scenes/Settings/Groups"));
|
||||
const Import = lazy(() => import("~/scenes/Settings/Import"));
|
||||
const Integrations = lazy(() => import("~/scenes/Settings/Integrations"));
|
||||
const Members = lazy(() => import("~/scenes/Settings/Members"));
|
||||
const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
|
||||
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
|
||||
@@ -211,7 +211,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: `${t("Install")}…`,
|
||||
path: settingsPath("integrations"),
|
||||
component: Integrations,
|
||||
component: Integrations.Component,
|
||||
preload: Integrations.preload,
|
||||
enabled: can.update,
|
||||
group: t("Integrations"),
|
||||
icon: PlusIcon,
|
||||
|
||||
@@ -55,11 +55,11 @@ if (element) {
|
||||
<Analytics>
|
||||
<Router history={history}>
|
||||
<Theme>
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<ActionContextProvider>
|
||||
<ActionContextProvider>
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
@@ -69,11 +69,11 @@ if (element) {
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</ActionContextProvider>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</ActionContextProvider>
|
||||
</Theme>
|
||||
</Router>
|
||||
</Analytics>
|
||||
|
||||
@@ -3,9 +3,6 @@ import i18n, { t } from "i18next";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import floor from "lodash/floor";
|
||||
import { action, autorun, computed, observable, set } from "mobx";
|
||||
import { Node, Schema } from "prosemirror-model";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||
import type {
|
||||
JSONObject,
|
||||
NavigationNode,
|
||||
@@ -17,7 +14,6 @@ import {
|
||||
NavigationNodeType,
|
||||
NotificationEventType,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
@@ -687,47 +683,6 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the markdown representation of the document derived from the ProseMirror data.
|
||||
*
|
||||
* @returns The markdown representation of the document as a string.
|
||||
*/
|
||||
toMarkdown = () => {
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const serializer = extensionManager.serializer();
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
|
||||
const doc = Node.fromJSON(
|
||||
schema,
|
||||
ProsemirrorHelper.attachmentsToAbsoluteUrls(this.data)
|
||||
);
|
||||
|
||||
const markdown = serializer.serialize(doc, {
|
||||
softBreak: true,
|
||||
});
|
||||
return markdown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the plain text representation of the document derived from the ProseMirror data.
|
||||
*
|
||||
* @returns The plain text representation of the document as a string.
|
||||
*/
|
||||
toPlainText = () => {
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const text = ProsemirrorHelper.toPlainText(
|
||||
Node.fromJSON(schema, this.data)
|
||||
);
|
||||
return text;
|
||||
};
|
||||
|
||||
download = (contentType: ExportContentType) =>
|
||||
client.post(
|
||||
`/documents.export`,
|
||||
|
||||
49
app/models/helpers/ProsemirrorHelper.ts
Normal file
49
app/models/helpers/ProsemirrorHelper.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import type Document from "../Document";
|
||||
import { Schema } from "prosemirror-model";
|
||||
import { Node } from "prosemirror-model";
|
||||
|
||||
export class ProsemirrorHelper {
|
||||
/**
|
||||
* Returns the markdown representation of the document derived from the ProseMirror data.
|
||||
*
|
||||
* @returns The markdown representation of the document as a string.
|
||||
*/
|
||||
static toMarkdown = (document: Document) => {
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const serializer = extensionManager.serializer();
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
|
||||
const doc = Node.fromJSON(
|
||||
schema,
|
||||
SharedProsemirrorHelper.attachmentsToAbsoluteUrls(document.data)
|
||||
);
|
||||
|
||||
const markdown = serializer.serialize(doc, {
|
||||
softBreak: true,
|
||||
});
|
||||
return markdown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the plain text representation of the document derived from the ProseMirror data.
|
||||
*
|
||||
* @returns The plain text representation of the document as a string.
|
||||
*/
|
||||
static toPlainText = (document: Document) => {
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const text = SharedProsemirrorHelper.toPlainText(
|
||||
Node.fromJSON(schema, document.data)
|
||||
);
|
||||
return text;
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GlobeIcon, PadlockIcon } from "outline-icons";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Suspense, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
@@ -13,6 +12,11 @@ import {
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Collection/SharePopover")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
/** Collection being shared */
|
||||
@@ -56,11 +60,13 @@ function ShareButton({ collection }: Props) {
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<SharePopover
|
||||
collection={collection}
|
||||
onRequestClose={closePopover}
|
||||
visible={open}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<SharePopover
|
||||
collection={collection}
|
||||
onRequestClose={closePopover}
|
||||
visible={open}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { lazy, useState, useCallback, useEffect, Suspense } from "react";
|
||||
import { useState, useCallback, useEffect, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useParams,
|
||||
@@ -47,10 +47,12 @@ import Empty from "./components/Empty";
|
||||
import MembershipPreview from "./components/MembershipPreview";
|
||||
import Notices from "./components/Notices";
|
||||
import Overview from "./components/Overview";
|
||||
import ShareButton from "./components/ShareButton";
|
||||
import first from "lodash/first";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const IconPicker = lazy(() => import("~/components/IconPicker"));
|
||||
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
|
||||
|
||||
const ShareButton = lazyWithRetry(() => import("./components/ShareButton"));
|
||||
|
||||
enum CollectionPath {
|
||||
Overview = "overview",
|
||||
|
||||
@@ -22,9 +22,11 @@ import type { Editor as SharedEditor } from "~/editor";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
import { Bubble } from "./CommentThreadItem";
|
||||
import { HighlightedText } from "./HighlightText";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const CommentEditor = lazyWithRetry(() => import("./CommentEditor"));
|
||||
|
||||
type Props = {
|
||||
/** Callback when the form is submitted. */
|
||||
|
||||
@@ -37,7 +37,9 @@ function Contents() {
|
||||
}
|
||||
}
|
||||
|
||||
setActiveSlug(activeId);
|
||||
if (activeSlug !== activeId) {
|
||||
setActiveSlug(activeId);
|
||||
}
|
||||
}, [scrollPosition, headings]);
|
||||
|
||||
// calculate the minimum heading level and adjust all the headings to make
|
||||
|
||||
@@ -27,16 +27,13 @@ import {
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import ConnectionStatus from "~/scenes/Document/components/ConnectionStatus";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import Branding from "~/components/Branding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
@@ -57,13 +54,11 @@ import Container from "./Container";
|
||||
import Contents from "./Contents";
|
||||
import Editor from "./Editor";
|
||||
import Header from "./Header";
|
||||
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||
import { MeasuredContainer } from "./MeasuredContainer";
|
||||
import Notices from "./Notices";
|
||||
import PublicReferences from "./PublicReferences";
|
||||
import References from "./References";
|
||||
import RevisionViewer from "./RevisionViewer";
|
||||
import { SizeWarning } from "./SizeWarning";
|
||||
|
||||
const AUTOSAVE_DELAY = 3000;
|
||||
|
||||
@@ -433,6 +428,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
document,
|
||||
revision,
|
||||
readOnly,
|
||||
@@ -633,19 +629,8 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
</React.Suspense>
|
||||
</Main>
|
||||
{isShare &&
|
||||
!parseDomain(window.location.origin).custom &&
|
||||
!auth.user && (
|
||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||
)}
|
||||
{children}
|
||||
</Container>
|
||||
{!isShare && (
|
||||
<Footer>
|
||||
<KeyboardShortcutsButton />
|
||||
<ConnectionStatus />
|
||||
<SizeWarning document={document} />
|
||||
</Footer>
|
||||
)}
|
||||
</MeasuredContainer>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
@@ -754,16 +739,6 @@ const RevisionContainer = styled.div<RevisionContainerProps>`
|
||||
`}
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
right: 20px;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
`;
|
||||
|
||||
const Background = styled(Container)`
|
||||
position: relative;
|
||||
background: ${s("background")};
|
||||
|
||||
@@ -24,8 +24,9 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
|
||||
|
||||
type Props = {
|
||||
/** ID of the associated document */
|
||||
|
||||
27
app/scenes/Document/components/Footer.tsx
Normal file
27
app/scenes/Document/components/Footer.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import styled from "styled-components";
|
||||
import type Document from "~/models/Document";
|
||||
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||
import ConnectionStatus from "./ConnectionStatus";
|
||||
import { SizeWarning } from "./SizeWarning";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
};
|
||||
|
||||
export const Footer = ({ document }: Props) => (
|
||||
<FooterWrapper>
|
||||
<KeyboardShortcutsButton />
|
||||
<ConnectionStatus />
|
||||
<SizeWarning document={document} />
|
||||
</FooterWrapper>
|
||||
);
|
||||
|
||||
const FooterWrapper = styled.div`
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
right: 20px;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
`;
|
||||
@@ -14,6 +14,7 @@ import useTextSelection from "~/hooks/useTextSelection";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import type Document from "~/models/Document";
|
||||
import { useFormatNumber } from "~/hooks/useFormatNumber";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -22,7 +23,7 @@ type Props = {
|
||||
function Insights({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const selectedText = useTextSelection();
|
||||
const text = document.toPlainText();
|
||||
const text = ProsemirrorHelper.toPlainText(document);
|
||||
const stats = useTextStats(text ?? "", selectedText);
|
||||
const formatNumber = useFormatNumber();
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GlobeIcon } from "outline-icons";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Suspense, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
@@ -12,6 +11,11 @@ import {
|
||||
} from "~/components/primitives/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Document")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
/** Document being shared */
|
||||
@@ -50,11 +54,13 @@ function ShareButton({ document }: Props) {
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<SharePopover
|
||||
document={document}
|
||||
onRequestClose={closePopover}
|
||||
visible={open}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<SharePopover
|
||||
document={document}
|
||||
onRequestClose={closePopover}
|
||||
visible={open}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -14,7 +15,7 @@ type Props = {
|
||||
|
||||
export const SizeWarning = ({ document }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const length = document.toPlainText().length;
|
||||
const length = ProsemirrorHelper.toPlainText(document).length;
|
||||
|
||||
if (length < DocumentValidation.maxRecommendedLength) {
|
||||
return null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
import Document from "./components/Document";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
type Params = {
|
||||
documentSlug: string;
|
||||
@@ -65,7 +66,11 @@ export default function DocumentScene(props: Props) {
|
||||
history={props.history}
|
||||
location={props.location}
|
||||
>
|
||||
{(rest) => <Document {...rest} />}
|
||||
{(rest) => (
|
||||
<Document {...rest}>
|
||||
<Footer document={rest.document} />
|
||||
</Document>
|
||||
)}
|
||||
</DataLoader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,8 +40,12 @@ import { BackButton } from "./components/BackButton";
|
||||
import { Background } from "./components/Background";
|
||||
import { Centered } from "./components/Centered";
|
||||
import { Notices } from "./components/Notices";
|
||||
import WorkspaceSetup from "./components/WorkspaceSetup";
|
||||
import { getRedirectUrl, navigateToSubdomain } from "./urls";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const WorkspaceSetup = lazyWithRetry(
|
||||
() => import("./components/WorkspaceSetup")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
children?: (config?: Config) => React.ReactNode;
|
||||
@@ -205,7 +209,11 @@ function Login({ children, onBack }: Props) {
|
||||
const preferOTP = isPWA;
|
||||
|
||||
if (firstRun) {
|
||||
return <WorkspaceSetup onBack={onBack} />;
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<WorkspaceSetup onBack={onBack} />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (emailLinkSentTo) {
|
||||
|
||||
@@ -12,8 +12,9 @@ import useStores from "~/hooks/useStores";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import IntegrationCard from "./components/IntegrationCard";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export function Integrations() {
|
||||
function Integrations() {
|
||||
const { t } = useTranslation();
|
||||
const { integrations } = useStores();
|
||||
const items = useSettingsConfig();
|
||||
@@ -70,3 +71,5 @@ const Cards = styled(Flex)`
|
||||
margin-top: 20px;
|
||||
width: "100%";
|
||||
`;
|
||||
|
||||
export default observer(Integrations);
|
||||
|
||||
@@ -5,6 +5,9 @@ import DocumentComponent from "~/scenes/Document/components/Document";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { useTeamContext } from "~/components/TeamContext";
|
||||
import { useMemo } from "react";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import Branding from "~/components/Branding";
|
||||
|
||||
type Props = {
|
||||
document: DocumentModel;
|
||||
@@ -14,8 +17,14 @@ type Props = {
|
||||
|
||||
function SharedDocument({ document, shareId, sharedTree }: Props) {
|
||||
const team = useTeamContext() as PublicTeam | undefined;
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { hasHeadings, setDocument } = useDocumentContext();
|
||||
const abilities = useMemo(() => ({}), []);
|
||||
const isCustomDomain = useMemo(
|
||||
() => parseDomain(window.location.origin).custom,
|
||||
[]
|
||||
);
|
||||
const showBranding = !isCustomDomain && !user;
|
||||
|
||||
const tocPosition = hasHeadings
|
||||
? (team?.tocPosition ?? TOCPosition.Left)
|
||||
@@ -23,14 +32,19 @@ function SharedDocument({ document, shareId, sharedTree }: Props) {
|
||||
setDocument(document);
|
||||
|
||||
return (
|
||||
<DocumentComponent
|
||||
abilities={abilities}
|
||||
document={document}
|
||||
sharedTree={sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
<>
|
||||
<DocumentComponent
|
||||
abilities={abilities}
|
||||
document={document}
|
||||
sharedTree={sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
{showBranding ? (
|
||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Suspense, useCallback, useEffect } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
@@ -28,10 +28,12 @@ import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { changeLanguage, detectLanguage } from "~/utils/language";
|
||||
import Loading from "../Document/components/Loading";
|
||||
import ErrorOffline from "../Errors/ErrorOffline";
|
||||
import Login from "../Login";
|
||||
import { Collection as CollectionScene } from "./Collection";
|
||||
import { Document as DocumentScene } from "./Document";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Login = lazyWithRetry(() => import("../Login"));
|
||||
|
||||
// Parse the canonical origin from the SSR HTML, only needs to be done once.
|
||||
const canonicalUrl = document
|
||||
@@ -194,21 +196,23 @@ function SharedScene() {
|
||||
if (error instanceof AuthorizationError) {
|
||||
setPostLoginPath(location.pathname);
|
||||
return (
|
||||
<Login>
|
||||
{(config) =>
|
||||
config?.name && isCloudHosted ? (
|
||||
<Content>
|
||||
{t(
|
||||
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
|
||||
{
|
||||
teamName: config.name,
|
||||
appName: env.APP_NAME,
|
||||
}
|
||||
)}
|
||||
</Content>
|
||||
) : null
|
||||
}
|
||||
</Login>
|
||||
<Suspense fallback={null}>
|
||||
<Login>
|
||||
{(config) =>
|
||||
config?.name && isCloudHosted ? (
|
||||
<Content>
|
||||
{t(
|
||||
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
|
||||
{
|
||||
teamName: config.name,
|
||||
appName: env.APP_NAME,
|
||||
}
|
||||
)}
|
||||
</Content>
|
||||
) : null
|
||||
}
|
||||
</Login>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return <Error404 />;
|
||||
|
||||
@@ -4,7 +4,6 @@ import flatten from "lodash/flatten";
|
||||
import isMatch from "lodash/isMatch";
|
||||
import uniq from "lodash/uniq";
|
||||
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import styled, { ServerStyleSheet, ThemeProvider } from "styled-components";
|
||||
import { prosemirrorToYDoc } from "y-prosemirror";
|
||||
|
||||
@@ -216,7 +216,6 @@
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Untitled": "Untitled",
|
||||
"Unpin": "Unpin",
|
||||
"{{ minutes }}m read": "{{ minutes }}m read",
|
||||
"Select a location to copy": "Select a location to copy",
|
||||
"Document copied": "Document copied",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
@@ -345,6 +344,7 @@
|
||||
"Reaction picker": "Reaction picker",
|
||||
"Could not load reactions": "Could not load reactions",
|
||||
"Reaction": "Reaction",
|
||||
"{{ minutes }}m read": "{{ minutes }}m read",
|
||||
"Revision deleted": "Revision deleted",
|
||||
"Current version": "Current version",
|
||||
"{{userName}} edited": "{{userName}} edited",
|
||||
|
||||
Reference in New Issue
Block a user