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:
Tom Moor
2025-10-02 12:22:19 +02:00
committed by GitHub
parent 5fbaa32f18
commit 640ecca9ca
33 changed files with 298 additions and 197 deletions

View File

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

View File

@@ -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"));
}
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
"Couldnt copy the document, try again?": "Couldnt 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",