mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8cb10248e | |||
| 762a0c78c7 | |||
| 1b88189f9c | |||
| ace351035a | |||
| 3222a77cc3 | |||
| d8b0e731ef | |||
| 80c1e5a10b | |||
| 5b89882e5e | |||
| f85ad1a7e1 | |||
| 38a3e651a7 | |||
| e06ff9d7d2 | |||
| 89cfc76d8f | |||
| c22ba4fa0c | |||
| 8dabc7f3cf | |||
| bd7f0cdc12 | |||
| 9a849418b1 | |||
| 0ab3a962d9 | |||
| 2c7ec179fa | |||
| 526833ec6e | |||
| d8f39e3b67 | |||
| 0565616b02 | |||
| dd9b28e898 | |||
| f5ef2f2b30 | |||
| 1f251d4829 | |||
| 749bf49ebe | |||
| 371665b35c | |||
| d20779e7ea | |||
| 1d86a4aee4 | |||
| 19e5888216 | |||
| a4039ccc81 | |||
| f0a7ee6df8 | |||
| c428d551b8 | |||
| 904f1a9726 | |||
| 8dc4f8b422 | |||
| 1ceb476a04 | |||
| 5439afa5c8 | |||
| 8619b219e7 | |||
| 5a14944d0c | |||
| 93509564e0 | |||
| aacad48585 | |||
| 8dbbcb6dce | |||
| 597da9f1ee | |||
| eab57a7144 | |||
| 20f928332e | |||
| 12847d3d79 | |||
| b298456126 | |||
| 4e773d7cb0 | |||
| 0dfab5b245 | |||
| a40ab4867d | |||
| 3270e05abc | |||
| 36f5ce26e2 | |||
| 10303220a5 | |||
| 1c52a3e7da | |||
| 8a7501d0ad | |||
| 8048b7e530 | |||
| d54167dbdf | |||
| 6b98da54bd | |||
| 03e322f1ad | |||
| e876b2131e | |||
| 1e62c09dd0 | |||
| 0f23b63e35 | |||
| 658be50dcd | |||
| 9288ac87e0 | |||
| 6aa7f34b01 | |||
| 7999cf0b98 | |||
| 4ebb0e1790 | |||
| 0ce992e87a | |||
| 3c3d18637e | |||
| 6708f5dbc2 | |||
| 38920cd1fe | |||
| 053133f2b7 | |||
| 505e41661a | |||
| 3c4ed666ce | |||
| 25e222bb22 | |||
| 7c84c67077 | |||
| d3fd0fc537 | |||
| 7f88ab55fb | |||
| 9038db525e | |||
| 9c7a53102b | |||
| 7dc00b7d31 | |||
| 112731a52b | |||
| c68c6af8f4 | |||
| 957ce69d2e | |||
| ff6a20ef38 | |||
| 6a69990833 | |||
| 0f45778d79 | |||
| 3886c179c5 | |||
| ccde98ce82 | |||
| 86a106e8e9 | |||
| 3fe5f907db |
@@ -119,6 +119,11 @@ SSL_CERT=
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# When behind a reverse proxy, the header to use for the client IP.
|
||||
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
|
||||
bundle-size:
|
||||
needs: [setup, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -528,7 +528,7 @@ export const createTemplate = createInternalLinkAction({
|
||||
keywords: "new create template",
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
(policy) => policy.abilities.createTemplate
|
||||
),
|
||||
to: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
|
||||
@@ -201,48 +201,6 @@ export const createDraftDocument = createInternalLinkAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({
|
||||
currentTeamId,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
stores,
|
||||
}) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId, {
|
||||
templateId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds the index of a document among its siblings in the collection tree.
|
||||
*
|
||||
@@ -982,7 +940,7 @@ export const printDocument = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: () => {
|
||||
queueMicrotask(window.print);
|
||||
setTimeout(window.print, 0);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1050,7 +1008,7 @@ export const createTemplateFromDocument = createAction({
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).updateDocument
|
||||
stores.policies.abilities(activeCollectionId).createTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
@@ -1381,7 +1339,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments();
|
||||
stores.ui.set({ rightSidebar: "comments" });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { newDocumentPath, newTemplatePath, urlify } from "~/utils/routeHelpers";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
settingsPath,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
@@ -57,6 +63,7 @@ export const deleteTemplate = createAction({
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await template.delete();
|
||||
history.push(settingsPath("templates"));
|
||||
toast.success(t("Template deleted"));
|
||||
}}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
@@ -217,7 +224,7 @@ export const printTemplate = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
|
||||
perform: () => {
|
||||
queueMicrotask(window.print);
|
||||
setTimeout(window.print, 0);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
matchPath,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { RightSidebarProvider } from "~/components/RightSidebarContext";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
@@ -23,8 +16,6 @@ import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
@@ -32,12 +23,6 @@ import NotificationBadge from "./NotificationBadge";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments/Comments")
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
);
|
||||
@@ -48,9 +33,7 @@ type Props = {
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const layoutRef = React.useRef<HTMLDivElement>(null);
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
@@ -92,50 +75,20 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
</Fade>
|
||||
);
|
||||
|
||||
const showHistory =
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
}) && can.listRevisions;
|
||||
const showComments =
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded &&
|
||||
!!team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentContextProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout
|
||||
title={team.name}
|
||||
sidebar={sidebar}
|
||||
sidebarRight={sidebarRight}
|
||||
ref={layoutRef}
|
||||
>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,12 +23,9 @@ const Container = styled.div<Props>`
|
||||
type ContentProps = { $maxWidth?: string };
|
||||
|
||||
const Content = styled.div<ContentProps>`
|
||||
max-width: ${(props) => props.$maxWidth ?? "46em"};
|
||||
max-width: ${(props: ContentProps) =>
|
||||
props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({
|
||||
|
||||
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -22,6 +22,7 @@ import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
@@ -58,6 +59,7 @@ function DocumentListItem(
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMobile = useMobile();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
@@ -159,7 +161,7 @@ function DocumentListItem(
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && <StarButton document={document} />}
|
||||
{canStar && !isMobile && <StarButton document={document} />}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -7,6 +8,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import { useRightSidebarContent } from "~/components/RightSidebarContext";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
@@ -19,16 +21,15 @@ type Props = {
|
||||
title?: string;
|
||||
/** Left sidebar content. */
|
||||
sidebar?: React.ReactNode;
|
||||
/** Right sidebar content. */
|
||||
sidebarRight?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Layout = React.forwardRef(function Layout_(
|
||||
{ title, children, sidebar, sidebarRight }: Props,
|
||||
{ title, children, sidebar }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
const sidebarRight = useRightSidebarContent();
|
||||
|
||||
return (
|
||||
<Container column auto ref={ref}>
|
||||
@@ -61,7 +62,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{sidebarRight}
|
||||
<AnimatePresence initial={false}>{sidebarRight}</AnimatePresence>
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import type { ActionVariant, ActionWithChildren } from "~/types";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
@@ -61,11 +62,6 @@ export const ContextMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -80,7 +76,7 @@ export const ContextMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type {
|
||||
ActionVariant,
|
||||
ActionWithChildren,
|
||||
@@ -98,11 +99,6 @@ export const DropdownMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
@@ -129,7 +125,7 @@ export const DropdownMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
|
||||
@@ -103,6 +103,7 @@ const StyledLink = styled(Link)`
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
pointer-events: none;
|
||||
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
@@ -20,6 +20,55 @@ import Tooltip from "../Tooltip";
|
||||
import NotificationListItem from "./NotificationListItem";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
|
||||
/**
|
||||
* Hook that returns filtered notifications in a stable order. The order is
|
||||
* snapshotted on first call (when the popover mounts) so that toggling
|
||||
* read/unread does not cause items to jump positions. Notifications that
|
||||
* arrive after the snapshot are prepended at the top.
|
||||
*
|
||||
* @param active - the current list of active notifications.
|
||||
* @param filter - the selected notification filter category.
|
||||
* @returns filtered notifications in snapshot order.
|
||||
*/
|
||||
function useStableOrderedNotifications(
|
||||
active: Notification[],
|
||||
filter: NotificationFilter
|
||||
) {
|
||||
const orderSnapshotRef = React.useRef<string[] | null>(null);
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (orderSnapshotRef.current === null) {
|
||||
orderSnapshotRef.current = active.map((n) => n.id);
|
||||
}
|
||||
|
||||
const filtered =
|
||||
filter === "all"
|
||||
? active
|
||||
: active.filter((notification) =>
|
||||
Notification.filterCategories[filter].includes(notification.event)
|
||||
);
|
||||
|
||||
const snapshot = orderSnapshotRef.current;
|
||||
const orderMap = new Map(snapshot.map((id, index) => [id, index]));
|
||||
const inSnapshot: Notification[] = [];
|
||||
const newItems: Notification[] = [];
|
||||
|
||||
for (const notification of filtered) {
|
||||
if (orderMap.has(notification.id)) {
|
||||
inSnapshot.push(notification);
|
||||
} else {
|
||||
newItems.push(notification);
|
||||
}
|
||||
}
|
||||
|
||||
inSnapshot.sort(
|
||||
(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)
|
||||
);
|
||||
|
||||
return [...newItems, ...inSnapshot];
|
||||
}, [active, filter]);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
@@ -49,16 +98,10 @@ function Notifications(
|
||||
[t]
|
||||
);
|
||||
|
||||
const filteredNotifications = React.useMemo(() => {
|
||||
if (filter === "all") {
|
||||
return notifications.active;
|
||||
}
|
||||
|
||||
const eventTypes = Notification.filterCategories[filter];
|
||||
return notifications.active.filter((notification) =>
|
||||
eventTypes.includes(notification.event)
|
||||
);
|
||||
}, [notifications.active, filter]);
|
||||
const filteredNotifications = useStableOrderedNotifications(
|
||||
notifications.active,
|
||||
filter
|
||||
);
|
||||
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
|
||||
type SetSidebarFn = (content: React.ReactNode) => void;
|
||||
|
||||
const RightSidebarSetterContext = React.createContext<SetSidebarFn | null>(
|
||||
null
|
||||
);
|
||||
const RightSidebarContentContext = React.createContext<React.ReactNode>(null);
|
||||
|
||||
/**
|
||||
* Provider that holds right sidebar content state. Wrap at the layout level
|
||||
* so that scenes can set sidebar content via the setter hook.
|
||||
*/
|
||||
export function RightSidebarProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [content, setContent] = React.useState<React.ReactNode>(null);
|
||||
|
||||
return (
|
||||
<RightSidebarSetterContext.Provider value={setContent}>
|
||||
<RightSidebarContentContext.Provider value={content}>
|
||||
{children}
|
||||
</RightSidebarContentContext.Provider>
|
||||
</RightSidebarSetterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable setter function to set the right sidebar content.
|
||||
* Used by scenes (e.g. Document) to populate the sidebar.
|
||||
*/
|
||||
export function useSetRightSidebar(): SetSidebarFn {
|
||||
const setter = React.useContext(RightSidebarSetterContext);
|
||||
if (!setter) {
|
||||
throw new Error(
|
||||
"useSetRightSidebar must be used within a RightSidebarProvider"
|
||||
);
|
||||
}
|
||||
return setter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current right sidebar content. Used by Layout to render
|
||||
* the sidebar.
|
||||
*/
|
||||
export function useRightSidebarContent(): React.ReactNode {
|
||||
return React.useContext(RightSidebarContentContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Context indicating whether the Right sidebar wrapper is already rendered
|
||||
* by an ancestor. When true, SidebarLayout skips rendering its own Right
|
||||
* wrapper to avoid duplicate animated containers.
|
||||
*/
|
||||
export const RightSidebarWrappedContext = React.createContext(false);
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type { SearchResult } from "~/types";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
@@ -28,73 +29,112 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const focusRef = React.useRef<HTMLElement | null>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
>();
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
>(searchResults);
|
||||
|
||||
// Cache search results by query string to avoid redundant API calls
|
||||
const cacheRef = React.useRef(new Map<string, SearchResult[]>());
|
||||
const queryRef = React.useRef(query);
|
||||
queryRef.current = query;
|
||||
|
||||
// When the query changes, restore cached results (including empty) or keep
|
||||
// previous results visible until new results arrive to avoid layout shift
|
||||
React.useEffect(() => {
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
setOpen(true);
|
||||
if (!query) {
|
||||
setSearchResults(undefined);
|
||||
return;
|
||||
}
|
||||
}, [searchResults, query]);
|
||||
|
||||
// Clear search results when the query changes to prevent stale results
|
||||
React.useEffect(() => {
|
||||
setSearchResults(undefined);
|
||||
const cached = cacheRef.current.get(query);
|
||||
if (cached !== undefined) {
|
||||
setSearchResults(cached);
|
||||
if (cached.length) {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query: searchQuery, ...options }) => {
|
||||
if (searchQuery?.length > 0) {
|
||||
const response = await documents.search({
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.length) {
|
||||
setSearchResults((state) => [...(state ?? []), ...response]);
|
||||
}
|
||||
|
||||
return response;
|
||||
async ({
|
||||
query: searchQuery,
|
||||
offset = 0,
|
||||
...options
|
||||
}: Record<string, any>) => {
|
||||
if (!searchQuery?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
// Return cached results for first-page lookups
|
||||
if (offset === 0 && cacheRef.current.has(searchQuery)) {
|
||||
return cacheRef.current.get(searchQuery)!;
|
||||
}
|
||||
|
||||
// Force offset to 0 for new queries — PaginatedList's reset() sets
|
||||
// offset via setState but fetchResults still uses the stale value
|
||||
// from its closure
|
||||
if (!cacheRef.current.has(searchQuery)) {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const response = await documents.search({
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
offset,
|
||||
...options,
|
||||
});
|
||||
|
||||
// Build complete result set in cache: replace for new queries, append
|
||||
// for pagination of an existing query
|
||||
const existing = cacheRef.current.get(searchQuery);
|
||||
cacheRef.current.set(
|
||||
searchQuery,
|
||||
existing ? [...existing, ...response] : response
|
||||
);
|
||||
|
||||
// Only update state if this query is still current to prevent stale
|
||||
// results from overwriting newer results after a race condition
|
||||
if (queryRef.current === searchQuery) {
|
||||
setSearchResults(cacheRef.current.get(searchQuery)!);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[documents, shareId]
|
||||
);
|
||||
|
||||
const handleSearchInputChange = React.useMemo(
|
||||
const debouncedSetQuery = React.useMemo(
|
||||
() =>
|
||||
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
const trimmedValue = value.trim();
|
||||
setQuery(trimmedValue);
|
||||
setOpen(!!trimmedValue);
|
||||
}, 300),
|
||||
[cachedQuery]
|
||||
debounce((value: string) => {
|
||||
setQuery(value);
|
||||
setOpen(!!value);
|
||||
}, 250),
|
||||
[]
|
||||
);
|
||||
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
const handleSearchInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debouncedSetQuery(event.target.value.trim());
|
||||
},
|
||||
[debouncedSetQuery]
|
||||
);
|
||||
|
||||
React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
|
||||
|
||||
const handleEscapeList = React.useCallback(
|
||||
() => searchInputRef?.current?.focus(),
|
||||
[searchInputRef]
|
||||
() => searchInputRef.current?.focus(),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchInputFocus = React.useCallback(() => {
|
||||
focusRef.current = searchInputRef.current;
|
||||
}, [searchInputRef]);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -106,6 +146,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (searchResults) {
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||
@@ -116,12 +157,12 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (atEnd) {
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
if (open || atEnd) {
|
||||
ev.preventDefault();
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
@@ -131,21 +172,17 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.currentTarget.value) {
|
||||
if (ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
[open, searchResults]
|
||||
@@ -153,11 +190,12 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
|
||||
const handleSearchItemClick = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = "";
|
||||
focusRef.current = document.getElementById(bodyContentId);
|
||||
}
|
||||
}, [searchInputRef]);
|
||||
}, []);
|
||||
|
||||
useKeyDown("/", (ev) => {
|
||||
if (
|
||||
@@ -193,7 +231,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
align="start"
|
||||
shrink
|
||||
onEscapeKeyDown={handleEscapeList}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onOpenAutoFocus={preventDefault}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target === searchInputRef.current) {
|
||||
@@ -203,8 +241,13 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
options={{
|
||||
query,
|
||||
snippetMinWords: 10,
|
||||
snippetMaxWords: 11,
|
||||
limit: 10,
|
||||
}}
|
||||
items={searchResults}
|
||||
fetch={performSearch}
|
||||
onEscape={handleEscapeList}
|
||||
empty={
|
||||
@@ -218,7 +261,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ref={index === 0 ? firstSearchItem : undefined}
|
||||
document={item.document}
|
||||
context={item.context}
|
||||
highlight={cachedQuery}
|
||||
highlight={query}
|
||||
onClick={handleSearchItemClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -89,7 +89,11 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
const members = React.useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
document.members,
|
||||
Array.from(
|
||||
new Map(
|
||||
document.members.map((memberUser) => [memberUser.id, memberUser])
|
||||
).values()
|
||||
),
|
||||
(memberUser) =>
|
||||
(invitedInSession.includes(memberUser.id) ? "_" : "") +
|
||||
memberUser.name.toLocaleLowerCase(),
|
||||
@@ -124,12 +128,19 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupMemberships
|
||||
.inDocument(document.id)
|
||||
{Array.from(
|
||||
new Map(
|
||||
groupMemberships
|
||||
.inDocument(document.id)
|
||||
.map((membership) => [membership.group.id, membership])
|
||||
).values()
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
).localeCompare(
|
||||
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
|
||||
)
|
||||
)
|
||||
.map((membership) => {
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
|
||||
@@ -193,8 +193,8 @@ export const Suggestions = observer(
|
||||
...pending.map((suggestion) => (
|
||||
<PendingListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion)}
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion)}
|
||||
onClick={() => removePendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
@@ -218,8 +218,8 @@ export const Suggestions = observer(
|
||||
...suggestionsWithPending.map((suggestion) => (
|
||||
<ListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion as User)}
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion as User)}
|
||||
onClick={() => addPendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
|
||||
@@ -8,19 +8,23 @@ import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useWindowScrollbarWidth from "~/hooks/useWindowScrollbarWidth";
|
||||
import { sidebarAppearDuration } from "~/styles/animations";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
border?: boolean;
|
||||
/** When true, skip the entrance animation and render at full width immediately. */
|
||||
skipInitialAnimation?: boolean;
|
||||
}
|
||||
|
||||
function Right({ children, border, className }: Props) {
|
||||
function Right({ children, border, className, skipInitialAnimation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const windowScrollbarWidth = useWindowScrollbarWidth();
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -67,16 +71,20 @@ function Right({ children, border, className }: Props) {
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${ui.sidebarRightWidth}px`,
|
||||
width: windowScrollbarWidth
|
||||
? `${ui.sidebarRightWidth - windowScrollbarWidth}px`
|
||||
: `${ui.sidebarRightWidth}px`,
|
||||
}),
|
||||
[ui.sidebarRightWidth]
|
||||
[ui.sidebarRightWidth, windowScrollbarWidth]
|
||||
);
|
||||
|
||||
const animationProps = {
|
||||
initial: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
initial: skipInitialAnimation
|
||||
? false
|
||||
: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
animate: {
|
||||
transition: isResizing
|
||||
? { duration: 0 }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import type Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -48,7 +47,7 @@ function SharedSidebar({ share }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSidebar $hoverTransition={!teamAvailable} canCollapse={false}>
|
||||
<Sidebar canCollapse={false}>
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
@@ -90,7 +89,7 @@ function SharedSidebar({ share }: Props) {
|
||||
)}
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
</StyledSidebar>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,33 +112,4 @@ const StyledSearchPopover = styled(SearchPopover)`
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
const ToggleWrapper = styled.div`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
transition:
|
||||
opacity 100ms ease-out,
|
||||
transform 100ms ease-out;
|
||||
`;
|
||||
|
||||
const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${({ $hoverTransition }) =>
|
||||
$hoverTransition &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:${hover} {
|
||||
${StyledSearchPopover} {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
${ToggleWrapper} {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(SharedSidebar);
|
||||
|
||||
@@ -28,6 +28,7 @@ interface Props extends Omit<
|
||||
disabled?: boolean;
|
||||
/** Callback when the switch state changes */
|
||||
onChange?: (checked: boolean) => void;
|
||||
inForm?: boolean;
|
||||
}
|
||||
|
||||
function Switch(
|
||||
@@ -35,6 +36,7 @@ function Switch(
|
||||
width = 32,
|
||||
height = 18,
|
||||
labelPosition = "left",
|
||||
inForm = true,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
@@ -71,7 +73,7 @@ function Switch(
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Wrapper $inForm={inForm}>
|
||||
<Label
|
||||
disabled={disabled}
|
||||
htmlFor={props.id}
|
||||
@@ -100,8 +102,8 @@ function Switch(
|
||||
return component;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
const Wrapper = styled.div<{ $inForm?: boolean }>`
|
||||
padding-bottom: ${(props) => (props.$inForm ? 8 : 0)}px;
|
||||
${undraggableOnDesktop()}
|
||||
`;
|
||||
|
||||
|
||||
@@ -95,6 +95,13 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
/** Restrict shared layout animation to the X axis only. */
|
||||
const horizontalOnly = (transform: Record<string, string>, generated: string) =>
|
||||
generated.replace(
|
||||
/translate3d\(([^,]+),\s*[^,]+,\s*([^)]+)\)/,
|
||||
"translate3d($1, 0px, $2)"
|
||||
);
|
||||
|
||||
const Tab: React.FC<Props> = (props: Props) => {
|
||||
const { children, exact, exactQueryString } = props;
|
||||
const theme = useTheme();
|
||||
@@ -112,6 +119,7 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</TabButton>
|
||||
@@ -140,6 +148,7 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnimateSharedLayout } from "framer-motion";
|
||||
import { LayoutGroup } from "framer-motion";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -84,13 +84,13 @@ const Tabs: React.FC = ({ children }: Props) => {
|
||||
}, [width, updateShadows]);
|
||||
|
||||
return (
|
||||
<AnimateSharedLayout>
|
||||
<LayoutGroup>
|
||||
<Sticky>
|
||||
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
|
||||
{children}
|
||||
</Nav>
|
||||
</Sticky>
|
||||
</AnimateSharedLayout>
|
||||
</LayoutGroup>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
|
||||
collections.orderedData.reduce<Option[]>((memo, collection) => {
|
||||
const canCollection = policies.abilities(collection.id);
|
||||
|
||||
if (canCollection.createDocument) {
|
||||
if (canCollection.createTemplate) {
|
||||
memo.push({
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
|
||||
@@ -40,7 +40,7 @@ const DrawerContent = React.forwardRef<
|
||||
transition: { bounce: 0, duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<StyledInnerContent ref={measureRef} {...rest}>
|
||||
<StyledInnerContent column ref={measureRef} {...rest}>
|
||||
{children}
|
||||
</StyledInnerContent>
|
||||
</StyledContent>
|
||||
@@ -58,9 +58,9 @@ const DrawerTitle = React.forwardRef<
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
<StyledText size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
{children}
|
||||
</Text>
|
||||
</StyledText>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -75,6 +75,10 @@ const DrawerTitle = React.forwardRef<
|
||||
});
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(m.div)`
|
||||
z-index: ${depths.menu};
|
||||
@@ -92,7 +96,7 @@ const StyledContent = styled(m.div)`
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
const StyledInnerContent = styled.div`
|
||||
const StyledInnerContent = styled(Flex)`
|
||||
padding: 6px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
@@ -129,7 +129,7 @@ const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
|
||||
`}
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
!props.disabled &&
|
||||
`
|
||||
&[data-highlighted],
|
||||
&[data-state="open"],
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
|
||||
@@ -20,6 +20,7 @@ function BlockMenu(props: Props) {
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
disclosure={options.disclosure}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
|
||||
@@ -39,7 +39,6 @@ const EmojiMenu = (props: Props) => {
|
||||
.map((item) => {
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
// @ts-expect-error emojiMartToGemoji key
|
||||
const id = emojiMartToGemoji[item.id] || item.id;
|
||||
const type = determineIconType(id);
|
||||
const value = type === IconType.Custom ? id : snakeCase(id);
|
||||
|
||||
@@ -90,12 +90,19 @@ function usePosition({
|
||||
} as DOMRect);
|
||||
|
||||
// position at the top right of code blocks
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
const isCodeNodeSelection =
|
||||
selection instanceof NodeSelection && isCode(selection.node);
|
||||
const codeBlock = isCodeNodeSelection
|
||||
? { pos: selection.from, node: selection.node }
|
||||
: findParentNode(isCode)(view.state.selection);
|
||||
const noticeBlock = findParentNode(
|
||||
(node) => node.type.name === "container_notice"
|
||||
)(view.state.selection);
|
||||
|
||||
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
|
||||
if (
|
||||
(codeBlock || noticeBlock) &&
|
||||
(view.state.selection.empty || isCodeNodeSelection)
|
||||
) {
|
||||
const position = codeBlock
|
||||
? codeBlock.pos
|
||||
: noticeBlock
|
||||
|
||||
@@ -82,154 +82,153 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
// Computed in the render body so MobX observer can track store access
|
||||
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
|
||||
// runs outside the reactive context and triggered MobX warnings.
|
||||
const items: MentionItem[] =
|
||||
actorId && !loading
|
||||
? users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
title: user.name,
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
label: user.name,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
.concat(
|
||||
groups
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map((group) => ({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24, marginRight: 4 }}
|
||||
>
|
||||
<GroupAvatar group={group} size={AvatarSize.Small} />
|
||||
</Flex>
|
||||
),
|
||||
title: group.name,
|
||||
subtitle: t("{{ count }} members", {
|
||||
count: group.memberCount,
|
||||
}),
|
||||
section: GroupSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Group,
|
||||
modelId: group.id,
|
||||
actorId,
|
||||
label: group.name,
|
||||
},
|
||||
}))
|
||||
)
|
||||
.concat(
|
||||
documents
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(doc) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: doc.icon ? (
|
||||
<Icon
|
||||
value={doc.icon}
|
||||
initial={doc.initial}
|
||||
color={doc.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collectionId ? (
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
) : undefined,
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
label: doc.title,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
collections
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(collection) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: collection.icon ? (
|
||||
<Icon
|
||||
value={collection.icon}
|
||||
initial={collection.initial}
|
||||
color={collection.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<CollectionIcon />
|
||||
),
|
||||
title: collection.name,
|
||||
section: CollectionsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
actorId,
|
||||
label: collection.name,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
)
|
||||
.concat([
|
||||
{
|
||||
name: "link",
|
||||
icon: <PlusIcon />,
|
||||
title: search?.trim(),
|
||||
section: DocumentsSection,
|
||||
subtitle: t("Create a new doc"),
|
||||
visible: !!search && !isEmail(search),
|
||||
priority: -1,
|
||||
const items: MentionItem[] = actorId
|
||||
? users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
title: user.name,
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: uuidv4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
label: search,
|
||||
label: user.name,
|
||||
},
|
||||
} as MentionItem,
|
||||
])
|
||||
: [];
|
||||
}) as MentionItem
|
||||
)
|
||||
.concat(
|
||||
groups
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map((group) => ({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24, marginRight: 4 }}
|
||||
>
|
||||
<GroupAvatar group={group} size={AvatarSize.Small} />
|
||||
</Flex>
|
||||
),
|
||||
title: group.name,
|
||||
subtitle: t("{{ count }} members", {
|
||||
count: group.memberCount,
|
||||
}),
|
||||
section: GroupSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Group,
|
||||
modelId: group.id,
|
||||
actorId,
|
||||
label: group.name,
|
||||
},
|
||||
}))
|
||||
)
|
||||
.concat(
|
||||
documents
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(doc) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: doc.icon ? (
|
||||
<Icon
|
||||
value={doc.icon}
|
||||
initial={doc.initial}
|
||||
color={doc.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collectionId ? (
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
) : undefined,
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
label: doc.title,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
collections
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(collection) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: collection.icon ? (
|
||||
<Icon
|
||||
value={collection.icon}
|
||||
initial={collection.initial}
|
||||
color={collection.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<CollectionIcon />
|
||||
),
|
||||
title: collection.name,
|
||||
section: CollectionsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
actorId,
|
||||
label: collection.name,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
)
|
||||
.concat([
|
||||
{
|
||||
name: "link",
|
||||
icon: <PlusIcon />,
|
||||
title: search?.trim(),
|
||||
section: DocumentsSection,
|
||||
subtitle: t("Create a new doc"),
|
||||
visible: !!search && !isEmail(search),
|
||||
priority: -1,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: uuidv4(),
|
||||
actorId,
|
||||
label: search,
|
||||
},
|
||||
} as MentionItem,
|
||||
])
|
||||
: [];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
|
||||
@@ -67,7 +67,10 @@ function useItems({
|
||||
|
||||
const singleUrl =
|
||||
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
|
||||
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
|
||||
const matchedEmbed = singleUrl
|
||||
? getMatchingEmbed(embeds, singleUrl)?.embed
|
||||
: null;
|
||||
const embed = matchedEmbed?.disabled ? null : matchedEmbed;
|
||||
|
||||
// Check embeddability for single URL
|
||||
useEffect(() => {
|
||||
|
||||
@@ -240,7 +240,10 @@ export function SelectionToolbar(props: Props) {
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
|
||||
if (isCodeSelection && selection.empty) {
|
||||
if (
|
||||
isCodeSelection &&
|
||||
(selection.empty || selection instanceof NodeSelection)
|
||||
) {
|
||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||
align = "end";
|
||||
} else if (isTableSelected(state)) {
|
||||
|
||||
@@ -6,21 +6,26 @@ import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { MouseSafeArea } from "~/components/MouseSafeArea";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -29,38 +34,6 @@ import { useEditor } from "./EditorContext";
|
||||
import Input from "./Input";
|
||||
import { MenuHeader } from "~/components/primitives/components/Menu";
|
||||
|
||||
type TopAnchor = {
|
||||
top: number;
|
||||
bottom: undefined;
|
||||
};
|
||||
|
||||
type BottomAnchor = {
|
||||
top: undefined;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
type LeftAnchor = {
|
||||
left: number;
|
||||
right: undefined;
|
||||
};
|
||||
|
||||
type RightAnchor = {
|
||||
left: undefined;
|
||||
right: number;
|
||||
};
|
||||
|
||||
type Position = ((TopAnchor | BottomAnchor) & (LeftAnchor | RightAnchor)) & {
|
||||
isAbove: boolean;
|
||||
};
|
||||
|
||||
const defaultPosition: Position = {
|
||||
top: 0,
|
||||
bottom: undefined,
|
||||
left: -10000,
|
||||
right: undefined,
|
||||
isAbove: false,
|
||||
};
|
||||
|
||||
export type Props<T extends MenuItem = MenuItem> = {
|
||||
rtl: boolean;
|
||||
isActive: boolean;
|
||||
@@ -80,6 +53,7 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
index: number,
|
||||
options: {
|
||||
selected: boolean;
|
||||
disclosure?: boolean;
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
) => React.ReactNode;
|
||||
@@ -92,23 +66,65 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const dictionary = useDictionary();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const hasActivated = React.useRef(false);
|
||||
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
});
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
|
||||
const [position, setPosition] = React.useState<Position>(defaultPosition);
|
||||
const [insertItem, setInsertItem] = React.useState<
|
||||
MenuItem | EmbedDescriptor
|
||||
>();
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [submenu, setSubmenu] = React.useState<{
|
||||
index: number;
|
||||
items: MenuItem[];
|
||||
selectedIndex: number;
|
||||
} | null>(null);
|
||||
const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map());
|
||||
const submenuContentRef = React.useRef<HTMLDivElement>(null);
|
||||
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Stores the caret bounding rect, snapshotted when the menu opens
|
||||
const caretRectRef = React.useRef(new DOMRect());
|
||||
|
||||
// Stable virtual element for Radix PopoverAnchor – never replaced so the
|
||||
// popper does not trigger unnecessary anchor-change cycles.
|
||||
const caretRef = React.useRef({
|
||||
getBoundingClientRect: () => caretRectRef.current,
|
||||
});
|
||||
|
||||
// Compute and store the caret rect during render so it is available before
|
||||
// the Radix popper effect runs for the first time.
|
||||
const caretRect = React.useMemo(() => {
|
||||
if (!props.isActive) {
|
||||
return new DOMRect();
|
||||
}
|
||||
|
||||
try {
|
||||
const { selection } = view.state;
|
||||
const fromPos = view.coordsAtPos(selection.from);
|
||||
const toPos = view.coordsAtPos(selection.to, -1);
|
||||
const top = Math.min(fromPos.top, toPos.top);
|
||||
const bottom = Math.max(fromPos.bottom, toPos.bottom);
|
||||
const left = Math.min(fromPos.left, toPos.left);
|
||||
const right = Math.max(fromPos.right, toPos.right);
|
||||
return new DOMRect(left, top, right - left, bottom - top);
|
||||
} catch (err) {
|
||||
Logger.warn("Unable to calculate caret position", err);
|
||||
return new DOMRect();
|
||||
}
|
||||
}, [props.isActive, view]);
|
||||
|
||||
caretRectRef.current = caretRect;
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem["children"]
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.isActive) {
|
||||
hasActivated.current = true;
|
||||
// Save the selection position when the menu opens. On mobile, the editor
|
||||
// may lose focus/selection when tapping on menu items, so we restore it.
|
||||
requestAnimationFrame(() => {
|
||||
@@ -121,81 +137,21 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.isActive]);
|
||||
|
||||
const calculatePosition = React.useCallback(
|
||||
(props: Props) => {
|
||||
if (!props.isActive) {
|
||||
return defaultPosition;
|
||||
}
|
||||
React.useEffect(() => {
|
||||
setSubmenu(null);
|
||||
|
||||
const caretPosition = () => {
|
||||
let fromPos;
|
||||
let toPos;
|
||||
try {
|
||||
fromPos = view.coordsAtPos(selection.from);
|
||||
toPos = view.coordsAtPos(selection.to, -1);
|
||||
} catch (err) {
|
||||
Logger.warn("Unable to calculate caret position", err);
|
||||
return {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
};
|
||||
}
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure that start < end for the menu to be positioned correctly
|
||||
return {
|
||||
top: Math.min(fromPos.top, toPos.top),
|
||||
bottom: Math.max(fromPos.bottom, toPos.bottom),
|
||||
left: Math.min(fromPos.left, toPos.left),
|
||||
right: Math.max(fromPos.right, toPos.right),
|
||||
};
|
||||
};
|
||||
setSelectedIndex(0);
|
||||
setInsertItem(undefined);
|
||||
}, [props.isActive]);
|
||||
|
||||
const { selection } = view.state;
|
||||
const ref = menuRef.current;
|
||||
const offsetWidth = ref ? ref.offsetWidth : 0;
|
||||
const offsetHeight = ref ? ref.offsetHeight : 0;
|
||||
const { top, bottom, right, left } = caretPosition();
|
||||
const margin = 12;
|
||||
|
||||
const offsetParent = ref?.offsetParent
|
||||
? ref.offsetParent.getBoundingClientRect()
|
||||
: ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
} as DOMRect);
|
||||
|
||||
let leftPos = Math.min(
|
||||
left - offsetParent.left,
|
||||
window.innerWidth - offsetParent.left - offsetWidth - margin
|
||||
);
|
||||
if (props.rtl) {
|
||||
leftPos = right - offsetWidth;
|
||||
}
|
||||
|
||||
if (top - offsetHeight > margin) {
|
||||
return {
|
||||
left: leftPos,
|
||||
top: undefined,
|
||||
bottom: offsetParent.bottom - top,
|
||||
right: undefined,
|
||||
isAbove: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
left: leftPos,
|
||||
top: bottom - offsetParent.top,
|
||||
bottom: undefined,
|
||||
right: undefined,
|
||||
isAbove: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
[view]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setSubmenu(null);
|
||||
}, [props.search]);
|
||||
|
||||
const handleClearSearch = React.useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -226,26 +182,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
);
|
||||
}, [props.search, props.trigger, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset scroll position to top when opening menu as the contents are
|
||||
// hidden, not unrendered
|
||||
if (menuRef.current) {
|
||||
menuRef.current.scroll({ top: 0 });
|
||||
}
|
||||
|
||||
setPosition(calculatePosition(props));
|
||||
setSelectedIndex(0);
|
||||
setInsertItem(undefined);
|
||||
}, [calculatePosition, props.isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [props.search]);
|
||||
|
||||
const restoreSelection = React.useCallback(() => {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
@@ -461,7 +397,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const embedItems: EmbedDescriptor[] = [];
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title && embed.visible !== false) {
|
||||
if (embed.title && embed.visible !== false && !embed.disabled) {
|
||||
embedItems.push(
|
||||
new EmbedDescriptor({
|
||||
...embed,
|
||||
@@ -481,11 +417,47 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
|
||||
const searchInput = search.toLowerCase();
|
||||
|
||||
const matchesSearch = (item: MenuItem | EmbedDescriptor) =>
|
||||
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.keywords || "").toLocaleLowerCase().includes(searchInput);
|
||||
|
||||
// When searching, flatten matching children into the top-level list so
|
||||
// they are directly navigable with the keyboard. If all children match,
|
||||
// exclude the parent item since it would be redundant.
|
||||
const fullyFlattenedParents = new Set<MenuItem | EmbedDescriptor>();
|
||||
if (search && filterable) {
|
||||
const flattened: (EmbedDescriptor | MenuItem)[] = [];
|
||||
for (const item of items) {
|
||||
if ("children" in item && item.children) {
|
||||
const children = resolveChildren(item.children);
|
||||
if (children) {
|
||||
const matching = children.filter(matchesSearch);
|
||||
if (matching.length > 0) {
|
||||
for (const child of matching) {
|
||||
const { children: _, ...flat } = child;
|
||||
flattened.push(flat);
|
||||
}
|
||||
if (matching.length === children.length) {
|
||||
fullyFlattenedParents.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items = items.concat(flattened);
|
||||
}
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
if (item.name === "separator") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fullyFlattenedParents.has(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.visible === false) {
|
||||
return false;
|
||||
}
|
||||
@@ -514,11 +486,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return (
|
||||
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.keywords || "").toLocaleLowerCase().includes(searchInput)
|
||||
);
|
||||
return matchesSearch(item);
|
||||
});
|
||||
|
||||
return filterExcessSeparators(
|
||||
@@ -541,18 +509,40 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
);
|
||||
}, [commands, props]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
!menuRef.current ||
|
||||
menuRef.current.contains(event.target as Element)
|
||||
) {
|
||||
const openSubmenu = React.useCallback(
|
||||
(index: number) => {
|
||||
const item = filtered[index];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (!children?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onClose();
|
||||
};
|
||||
const normalized = filterExcessSeparators(
|
||||
children.filter((child) => child.visible !== false)
|
||||
);
|
||||
const firstSelectable = normalized.findIndex(
|
||||
(child) =>
|
||||
child.name !== "separator" && !("disabled" in child && child.disabled)
|
||||
);
|
||||
if (firstSelectable === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmenu({
|
||||
index,
|
||||
items: normalized,
|
||||
selectedIndex: firstSelectable,
|
||||
});
|
||||
},
|
||||
[filtered]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
@@ -561,18 +551,109 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the link input's own handlers manage navigation keys
|
||||
if (insertItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Submenu open: route keys into it ---
|
||||
if (submenu) {
|
||||
if (event.key === "ArrowDown" || (event.ctrlKey && event.key === "n")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const total = submenu.items.length - 1;
|
||||
let next = submenu.selectedIndex + 1;
|
||||
while (next <= total) {
|
||||
const child = submenu.items[next];
|
||||
if (
|
||||
child?.name !== "separator" &&
|
||||
!("disabled" in child && child.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
next++;
|
||||
}
|
||||
if (next <= total) {
|
||||
setSubmenu((s) => (s ? { ...s, selectedIndex: next } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let prev = submenu.selectedIndex - 1;
|
||||
while (prev >= 0) {
|
||||
const child = submenu.items[prev];
|
||||
if (
|
||||
child?.name !== "separator" &&
|
||||
!("disabled" in child && child.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
prev--;
|
||||
}
|
||||
if (prev >= 0) {
|
||||
setSubmenu((s) => (s ? { ...s, selectedIndex: prev } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSubmenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const child = submenu.items[submenu.selectedIndex];
|
||||
if (child) {
|
||||
handleClickItem(child);
|
||||
setSubmenu(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Normal (no submenu) ---
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
const item = filtered[selectedIndex];
|
||||
|
||||
if (item) {
|
||||
handleClickItem(item);
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (children?.length) {
|
||||
openSubmenu(selectedIndex);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
} else {
|
||||
props.onClose(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
const item = filtered[selectedIndex];
|
||||
if (item) {
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (children?.length) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openSubmenu(selectedIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "ArrowUp" ||
|
||||
(event.key === "Tab" && event.shiftKey) ||
|
||||
@@ -636,18 +717,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("keydown", handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("keydown", handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [close, filtered, handleClickItem, props, selectedIndex]);
|
||||
}, [close, filtered, handleClickItem, insertItem, openSubmenu, props, selectedIndex, submenu]);
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
@@ -675,6 +754,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
</VisuallyHidden.Root>
|
||||
);
|
||||
|
||||
// Close submenu when parent selection moves away from the trigger
|
||||
React.useEffect(() => {
|
||||
if (submenu && submenu.index !== selectedIndex) {
|
||||
setSubmenu(null);
|
||||
}
|
||||
}, [selectedIndex, submenu]);
|
||||
|
||||
// Cleanup hover timer on unmount
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderItems = () => {
|
||||
let prevHeading: string | undefined;
|
||||
|
||||
@@ -693,6 +789,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasChildren = !!(
|
||||
"children" in item && resolveChildren(item.children)?.length
|
||||
);
|
||||
|
||||
const handlePointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
!("disabled" in item && item.disabled) &&
|
||||
@@ -708,6 +808,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
|
||||
// Hover to open submenu with delay
|
||||
if (hasChildren) {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
openSubmenu(index);
|
||||
}, 150);
|
||||
} else {
|
||||
// Close submenu when hovering a regular item
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
setSubmenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
@@ -722,23 +838,37 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const handleOnClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handleClickItem(item);
|
||||
if (hasChildren) {
|
||||
openSubmenu(index);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const itemRef = (node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
itemRefs.current.set(index, node);
|
||||
} else {
|
||||
itemRefs.current.delete(index);
|
||||
}
|
||||
};
|
||||
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== prevHeading && (
|
||||
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
|
||||
)}
|
||||
<ListItem
|
||||
ref={itemRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
disclosure: hasChildren,
|
||||
onClick: handleOnClick,
|
||||
})}
|
||||
</ListItem>
|
||||
@@ -792,37 +922,152 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Wrapper active={isActive} ref={menuRef} hiddenScrollbars {...position}>
|
||||
{(isActive || hasActivated.current) && (
|
||||
<>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
"placeholder" in insertItem && !!insertItem.placeholder
|
||||
? insertItem.placeholder
|
||||
: insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
<>
|
||||
<Popover open={isActive} onOpenChange={handleOpenChange} modal={false}>
|
||||
<PopoverAnchor virtualRef={caretRef} />
|
||||
<BouncyPopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
width={280}
|
||||
shrink
|
||||
style={{
|
||||
padding: 0,
|
||||
maxHeight:
|
||||
"min(324px, var(--radix-popover-content-available-height))",
|
||||
}}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
if (
|
||||
submenuContentRef.current?.contains(
|
||||
e.target as Node
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
"placeholder" in insertItem && !!insertItem.placeholder
|
||||
? insertItem.placeholder
|
||||
: insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
{fileInput}
|
||||
</BouncyPopoverContent>
|
||||
</Popover>
|
||||
{submenu && itemRefs.current.get(submenu.index) && (
|
||||
<Popover open modal={false}>
|
||||
<PopoverAnchor
|
||||
virtualRef={{
|
||||
current: {
|
||||
getBoundingClientRect: () =>
|
||||
itemRefs.current
|
||||
.get(submenu.index)!
|
||||
.getBoundingClientRect(),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<SubmenuPopoverContent
|
||||
ref={submenuContentRef}
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
width={220}
|
||||
shrink
|
||||
style={{ padding: 0 }}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onPointerLeave={() => setSubmenu(null)}
|
||||
>
|
||||
<MouseSafeArea parentRef={submenuContentRef} />
|
||||
<List>
|
||||
{submenu.items.map((child, childIndex) => {
|
||||
if (child.name === "separator") {
|
||||
return (
|
||||
<ListItem key={childIndex}>
|
||||
<hr />
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
if (!child.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChildPointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
submenu.selectedIndex !== childIndex &&
|
||||
(pointerRef.current.clientX !== ev.clientX ||
|
||||
pointerRef.current.clientY !== ev.clientY)
|
||||
) {
|
||||
setSubmenu((s) =>
|
||||
s ? { ...s, selectedIndex: childIndex } : s
|
||||
);
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
{fileInput}
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
pointerRef.current = {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const handleChildClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handleClickItem(child);
|
||||
setSubmenu(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`sub-${childIndex}-${child.name}`}
|
||||
onPointerMove={handleChildPointerMove}
|
||||
>
|
||||
{props.renderMenuItem(child as any, childIndex, {
|
||||
selected: childIndex === submenu.selectedIndex,
|
||||
onClick: handleChildClick,
|
||||
})}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</SubmenuPopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const bouncyFadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`;
|
||||
|
||||
const BouncyPopoverContent = styled(PopoverContent)`
|
||||
&[data-state="open"] {
|
||||
animation: ${bouncyFadeIn} 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
`;
|
||||
|
||||
const SubmenuPopoverContent = styled(PopoverContent)`
|
||||
max-height: min(324px, var(--radix-popover-content-available-height));
|
||||
`;
|
||||
|
||||
const LinkInputWrapper = styled.div`
|
||||
margin: 8px;
|
||||
`;
|
||||
@@ -839,6 +1084,13 @@ const List = styled.ol`
|
||||
height: 100%;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid ${s("divider")};
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
@@ -860,61 +1112,4 @@ const MobileScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Scrollable)<{
|
||||
active: boolean;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
isAbove: boolean;
|
||||
}>`
|
||||
color: ${s("textSecondary")};
|
||||
font-family: ${s("fontFamily")};
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
${(props) => props.top !== undefined && `top: ${props.top}px`};
|
||||
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
|
||||
left: ${(props) => props.left}px;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
|
||||
rgba(0, 0, 0, 0.08) 0px 4px 8px,
|
||||
rgba(0, 0, 0, 0.08) 0px 2px 4px;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transition-delay: 150ms;
|
||||
line-height: 0;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: 280px;
|
||||
height: auto;
|
||||
max-height: 324px;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid ${s("divider")};
|
||||
}
|
||||
|
||||
${({ active, isAbove }) =>
|
||||
active &&
|
||||
`
|
||||
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default SuggestionsMenu;
|
||||
|
||||
@@ -5,6 +5,7 @@ import styled from "styled-components";
|
||||
import { usePortalContext } from "~/components/Portal";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuDisclosure,
|
||||
MenuIconWrapper,
|
||||
MenuLabel,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
@@ -26,6 +27,8 @@ export type Props = {
|
||||
subtitle?: React.ReactNode;
|
||||
/** A string representing the keyboard shortcut for the item */
|
||||
shortcut?: string;
|
||||
/** Whether to show a disclosure arrow indicating a submenu */
|
||||
disclosure?: boolean;
|
||||
};
|
||||
|
||||
function SuggestionsMenuItem({
|
||||
@@ -37,6 +40,7 @@ function SuggestionsMenuItem({
|
||||
subtitle,
|
||||
shortcut,
|
||||
icon,
|
||||
disclosure,
|
||||
}: Props) {
|
||||
const portal = usePortalContext();
|
||||
const ref = React.useCallback(
|
||||
@@ -75,6 +79,7 @@ function SuggestionsMenuItem({
|
||||
)}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuLabel>
|
||||
{disclosure && <MenuDisclosure />}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,12 +55,10 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
Decoration.widget(
|
||||
parent.pos,
|
||||
() => {
|
||||
button.addEventListener(
|
||||
"click",
|
||||
action(() => {
|
||||
this.state.open = true;
|
||||
})
|
||||
);
|
||||
button.onclick = action(() => {
|
||||
this.state.query = "";
|
||||
this.state.open = true;
|
||||
});
|
||||
return button;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,9 +45,8 @@ export default class SelectionToolbarExtension extends Extension {
|
||||
}
|
||||
|
||||
if (
|
||||
(isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)) &&
|
||||
selection.from > 0
|
||||
isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
+5
-18
@@ -692,19 +692,14 @@ export class Editor extends React.PureComponent<
|
||||
public removeComment = (commentId: string) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markRemoved = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (markRemoved) {
|
||||
return false;
|
||||
}
|
||||
const mark = node.marks.find(
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
tr.removeMark(pos, pos + node.nodeSize, mark);
|
||||
markRemoved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -718,10 +713,7 @@ export class Editor extends React.PureComponent<
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, attrs);
|
||||
markRemoved = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -739,13 +731,8 @@ export class Editor extends React.PureComponent<
|
||||
) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markUpdated = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (markUpdated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
@@ -758,7 +745,6 @@ export class Editor extends React.PureComponent<
|
||||
...attrs,
|
||||
});
|
||||
tr.removeMark(from, to, mark).addMark(from, to, newMark);
|
||||
markUpdated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -774,10 +760,7 @@ export class Editor extends React.PureComponent<
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, newAttrs);
|
||||
markUpdated = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -911,7 +894,11 @@ const EditorContainer = styled(Styles)<{
|
||||
css`
|
||||
span#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
text-decoration: underline 2px ${props.theme.commentMarkBackground};
|
||||
|
||||
* {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
a#comment-${props.focusedCommentId}
|
||||
~ span.component-image
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CopyIcon, EditIcon, ExpandedIcon } from "outline-icons";
|
||||
import { CopyIcon, EditIcon, ExpandedIcon, TextWrapIcon } from "outline-icons";
|
||||
import type { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import {
|
||||
pluginKey as mermaidPluginKey,
|
||||
@@ -19,7 +20,10 @@ export default function codeMenuItems(
|
||||
readOnly: boolean | undefined,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node();
|
||||
const node =
|
||||
state.selection instanceof NodeSelection
|
||||
? state.selection.node
|
||||
: state.selection.$from.node();
|
||||
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
@@ -44,6 +48,9 @@ export default function codeMenuItems(
|
||||
]
|
||||
: remainingLangMenuItems;
|
||||
|
||||
const isEditingMermaid = !!(mermaidPluginKey.getState(state) as MermaidState)
|
||||
?.editingId;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "copyToClipboard",
|
||||
@@ -60,10 +67,17 @@ export default function codeMenuItems(
|
||||
name: "edit_mermaid",
|
||||
icon: <EditIcon />,
|
||||
tooltip: dictionary.editDiagram,
|
||||
visible:
|
||||
!(mermaidPluginKey.getState(state) as MermaidState)?.editingId &&
|
||||
isMermaid(node) &&
|
||||
!readOnly,
|
||||
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleCodeBlockWrap",
|
||||
icon: <TextWrapIcon />,
|
||||
tooltip: dictionary.wrapText,
|
||||
active: () => node.attrs.wrap,
|
||||
visible: !readOnly && (!isMermaid(node) || isEditingMermaid),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -123,6 +123,7 @@ export default function useDictionary() {
|
||||
uploadImage: t("Upload an image"),
|
||||
formattingControls: t("Formatting controls"),
|
||||
distributeColumns: t("Distribute columns"),
|
||||
wrapText: t("Wrap text"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
+10
-2
@@ -1,9 +1,10 @@
|
||||
import find from "lodash/find";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { IntegrationType, TeamPreference } from "@shared/types";
|
||||
import type Integration from "~/models/Integration";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import useStores from "./useStores";
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,7 @@ import useStores from "./useStores";
|
||||
*/
|
||||
export default function useEmbeds(loadIfMissing = false) {
|
||||
const { integrations } = useStores();
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchEmbedIntegrations() {
|
||||
@@ -31,6 +33,9 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
}
|
||||
}, [integrations, loadIfMissing]);
|
||||
|
||||
const disabledEmbeds =
|
||||
(team?.getPreference(TeamPreference.DisabledEmbeds) as string[]) || [];
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
embeds.map((e) => {
|
||||
@@ -42,8 +47,11 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
e.settings = integration.settings;
|
||||
}
|
||||
|
||||
e.disabled = disabledEmbeds.includes(e.id);
|
||||
|
||||
return e;
|
||||
}),
|
||||
[integrations.orderedData]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[integrations.orderedData, team?.preferences]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,9 +63,14 @@ window.addEventListener("keydown", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track whether defaultPrevented was already set by an external handler (e.g.
|
||||
// Radix UI's DismissableLayer) so we only break on preventDefault calls made
|
||||
// by our own callbacks.
|
||||
const wasDefaultPrevented = event.defaultPrevented;
|
||||
|
||||
// reverse so that the last registered callbacks get executed first
|
||||
for (const registered of callbacks.reverse()) {
|
||||
if (event.defaultPrevented === true) {
|
||||
for (const registered of [...callbacks].reverse()) {
|
||||
if (!wasDefaultPrevented && event.defaultPrevented) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
PlusIcon,
|
||||
InternetIcon,
|
||||
SmileyIcon,
|
||||
BuildingBlocksIcon,
|
||||
BrowserIcon,
|
||||
} from "outline-icons";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -32,7 +32,7 @@ import useStores from "./useStores";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
|
||||
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
|
||||
const APIAndAccess = lazy(() => import("~/scenes/Settings/APIAndAccess"));
|
||||
const Authentication = lazy(() => import("~/scenes/Settings/Authentication"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
@@ -48,6 +48,7 @@ const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
|
||||
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
|
||||
const CustomEmojis = lazy(() => import("~/scenes/Settings/CustomEmojis"));
|
||||
const Embeds = lazy(() => import("~/scenes/Settings/Embeds"));
|
||||
|
||||
export type ConfigItem = {
|
||||
name: string;
|
||||
@@ -107,13 +108,13 @@ const useSettingsConfig = () => {
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API & Apps"),
|
||||
path: settingsPath("api-and-apps"),
|
||||
component: APIAndApps.Component,
|
||||
preload: APIAndApps.preload,
|
||||
name: t("API & Access"),
|
||||
path: settingsPath("api-and-access"),
|
||||
component: APIAndAccess.Component,
|
||||
preload: APIAndAccess.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: BuildingBlocksIcon,
|
||||
icon: PadlockIcon,
|
||||
},
|
||||
// Workspace
|
||||
{
|
||||
@@ -175,7 +176,7 @@ const useSettingsConfig = () => {
|
||||
path: settingsPath("templates"),
|
||||
component: Templates.Component,
|
||||
preload: Templates.preload,
|
||||
enabled: can.createTemplate,
|
||||
enabled: can.readTemplate,
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
@@ -234,6 +235,18 @@ const useSettingsConfig = () => {
|
||||
icon: ExportIcon,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
name: t("Embeds"),
|
||||
path: integrationSettingsPath("embeds"),
|
||||
component: Embeds.Component,
|
||||
preload: Embeds.preload,
|
||||
description: t(
|
||||
"Configure which embed providers are available in the editor."
|
||||
),
|
||||
enabled: can.update,
|
||||
group: t("Integrations"),
|
||||
icon: BrowserIcon,
|
||||
},
|
||||
{
|
||||
name: `${t("Install")}…`,
|
||||
path: settingsPath("integrations"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type Template from "~/models/Template";
|
||||
import { ActionSeparator, createAction } from "~/actions";
|
||||
import {
|
||||
copyTemplate,
|
||||
createDocumentFromTemplate,
|
||||
deleteTemplate,
|
||||
moveTemplate,
|
||||
} from "~/actions/definitions/templates";
|
||||
@@ -49,6 +50,7 @@ export function useTemplateSettingsActions(
|
||||
}),
|
||||
moveTemplate,
|
||||
ActionSeparator,
|
||||
createDocumentFromTemplate,
|
||||
copyTemplate,
|
||||
ActionSeparator,
|
||||
deleteTemplate,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Returns the width of the window's vertical scrollbar in pixels, or null
|
||||
* if not yet measured. Continuously re-measures as the scrollbar appears or
|
||||
* disappears.
|
||||
*
|
||||
* @returns the scrollbar width, or null before measurement.
|
||||
*/
|
||||
export default function useWindowScrollbarWidth(): number | null {
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
const measure = () => {
|
||||
const scrollbarWidth = htmlElement.scrollWidth - htmlElement.clientWidth;
|
||||
setWidth(scrollbarWidth);
|
||||
};
|
||||
|
||||
// Defer initial measurement to after browser has painted
|
||||
const timeout = setTimeout(measure);
|
||||
|
||||
// Re-measure when html element resizes (scrollbar appears/disappears)
|
||||
const resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(htmlElement);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return width;
|
||||
}
|
||||
+2
-5
@@ -1,6 +1,6 @@
|
||||
// oxlint-disable-next-line import/no-unresolved
|
||||
import "vite/modulepreload-polyfill";
|
||||
import { LazyMotion } from "framer-motion";
|
||||
import { LazyMotion, domMax } from "framer-motion";
|
||||
import { KBarProvider } from "kbar";
|
||||
import { Provider } from "mobx-react";
|
||||
import { configure as configureMobx } from "mobx";
|
||||
@@ -45,9 +45,6 @@ configureMobx({
|
||||
isolateGlobalState: true,
|
||||
});
|
||||
|
||||
// Make sure to return the specific export containing the feature bundle.
|
||||
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
|
||||
|
||||
const commandBarOptions = {
|
||||
animations: {
|
||||
enterMs: 250,
|
||||
@@ -67,7 +64,7 @@ if (element) {
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<LazyMotion features={domMax}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
|
||||
@@ -33,7 +33,7 @@ function NewTemplateMenu() {
|
||||
name: collection.name,
|
||||
section: DocumentSection,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
visible: !!canCollection.createDocument,
|
||||
visible: !!canCollection.createTemplate,
|
||||
to: newTemplatePath(collection.id),
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
NavigationNodeType,
|
||||
type ProsemirrorData,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import type CollectionsStore from "~/stores/CollectionsStore";
|
||||
import type Document from "~/models/Document";
|
||||
@@ -125,13 +124,7 @@ export default class Collection extends ParanoidModel {
|
||||
* @returns boolean
|
||||
*/
|
||||
get isPrivate(): boolean {
|
||||
return !this.permission;
|
||||
}
|
||||
|
||||
/** Returns whether the collection description is not empty. */
|
||||
@computed
|
||||
get hasDescription(): boolean {
|
||||
return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false;
|
||||
return this.permission === null;
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -7,7 +7,6 @@ import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
import type RevisionsStore from "~/stores/RevisionsStore";
|
||||
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
class Revision extends ParanoidModel {
|
||||
@@ -97,11 +96,6 @@ class Revision extends ParanoidModel {
|
||||
: null;
|
||||
}
|
||||
|
||||
@computed
|
||||
get changeset() {
|
||||
return ChangesetHelper.getChangeset(this.data, this.before?.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a download of the revision in the specified format.
|
||||
*
|
||||
|
||||
+3
-3
@@ -114,10 +114,10 @@ class Team extends Model {
|
||||
/**
|
||||
* Set the value for a specific preference key.
|
||||
*
|
||||
* @param key The TeamPreference key to retrieve
|
||||
* @param value The value to set
|
||||
* @param key The TeamPreference key to set.
|
||||
* @param value The value to set.
|
||||
*/
|
||||
setPreference(key: TeamPreference, value: boolean) {
|
||||
setPreference<T extends TeamPreference>(key: T, value: TeamPreferences[T]) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
|
||||
@@ -9,6 +9,13 @@ interface HasData {
|
||||
data: ProsemirrorData;
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const serializer = extensionManager.serializer();
|
||||
|
||||
export class ProsemirrorHelper {
|
||||
/**
|
||||
* Returns the markdown representation of the document derived from the ProseMirror data.
|
||||
@@ -16,13 +23,6 @@ export class ProsemirrorHelper {
|
||||
* @returns The markdown representation of the document as a string.
|
||||
*/
|
||||
static toMarkdown = (document: HasData) => {
|
||||
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)
|
||||
@@ -40,11 +40,6 @@ export class ProsemirrorHelper {
|
||||
* @returns The plain text representation of the document as a string.
|
||||
*/
|
||||
static toPlainText = (document: HasData) => {
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -3,7 +3,9 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import first from "lodash/first";
|
||||
import { Suspense, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Heading from "~/components/Heading";
|
||||
import ContentEditable from "~/components/ContentEditable";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type Collection from "~/models/Collection";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -16,16 +18,32 @@ const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
|
||||
type Props = {
|
||||
/** The collection for which to render a header */
|
||||
collection: Collection;
|
||||
/** Whether the header is in editing mode */
|
||||
isEditing?: boolean;
|
||||
};
|
||||
|
||||
export const Header = observer(function Header_({ collection }: Props) {
|
||||
export const Header = observer(function Header_({
|
||||
collection,
|
||||
isEditing,
|
||||
}: Props) {
|
||||
const can = usePolicy(collection);
|
||||
const canEdit = can.update && isEditing;
|
||||
const handleIconChange = useCallback(
|
||||
(icon: string | null, color: string | null) =>
|
||||
collection?.save({ icon, color }),
|
||||
[collection]
|
||||
);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length > 0 && trimmed !== collection.name) {
|
||||
void collection.save({ name: trimmed });
|
||||
}
|
||||
},
|
||||
[collection]
|
||||
);
|
||||
|
||||
const fallbackIcon = collection ? (
|
||||
<CollectionIcon collection={collection} size={40} expanded />
|
||||
) : null;
|
||||
@@ -33,7 +51,7 @@ export const Header = observer(function Header_({ collection }: Props) {
|
||||
return (
|
||||
<StyledHeading>
|
||||
<IconTitleWrapper>
|
||||
{can.update ? (
|
||||
{canEdit ? (
|
||||
<Suspense fallback={fallbackIcon}>
|
||||
<IconPicker
|
||||
icon={collection.icon ?? "collection"}
|
||||
@@ -51,7 +69,16 @@ export const Header = observer(function Header_({ collection }: Props) {
|
||||
fallbackIcon
|
||||
)}
|
||||
</IconTitleWrapper>
|
||||
{collection.name}
|
||||
{canEdit ? (
|
||||
<ContentEditable
|
||||
value={collection.name}
|
||||
onChange={handleTitleChange}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
dir="auto"
|
||||
/>
|
||||
) : (
|
||||
collection.name
|
||||
)}
|
||||
</StyledHeading>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ function Overview({ collection, readOnly }: Props) {
|
||||
return (
|
||||
<>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{(collection.hasDescription || can.update) && (
|
||||
{(can.update || readOnly) && (
|
||||
<Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<MeasuredContainer name="document">
|
||||
<Editor
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const SharePopover = lazyWithRetry(
|
||||
@@ -64,6 +65,7 @@ function ShareButton({ collection }: Props) {
|
||||
minHeight={175}
|
||||
side="bottom"
|
||||
align="end"
|
||||
onEscapeKeyDown={preventDefault}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SharePopover
|
||||
|
||||
@@ -49,6 +49,7 @@ import Overview from "./components/Overview";
|
||||
import { Header } from "./components/Header";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
const CollectionScene = observer(function CollectionScene_() {
|
||||
const params = useParams<{ collectionSlug?: string }>();
|
||||
@@ -67,14 +68,17 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
const id = params.collectionSlug || "";
|
||||
const urlId = id.split("-").pop() ?? "";
|
||||
|
||||
const collection: Collection | null | undefined = collections.get(id);
|
||||
const collection = collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
const hasDescription = collection?.data
|
||||
? !ProsemirrorHelper.isEmptyData(collection.data)
|
||||
: false;
|
||||
|
||||
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
|
||||
|
||||
const [collectionTab, setCollectionTab] = usePersistedState<CollectionTab>(
|
||||
`collection-tab:${collection?.id}`,
|
||||
collection?.hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
|
||||
hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
|
||||
{
|
||||
listen: false,
|
||||
}
|
||||
@@ -130,7 +134,7 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const showOverview = can.update || collection?.hasDescription;
|
||||
const showOverview = can.update || hasDescription;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -172,7 +176,10 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
<Notices collection={collection} />
|
||||
<Header collection={collection} />
|
||||
<Header
|
||||
collection={collection}
|
||||
isEditing={isEditRoute && !!user?.separateEditMode}
|
||||
/>
|
||||
|
||||
<PinnedDocuments
|
||||
pins={pins}
|
||||
@@ -205,7 +212,9 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
{showOverview ? (
|
||||
<Overview
|
||||
collection={collection}
|
||||
readOnly={!isEditRoute && !!user?.separateEditMode}
|
||||
readOnly={
|
||||
!can.update || (!isEditRoute && !!user?.separateEditMode)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Redirect
|
||||
|
||||
@@ -15,6 +15,7 @@ import usePersistedState from "~/hooks/usePersistedState";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Switch from "~/components/Switch";
|
||||
import { action } from "mobx";
|
||||
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
|
||||
|
||||
/**
|
||||
* Changesets scene for developer playground.
|
||||
@@ -89,6 +90,10 @@ function Changesets() {
|
||||
const mockDiffRevision = stores.revisions.get("mock-diff-revision-" + id);
|
||||
const mockBeforeRevision = stores.revisions.get("mock-before-revision-" + id);
|
||||
const mockAfterRevision = stores.revisions.get("mock-after-revision-" + id);
|
||||
const changeset = ChangesetHelper.getChangeset(
|
||||
mockDiffRevision?.data,
|
||||
mockDiffRevision?.before?.data
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title="Changeset Playground" centered>
|
||||
@@ -155,9 +160,7 @@ function Changesets() {
|
||||
{showChangeset && (
|
||||
<>
|
||||
<Heading>Changeset</Heading>
|
||||
<Pre>
|
||||
{JSON.stringify(mockDiffRevision.changeset?.changes, null, 2)}
|
||||
</Pre>
|
||||
<Pre>{JSON.stringify(changeset?.changes, null, 2)}</Pre>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -312,25 +312,27 @@ function CommentForm({
|
||||
{highlightedText && (
|
||||
<HighlightedText>{highlightedText}</HighlightedText>
|
||||
)}
|
||||
<CommentEditor
|
||||
key={`${forceRender}`}
|
||||
ref={mergeRefs([editorRef, handleMounted])}
|
||||
defaultValue={draft}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onUpArrowAtStart={handleUpArrowAtStart}
|
||||
maxLength={CommentValidation.maxLength}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
// isNew is only the case for comments that exist in draft state,
|
||||
// they are marks in the document, but not yet saved to the db.
|
||||
(thread?.isNew
|
||||
? `${t("Add a comment")}…`
|
||||
: `${t("Add a reply")}…`)
|
||||
}
|
||||
/>
|
||||
<React.Suspense fallback={<div style={{ height: 24 }} />}>
|
||||
<CommentEditor
|
||||
key={`${forceRender}`}
|
||||
ref={mergeRefs([editorRef, handleMounted])}
|
||||
defaultValue={draft}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onUpArrowAtStart={handleUpArrowAtStart}
|
||||
maxLength={CommentValidation.maxLength}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
// isNew is only the case for comments that exist in draft state,
|
||||
// they are marks in the document, but not yet saved to the db.
|
||||
(thread?.isNew
|
||||
? `${t("Add a comment")}…`
|
||||
: `${t("Add a reply")}…`)
|
||||
}
|
||||
/>
|
||||
</React.Suspense>
|
||||
{(inputFocused || draft) && (
|
||||
<Flex justify="space-between" reverse={dir === "rtl"} gap={8}>
|
||||
<HStack>
|
||||
|
||||
@@ -27,7 +27,9 @@ import { resolveCommentFactory } from "~/actions/definitions/comments";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const CommentEditor = lazyWithRetry(() => import("./CommentEditor"));
|
||||
import { HighlightedText } from "./HighlightText";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
|
||||
@@ -231,16 +233,18 @@ function CommentThreadItem({
|
||||
<HighlightedText>{highlightedText}</HighlightedText>
|
||||
)}
|
||||
<Body ref={formRef} onSubmit={handleSubmit}>
|
||||
<StyledCommentEditor
|
||||
key={String(isEditing)}
|
||||
readOnly={!isEditing}
|
||||
value={comment.data}
|
||||
defaultValue={data}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
autoFocus
|
||||
/>
|
||||
<React.Suspense fallback={null}>
|
||||
<StyledCommentEditor
|
||||
key={String(isEditing)}
|
||||
readOnly={!isEditing}
|
||||
value={comment.data}
|
||||
defaultValue={data}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
autoFocus
|
||||
/>
|
||||
</React.Suspense>
|
||||
{isEditing && (
|
||||
<Flex align="flex-end" gap={8}>
|
||||
<ButtonSmall type="submit" borderOnHover>
|
||||
|
||||
@@ -31,7 +31,8 @@ import useMobile from "~/hooks/useMobile";
|
||||
function Comments() {
|
||||
const { ui, comments, documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const { editor, isEditorInitialized } = useDocumentContext();
|
||||
const { editor, isEditorInitialized, setFocusedCommentId } =
|
||||
useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
@@ -48,7 +49,7 @@ function Comments() {
|
||||
const isAtBottom = useRef(true);
|
||||
const [showJumpToRecentBtn, setShowJumpToRecentBtn] = useState(false);
|
||||
|
||||
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
|
||||
useKeyDown("Escape", () => document && ui.set({ rightSidebar: null }));
|
||||
|
||||
// Account for the resolved status of the comment changing
|
||||
useEffect(() => {
|
||||
@@ -203,7 +204,10 @@ function Comments() {
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
onClose={() => {
|
||||
ui.set({ rightSidebar: null });
|
||||
setFocusedCommentId(null);
|
||||
}}
|
||||
scrollable={false}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "~/utils/errors";
|
||||
import history from "~/utils/history";
|
||||
import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers";
|
||||
import useDocumentSidebar from "../hooks/useDocumentSidebar";
|
||||
import Loading from "./Loading";
|
||||
import MarkAsViewed from "./MarkAsViewed";
|
||||
|
||||
@@ -89,6 +90,8 @@ function DataLoader({ match, children }: Props) {
|
||||
const location = useLocation<LocationState>();
|
||||
const missingPolicy = !can || Object.keys(can).length === 0;
|
||||
|
||||
useDocumentSidebar();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchDocument() {
|
||||
try {
|
||||
|
||||
@@ -60,7 +60,12 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
pathname: documentPath(document as Document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
onClick={() => ui.toggleComments()}
|
||||
onClick={() =>
|
||||
ui.set({
|
||||
rightSidebar:
|
||||
ui.rightSidebar === "comments" ? null : "comments",
|
||||
})
|
||||
}
|
||||
>
|
||||
<CommentIcon size={18} />
|
||||
{commentsCount
|
||||
|
||||
@@ -25,7 +25,9 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import IconPicker from "~/components/IconPicker";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
|
||||
|
||||
type Props = {
|
||||
/** ID of the associated document */
|
||||
|
||||
@@ -101,7 +101,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
) {
|
||||
setFocusedCommentId(focusedComment.id);
|
||||
}
|
||||
ui.set({ commentsExpanded: true });
|
||||
ui.set({ rightSidebar: "comments" });
|
||||
}
|
||||
}, [focusedComment, ui, document.id, params]);
|
||||
|
||||
@@ -250,7 +250,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
commentingEnabled && can.comment ? handleRemoveComment : undefined
|
||||
}
|
||||
onOpenCommentsSidebar={
|
||||
commentingEnabled ? ui.toggleComments : undefined
|
||||
commentingEnabled
|
||||
? () => ui.set({ rightSidebar: "comments" })
|
||||
: undefined
|
||||
}
|
||||
onInit={handleInit}
|
||||
onDestroy={handleDestroy}
|
||||
|
||||
@@ -217,7 +217,13 @@ function History() {
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ height: "100%" }}
|
||||
style={{
|
||||
// When there are no items, drawer renders with a minimum height
|
||||
// and that height is retained when items are fetched and re-rendered.
|
||||
// To circumvent this, we force some `minHeight` here.
|
||||
minHeight: isMobile ? "70vh" : undefined,
|
||||
height: "100%",
|
||||
}}
|
||||
auto
|
||||
>
|
||||
<Empty>{t("No history yet")}</Empty>
|
||||
@@ -235,6 +241,7 @@ const Content = styled.div`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
border-radius: 8px;
|
||||
padding: 8px 8px 0;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(History);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||
import Diff from "@shared/editor/extensions/Diff";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import { type Editor as TEditor } from "~/editor";
|
||||
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
|
||||
|
||||
type Props = Omit<EditorProps, "extensions"> & {
|
||||
/** The ID of the revision */
|
||||
@@ -44,15 +45,18 @@ function RevisionViewer(props: Props, ref: React.Ref<TEditor>) {
|
||||
* Create editor extensions with the Diff extension configured to render
|
||||
* the calculated changes as decorations in the editor.
|
||||
*/
|
||||
const extensions = React.useMemo(
|
||||
() => [
|
||||
const extensions = React.useMemo(() => {
|
||||
const changeset = ChangesetHelper.getChangeset(
|
||||
revision.data,
|
||||
revision.before?.data
|
||||
);
|
||||
return [
|
||||
...withComments(richExtensions),
|
||||
...(showChanges && revision.changeset?.changes
|
||||
? [new Diff({ changes: revision.changeset?.changes })]
|
||||
...(showChanges && changeset?.changes
|
||||
? [new Diff({ changes: changeset?.changes })]
|
||||
: []),
|
||||
],
|
||||
[revision.changeset, showChanges]
|
||||
);
|
||||
];
|
||||
}, [revision.data, showChanges]);
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "~/components/primitives/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const SharePopover = lazyWithRetry(
|
||||
@@ -58,6 +59,7 @@ function ShareButton({ document }: Props) {
|
||||
minHeight={175}
|
||||
side="bottom"
|
||||
align="end"
|
||||
onEscapeKeyDown={preventDefault}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SharePopover
|
||||
|
||||
@@ -6,17 +6,18 @@ import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import { PortalContext } from "~/components/Portal";
|
||||
import { RightSidebarWrappedContext } from "~/components/RightSidebarContext";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
import RightSidebar from "~/components/Sidebar/Right";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import { PortalContext } from "~/components/Portal";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||
/* The title of the sidebar */
|
||||
@@ -24,7 +25,7 @@ type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||
/* The content of the sidebar */
|
||||
children: React.ReactNode;
|
||||
/* Called when the sidebar is closed */
|
||||
onClose: () => void;
|
||||
onClose?: () => void;
|
||||
/* Whether the sidebar should be scrollable */
|
||||
scrollable?: boolean;
|
||||
};
|
||||
@@ -32,6 +33,7 @@ type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||
function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const isWrapped = React.useContext(RightSidebarWrappedContext);
|
||||
const [drawerElement, setDrawerElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -43,17 +45,21 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
children
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
<Drawer onClose={onClose} defaultOpen>
|
||||
<DrawerContent ref={setDrawerElement}>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<PortalContext.Provider value={drawerElement}>
|
||||
{content}
|
||||
</PortalContext.Provider>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<RightSidebar>
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer onClose={onClose} defaultOpen>
|
||||
<DrawerContent ref={setDrawerElement}>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<PortalContext.Provider value={drawerElement}>
|
||||
{content}
|
||||
</PortalContext.Provider>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<Header>
|
||||
<Title>{title}</Title>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
@@ -66,8 +72,14 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
</Tooltip>
|
||||
</Header>
|
||||
{content}
|
||||
</RightSidebar>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isWrapped) {
|
||||
return inner;
|
||||
}
|
||||
|
||||
return <RightSidebar>{inner}</RightSidebar>;
|
||||
}
|
||||
|
||||
const ForwardIcon = styled(BackIcon)`
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Route, matchPath, useLocation } from "react-router-dom";
|
||||
import {
|
||||
RightSidebarWrappedContext,
|
||||
useSetRightSidebar,
|
||||
} from "~/components/RightSidebarContext";
|
||||
import RightSidebar from "~/components/Sidebar/Right";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug,
|
||||
} from "~/utils/routeHelpers";
|
||||
import SidebarLayout from "~/scenes/Document/components/SidebarLayout";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments/Comments")
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
|
||||
interface DocumentSidebarContentProps {
|
||||
skipInitialAnimation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable component that reads `ui.rightSidebar` and renders the appropriate
|
||||
* sidebar content. On desktop, wraps content in a single Right sidebar that
|
||||
* stays mounted across panel switches to avoid re-triggering the open/close
|
||||
* animation.
|
||||
*/
|
||||
const DocumentSidebarContent = observer(function DocumentSidebarContent({
|
||||
skipInitialAnimation,
|
||||
}: DocumentSidebarContentProps) {
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const inner = (
|
||||
<Route path={`/doc/${matchDocumentSlug}`}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<SidebarLayout title={<PlaceholderText width={100} />}>
|
||||
{null}
|
||||
</SidebarLayout>
|
||||
}
|
||||
>
|
||||
{ui.rightSidebar === "comments" && <DocumentComments />}
|
||||
{ui.rightSidebar === "history" && <DocumentHistory />}
|
||||
</React.Suspense>
|
||||
</Route>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return inner;
|
||||
}
|
||||
|
||||
return (
|
||||
<RightSidebar skipInitialAnimation={skipInitialAnimation}>
|
||||
<RightSidebarWrappedContext.Provider value={true}>
|
||||
{inner}
|
||||
</RightSidebarWrappedContext.Provider>
|
||||
</RightSidebar>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Manages the right sidebar for the Document scene. Syncs the history route
|
||||
* to store state, sets a stable component into the sidebar context when open,
|
||||
* and clears it when closed or on unmount.
|
||||
*/
|
||||
export default function useDocumentSidebar() {
|
||||
const { ui, documents } = useStores();
|
||||
const location = useLocation();
|
||||
const setSidebar = useSetRightSidebar();
|
||||
const isHistoryRoute = !!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
});
|
||||
const isOpen = ui.rightSidebar !== null;
|
||||
const isInitialOpenRef = React.useRef(isOpen);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isHistoryRoute) {
|
||||
ui.set({ rightSidebar: "history" });
|
||||
} else if (ui.rightSidebar === "history") {
|
||||
ui.set({ rightSidebar: null });
|
||||
}
|
||||
}, [isHistoryRoute, ui]);
|
||||
|
||||
// When the sidebar switches away from history while still on a /history URL,
|
||||
// update the URL to remove the /history suffix.
|
||||
React.useEffect(() => {
|
||||
if (isHistoryRoute && ui.rightSidebar !== "history") {
|
||||
const document = ui.activeDocumentId
|
||||
? documents.get(ui.activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
history.push(documentPath(document));
|
||||
}
|
||||
}
|
||||
}, [ui.rightSidebar, isHistoryRoute, ui.activeDocumentId, documents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSidebar(
|
||||
<DocumentSidebarContent
|
||||
skipInitialAnimation={isInitialOpenRef.current}
|
||||
/>
|
||||
);
|
||||
isInitialOpenRef.current = false;
|
||||
} else {
|
||||
setSidebar(null);
|
||||
}
|
||||
}, [isOpen, setSidebar]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
setSidebar(null);
|
||||
},
|
||||
[setSidebar]
|
||||
);
|
||||
}
|
||||
@@ -162,11 +162,11 @@ function Authorize() {
|
||||
<Text as="p" type="secondary">
|
||||
{t("Required OAuth parameters are missing")}
|
||||
<Pre>
|
||||
{missingParams.map((param) => (
|
||||
<>
|
||||
{missingParams.map((param: string) => (
|
||||
<span key={param}>
|
||||
{param}
|
||||
<br />
|
||||
</>
|
||||
</span>
|
||||
))}
|
||||
</Pre>
|
||||
</Text>
|
||||
|
||||
@@ -28,6 +28,7 @@ import usePaginatedRequest from "~/hooks/usePaginatedRequest";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { PaginationParams, SearchResult } from "~/types";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
import CollectionFilter from "./components/CollectionFilter";
|
||||
@@ -39,10 +40,12 @@ import SearchInput from "./components/SearchInput";
|
||||
import { SortInput } from "./components/SortInput";
|
||||
import UserFilter from "./components/UserFilter";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
function Search() {
|
||||
const { t } = useTranslation();
|
||||
const { documents, searches } = useStores();
|
||||
const isMobile = useMobile();
|
||||
|
||||
// routing
|
||||
const params = useQuery();
|
||||
@@ -184,6 +187,10 @@ function Search() {
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
updateLocation(ev.currentTarget.value);
|
||||
return;
|
||||
@@ -229,16 +236,23 @@ function Search() {
|
||||
const handleEscape = () => searchInputRef.current?.focus();
|
||||
const showEmpty = !loading && query && data?.length === 0;
|
||||
|
||||
const sortInput = filterVisibility.sort ? (
|
||||
<SortInput
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
onSelect={(sort, direction) => handleFilterChange({ sort, direction })}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Scene textTitle={query ? `${query} – ${t("Search")}` : t("Search")}>
|
||||
<Scene
|
||||
textTitle={query ? `${query} – ${t("Search")}` : t("Search")}
|
||||
actions={isMobile ? sortInput : null}
|
||||
>
|
||||
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
|
||||
{loading && <LoadingIndicator />}
|
||||
<ResultsWrapper column auto>
|
||||
<form
|
||||
method="GET"
|
||||
action={searchPath()}
|
||||
onSubmit={(ev) => ev.preventDefault()}
|
||||
>
|
||||
<form method="GET" action={searchPath()} onSubmit={preventDefault}>
|
||||
<SearchInput
|
||||
name="query"
|
||||
key={query ? "search" : "recent"}
|
||||
@@ -253,9 +267,8 @@ function Search() {
|
||||
onKeyDown={handleKeyDown}
|
||||
defaultValue={query ?? ""}
|
||||
/>
|
||||
|
||||
<Filters>
|
||||
<Flex align="center" gap={4}>
|
||||
<Flex align="center" gap={4} wrap>
|
||||
{filterVisibility.document && (
|
||||
<DocumentFilter
|
||||
document={document!}
|
||||
@@ -301,18 +314,11 @@ function Search() {
|
||||
handleFilterChange({ titleFilter: checked });
|
||||
}}
|
||||
checked={titleFilter}
|
||||
inForm={false}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{filterVisibility.sort && (
|
||||
<SortInput
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
onSelect={(sort, direction) =>
|
||||
handleFilterChange({ sort, direction })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isMobile ? null : sortInput}
|
||||
</Filters>
|
||||
</form>
|
||||
{isSearchable ? (
|
||||
@@ -412,9 +418,9 @@ const Filters = styled(HStack)`
|
||||
const SearchTitlesFilter = styled(Switch)`
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
height: 28px;
|
||||
`;
|
||||
|
||||
export default observer(Search);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -59,14 +58,10 @@ const StyledInput = styled.input`
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
border: 0;
|
||||
background: #14171f;
|
||||
background: ${s("inputBackground")};
|
||||
border-radius: 4px;
|
||||
color: ${s("text")};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
background: ${s("sidebarBackground")};
|
||||
`};
|
||||
|
||||
::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { BuildingBlocksIcon } from "outline-icons";
|
||||
import { PadlockIcon } from "outline-icons";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import type OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
|
||||
@@ -18,7 +18,7 @@ import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
import OAuthAuthenticationListItem from "./components/OAuthAuthenticationListItem";
|
||||
|
||||
function APIAndApps() {
|
||||
function APIAndAccess() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
@@ -28,8 +28,8 @@ function APIAndApps() {
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API & Apps")}
|
||||
icon={<BuildingBlocksIcon />}
|
||||
title={t("API & Access")}
|
||||
icon={<PadlockIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
@@ -44,7 +44,7 @@ function APIAndApps() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API & Apps")}</Heading>
|
||||
<Heading>{t("API & Access")}</Heading>
|
||||
<h2>{t("API keys")}</h2>
|
||||
{can.createApiKey ? (
|
||||
<Text as="p" type="secondary">
|
||||
@@ -98,4 +98,4 @@ function APIAndApps() {
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(APIAndApps);
|
||||
export default observer(APIAndAccess);
|
||||
@@ -0,0 +1,157 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { BrowserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import Heading from "~/components/Heading";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { IntegrationScene } from "./components/IntegrationScene";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
/** List of embed providers available for configuration. */
|
||||
const providers = embeds.filter((e) => e.id !== "embed");
|
||||
|
||||
function Embeds() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const showSuccessMessage = React.useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
toast.success(t("Settings saved"));
|
||||
}, 250),
|
||||
[t]
|
||||
);
|
||||
|
||||
const saveData = React.useCallback(
|
||||
async (newData: Record<string, unknown>) => {
|
||||
try {
|
||||
await team.save(newData);
|
||||
showSuccessMessage();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
},
|
||||
[team, showSuccessMessage]
|
||||
);
|
||||
|
||||
const handleDocumentEmbedsChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
await saveData({ documentEmbeds: checked });
|
||||
},
|
||||
[saveData]
|
||||
);
|
||||
|
||||
const handleToggleEmbed = React.useCallback(
|
||||
async (id: string, enabled: boolean) => {
|
||||
const disabledEmbeds =
|
||||
(team.getPreference(TeamPreference.DisabledEmbeds) as string[]) || [];
|
||||
|
||||
const updated = enabled
|
||||
? disabledEmbeds.filter((t) => t !== id)
|
||||
: [...disabledEmbeds, id];
|
||||
|
||||
team.setPreference(TeamPreference.DisabledEmbeds, updated);
|
||||
await saveData({
|
||||
preferences: { ...team.preferences },
|
||||
});
|
||||
},
|
||||
[team, saveData]
|
||||
);
|
||||
|
||||
const handleToggleAllEmbeds = React.useCallback(
|
||||
async (enabled: boolean) => {
|
||||
const updated = enabled ? [] : providers.map((e) => e.id);
|
||||
|
||||
team.setPreference(TeamPreference.DisabledEmbeds, updated);
|
||||
await saveData({
|
||||
preferences: { ...team.preferences },
|
||||
});
|
||||
},
|
||||
[team, saveData]
|
||||
);
|
||||
|
||||
const disabledEmbeds =
|
||||
(team.getPreference(TeamPreference.DisabledEmbeds) as string[]) || [];
|
||||
|
||||
return (
|
||||
<IntegrationScene title={t("Embeds")} icon={<BrowserIcon />}>
|
||||
<Heading>{t("Embeds")}</Heading>
|
||||
|
||||
<SettingRow
|
||||
label={t("Enabled")}
|
||||
name="documentEmbeds"
|
||||
description={t(
|
||||
"Allow supported providers to be inserted as interactive embeds in documents."
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
id="documentEmbeds"
|
||||
checked={team.documentEmbeds}
|
||||
onChange={handleDocumentEmbedsChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{team.documentEmbeds && (
|
||||
<>
|
||||
<Heading as="h2">{t("Providers")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Enabled providers will appear in the editor slash menu and embed
|
||||
automatically when a compatible link is pasted. Existing embeds in
|
||||
documents will continue to display regardless of these settings.
|
||||
</Trans>
|
||||
</Text>
|
||||
<SettingRow
|
||||
name="allEmbeds"
|
||||
label={t("All providers")}
|
||||
compact
|
||||
border={false}
|
||||
>
|
||||
<Switch
|
||||
id="allEmbeds"
|
||||
checked={disabledEmbeds.length === 0}
|
||||
onChange={handleToggleAllEmbeds}
|
||||
/>
|
||||
</SettingRow>
|
||||
{providers.map((embed) => {
|
||||
const enabled = !disabledEmbeds.includes(embed.id);
|
||||
return (
|
||||
<SettingRow
|
||||
key={embed.id}
|
||||
name={embed.title}
|
||||
label={
|
||||
<HStack
|
||||
style={{ filter: enabled ? "none" : "grayscale(100%)" }}
|
||||
>
|
||||
{embed.icon}
|
||||
<Text type={enabled ? undefined : "tertiary"}>
|
||||
{embed.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
compact
|
||||
>
|
||||
<Switch
|
||||
id={embed.id}
|
||||
checked={enabled}
|
||||
onChange={(checked: boolean) =>
|
||||
handleToggleEmbed(embed.id, checked)
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Embeds);
|
||||
@@ -10,7 +10,7 @@ import Text from "~/components/Text";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import IntegrationCard from "./components/IntegrationCard";
|
||||
import IntegrationCard, { Card } from "./components/IntegrationCard";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
@@ -62,6 +62,9 @@ function Integrations() {
|
||||
{groupedItems.available?.map((item) => (
|
||||
<IntegrationCard key={item.path} integration={item} />
|
||||
))}
|
||||
{groupedItems.available?.length % 2 === 1 && (
|
||||
<Card style={{ visibility: "hidden" }} />
|
||||
)}
|
||||
</Cards>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,6 @@ function Security() {
|
||||
|
||||
const [data, setData] = useState({
|
||||
sharing: team.sharing,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
defaultUserRole: team.defaultUserRole,
|
||||
memberCollectionCreate: team.memberCollectionCreate,
|
||||
memberTeamCreate: team.memberTeamCreate,
|
||||
@@ -107,13 +106,6 @@ function Security() {
|
||||
[saveData]
|
||||
);
|
||||
|
||||
const handleDocumentEmbedsChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
await saveData({ documentEmbeds: checked });
|
||||
},
|
||||
[saveData]
|
||||
);
|
||||
|
||||
const handlePasskeysEnabledChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
await saveData({ passkeysEnabled: checked });
|
||||
@@ -327,19 +319,6 @@ function Security() {
|
||||
onChange={handleMembersCanDeleteAccountChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("Rich service embeds")}
|
||||
name="documentEmbeds"
|
||||
description={t(
|
||||
"Links to supported services are shown as rich embeds within your documents"
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
id="documentEmbeds"
|
||||
checked={data.documentEmbeds}
|
||||
onChange={handleDocumentEmbedsChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("Email address visibility")}
|
||||
name={TeamPreference.EmailDisplay}
|
||||
|
||||
@@ -10,6 +10,7 @@ import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Button from "~/components/Button";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import Scene from "~/components/Scene";
|
||||
import { TemplateForm } from "~/components/Template/TemplateForm";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
@@ -29,7 +30,7 @@ const LoadingState = observer(function LoadingState() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { templates, ui } = useStores();
|
||||
const template = templates.get(id);
|
||||
const { request } = useRequest(() => templates.fetch(id));
|
||||
const { request, error } = useRequest(() => templates.fetch(id));
|
||||
|
||||
useEffect(() => {
|
||||
if (!template) {
|
||||
@@ -46,6 +47,10 @@ const LoadingState = observer(function LoadingState() {
|
||||
};
|
||||
}, [template, ui]);
|
||||
|
||||
if (error) {
|
||||
return <Error404 />;
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ function Templates() {
|
||||
icon={<ShapesIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createTemplate && (
|
||||
{can.readTemplate && (
|
||||
<Action>
|
||||
<NewTemplateMenu />
|
||||
</Action>
|
||||
|
||||
@@ -65,10 +65,10 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
{apiKey.scope && (
|
||||
<Tooltip
|
||||
content={apiKey.scope.map((s) => (
|
||||
<>
|
||||
<span key={s}>
|
||||
{s}
|
||||
<br />
|
||||
</>
|
||||
</span>
|
||||
))}
|
||||
>
|
||||
<Text type="tertiary"> · {t("Restricted scope")}</Text>
|
||||
|
||||
@@ -28,7 +28,7 @@ function IntegrationCard({ integration, isConnected }: Props) {
|
||||
</VStack>
|
||||
</VStack>
|
||||
<Button as="span" neutral>
|
||||
{isConnected ? t("Configure") : t("Connect")}
|
||||
{t("Configure")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -39,7 +39,7 @@ function IntegrationCard({ integration, isConnected }: Props) {
|
||||
|
||||
export default IntegrationCard;
|
||||
|
||||
const Card = styled.div`
|
||||
export const Card = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
||||
@@ -12,21 +12,18 @@ type Props = {
|
||||
name: string;
|
||||
visible?: boolean;
|
||||
border?: boolean;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const Row = styled(Flex)<{ $border?: boolean }>`
|
||||
display: block;
|
||||
padding: 22px 0;
|
||||
const Row = styled(Flex)<{ $border?: boolean; $compact?: boolean }>`
|
||||
padding: ${(props) => (props.$compact ? "12px 0" : "22px 0")};
|
||||
align-items: ${(props) => (props.$compact ? "center" : "initial")};
|
||||
border-bottom: 1px solid
|
||||
${(props) =>
|
||||
props.$border === false
|
||||
? "transparent"
|
||||
: transparentize(0.5, props.theme.divider)};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
@@ -39,11 +36,25 @@ const Column = styled.div`
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
min-width: 65%;
|
||||
min-width: 50%;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
min-width: 65%;
|
||||
`}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
min-width: 0;
|
||||
|
||||
> * {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
> * {
|
||||
align-self: initial;
|
||||
}
|
||||
`}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -60,6 +71,7 @@ const Label = styled(Text)`
|
||||
const SettingRow: React.FC<Props> = ({
|
||||
visible,
|
||||
description,
|
||||
compact,
|
||||
name,
|
||||
label,
|
||||
border,
|
||||
@@ -68,8 +80,9 @@ const SettingRow: React.FC<Props> = ({
|
||||
if (visible === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gap={32} $border={border}>
|
||||
<Row gap={32} $border={border} $compact={compact}>
|
||||
<Column>
|
||||
<Label as="h3">
|
||||
<label htmlFor={name}>{label}</label>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useTemplateSettingsActions } from "~/hooks/useTemplateSettingsActions";
|
||||
import TemplateMenu from "~/menus/TemplateMenu";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import history from "~/utils/history";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
|
||||
const ROW_HEIGHT = 50;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
@@ -54,7 +55,6 @@ const TemplateRowContextMenu = observer(function TemplateRowContextMenu({
|
||||
|
||||
export function TemplatesTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleOpen = (template: Template) => () => {
|
||||
history.push(template.path);
|
||||
@@ -81,21 +81,7 @@ export function TemplatesTable(props: Props) {
|
||||
header: t("Title"),
|
||||
accessor: (template) => template.titleWithDefault,
|
||||
component: (template) => (
|
||||
<ButtonLink onClick={handleOpen(template)}>
|
||||
<Flex align="center" gap={4}>
|
||||
{template.icon ? (
|
||||
<Icon
|
||||
value={template.icon}
|
||||
initial={template.initial}
|
||||
color={template.color || undefined}
|
||||
size={24}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon size={24} color={theme.textSecondary} />
|
||||
)}
|
||||
<Title>{template.titleWithDefault}</Title>
|
||||
</Flex>
|
||||
</ButtonLink>
|
||||
<TemplateLink template={template} onClick={handleOpen} />
|
||||
),
|
||||
width: "4fr",
|
||||
},
|
||||
@@ -155,6 +141,44 @@ export function TemplatesTable(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const TemplateLink = observer(
|
||||
({
|
||||
template,
|
||||
onClick,
|
||||
}: {
|
||||
template: Template;
|
||||
onClick: (template: Template) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const can = usePolicy(template);
|
||||
const content = (
|
||||
<Flex align="center" gap={4}>
|
||||
{template.icon ? (
|
||||
<Icon
|
||||
value={template.icon}
|
||||
initial={template.initial}
|
||||
color={template.color || undefined}
|
||||
size={24}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon size={24} color={theme.textSecondary} />
|
||||
)}
|
||||
{can.update ? (
|
||||
<Title>{template.titleWithDefault}</Title>
|
||||
) : (
|
||||
<Text>{template.titleWithDefault}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (!can.update) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <ButtonLink onClick={() => onClick(template)}>{content}</ButtonLink>;
|
||||
}
|
||||
);
|
||||
|
||||
const Permission = observer(({ template }: { template: Template }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
+7
-10
@@ -28,7 +28,7 @@ export enum SystemTheme {
|
||||
type PersistedData = Pick<
|
||||
UiStore,
|
||||
| "languagePromptDismissed"
|
||||
| "commentsExpanded"
|
||||
| "rightSidebar"
|
||||
| "theme"
|
||||
| "sidebarWidth"
|
||||
| "sidebarRightWidth"
|
||||
@@ -78,7 +78,7 @@ class UiStore {
|
||||
sidebarCollapsed = false;
|
||||
|
||||
@observable
|
||||
commentsExpanded = false;
|
||||
rightSidebar: "comments" | "history" | null = null;
|
||||
|
||||
@observable
|
||||
sidebarIsResizing = false;
|
||||
@@ -111,7 +111,7 @@ class UiStore {
|
||||
this.sidebarRightWidth =
|
||||
data.sidebarRightWidth || defaultTheme.sidebarRightWidth;
|
||||
this.tocVisible = data.tocVisible;
|
||||
this.commentsExpanded = !!data.commentsExpanded;
|
||||
this.rightSidebar = data.rightSidebar ?? null;
|
||||
this.theme = data.theme || Theme.System;
|
||||
|
||||
// system theme listeners
|
||||
@@ -340,11 +340,6 @@ class UiStore {
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
toggleComments = () => {
|
||||
this.set({ commentsExpanded: !this.commentsExpanded });
|
||||
};
|
||||
|
||||
@action
|
||||
toggleCollapsedSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
@@ -398,7 +393,9 @@ class UiStore {
|
||||
get readyToShow() {
|
||||
return (
|
||||
!this.rootStore.auth.user ||
|
||||
(this.rootStore.collections.isLoaded && this.rootStore.documents.isLoaded)
|
||||
(this.rootStore.collections.isLoaded &&
|
||||
this.rootStore.stars.isLoaded &&
|
||||
this.rootStore.userMemberships.isLoaded)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -433,7 +430,7 @@ class UiStore {
|
||||
sidebarWidth: this.sidebarWidth,
|
||||
sidebarRightWidth: this.sidebarRightWidth,
|
||||
languagePromptDismissed: this.languagePromptDismissed,
|
||||
commentsExpanded: this.commentsExpanded,
|
||||
rightSidebar: this.rightSidebar,
|
||||
theme: this.theme,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,15 @@ class UnfurlsStore extends Store<Unfurl<any>> {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
}): Promise<Unfurl<UnfurlType> | undefined> => {
|
||||
try {
|
||||
const protocol = new URL(url).protocol;
|
||||
if (protocol !== "http:" && protocol !== "https:" && protocol !== "mention:") {
|
||||
return;
|
||||
}
|
||||
} catch (_err) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unfurl = this.get(url);
|
||||
|
||||
if (unfurl) {
|
||||
@@ -74,7 +83,9 @@ class UnfurlsStore extends Store<Unfurl<any>> {
|
||||
data,
|
||||
} as Unfurl<UnfurlType>);
|
||||
} catch (err) {
|
||||
Logger.error(`Failed to unfurl url ${url}`, err);
|
||||
Logger.warn(`Failed to unfurl url ${url}`, {
|
||||
message: err.message,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
|
||||
Vendored
+1
@@ -159,6 +159,7 @@ declare module "styled-components" {
|
||||
titleBarDivider: string;
|
||||
inputBorder: string;
|
||||
inputBorderFocused: string;
|
||||
inputBackground: string;
|
||||
listItemHoverBackground: string;
|
||||
mentionBackground: string;
|
||||
mentionHoverBackground: string;
|
||||
|
||||
+20
-1
@@ -44,6 +44,9 @@ class ApiClient {
|
||||
|
||||
shareId?: string;
|
||||
|
||||
/** Map of in-flight POST requests for deduplication, keyed by path + body. */
|
||||
private inflightRequests = new Map<string, Promise<any>>();
|
||||
|
||||
constructor(options: Options = {}) {
|
||||
this.baseUrl = options.baseUrl || "/api";
|
||||
}
|
||||
@@ -280,7 +283,23 @@ class ApiClient {
|
||||
path: string,
|
||||
data?: JSONObject | FormData | undefined,
|
||||
options?: FetchOptions
|
||||
) => this.fetch<T>(path, "POST", data, options);
|
||||
): Promise<T> => {
|
||||
if (data instanceof FormData) {
|
||||
return this.fetch<T>(path, "POST", data, options);
|
||||
}
|
||||
|
||||
const key = `${path}:${JSON.stringify(data)}:${JSON.stringify(options)}`;
|
||||
const inflight = this.inflightRequests.get(key);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const promise = this.fetch<T>(path, "POST", data, options).finally(() => {
|
||||
this.inflightRequests.delete(key);
|
||||
});
|
||||
this.inflightRequests.set(key, promise);
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
export const client = new ApiClient();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Calls preventDefault on the event. Useful as a stable callback reference.
|
||||
*
|
||||
* @param event the event to prevent default on.
|
||||
*/
|
||||
export const preventDefault = (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import { domMax } from "framer-motion";
|
||||
|
||||
export default domMax;
|
||||
+5
-3
@@ -78,6 +78,7 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@linear/sdk": "^58.1.0",
|
||||
"@mermaid-js/layout-elk": "^0.2.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@node-oauth/oauth2-server": "^5.2.0",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
@@ -132,7 +133,7 @@
|
||||
"fetch-retry": "^5.0.6",
|
||||
"form-data": "^4.0.5",
|
||||
"fractional-index": "^1.0.0",
|
||||
"framer-motion": "^4.1.17",
|
||||
"framer-motion": "^6.5.1",
|
||||
"franc": "^6.2.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fuzzy-search": "^3.2.1",
|
||||
@@ -147,6 +148,7 @@
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
"iso-639-3": "^3.0.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsdom": "^22.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
@@ -177,7 +179,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^4.0.0",
|
||||
"outline-icons": "^4.1.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"pako": "^2.1.0",
|
||||
"passport": "^0.7.0",
|
||||
@@ -382,7 +384,7 @@
|
||||
"d3": "^7.0.0",
|
||||
"debug": "4.3.4",
|
||||
"node-fetch": "^2.7.0",
|
||||
"js-yaml": "^3.14.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"qs": "6.14.1",
|
||||
"prismjs": "1.30.0",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
|
||||
@@ -59,7 +59,7 @@ const AccessTokenResponseSchema = z.object({
|
||||
export class NotionClient {
|
||||
private client: Client;
|
||||
private limiter: ReturnType<typeof RateLimit>;
|
||||
private pageSize = 25;
|
||||
private pageSize = 100;
|
||||
private maxRetries = 3;
|
||||
private retryDelay = 1000;
|
||||
private skipChildrenForBlock = [
|
||||
|
||||
@@ -24,7 +24,8 @@ type ParsePageOutput = ImportTaskOutput[number] & {
|
||||
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
|
||||
private skippableErrorMessages = [
|
||||
"Database retrievals do not support linked databases",
|
||||
"does not contain any data sources accessible by this API bot", // error msg for linked database views
|
||||
"does not contain any data sources accessible by this API bot", // error msg for linked database views,
|
||||
"Databases with multiple data sources are not supported in this API version", // https://github.com/outline/outline/issues/11573#issuecomment-3993691460
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
BookmarkBlockObjectResponse,
|
||||
BreadcrumbBlockObjectResponse,
|
||||
BulletedListItemBlockObjectResponse,
|
||||
ChildDatabaseBlockObjectResponse,
|
||||
ChildPageBlockObjectResponse,
|
||||
DividerBlockObjectResponse,
|
||||
Heading1BlockObjectResponse,
|
||||
Heading2BlockObjectResponse,
|
||||
@@ -60,8 +62,11 @@ export class NotionConverter {
|
||||
const mapChild = (
|
||||
child: Block
|
||||
): ProsemirrorData | ProsemirrorData[] | undefined => {
|
||||
if (child.type === "child_page" || child.type === "child_database") {
|
||||
return; // this will be created as a nested page, no need to handle/convert.
|
||||
if (child.type === "child_page") {
|
||||
return this.child_page(child);
|
||||
}
|
||||
if (child.type === "child_database") {
|
||||
return this.child_database(child);
|
||||
}
|
||||
|
||||
// @ts-expect-error Not all blocks have an interface
|
||||
@@ -506,6 +511,42 @@ export class NotionConverter {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private static child_page(
|
||||
item: Block<ChildPageBlockObjectResponse>
|
||||
): ProsemirrorData {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
type: MentionType.Document,
|
||||
modelId: item.id,
|
||||
label: item.child_page.title,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static child_database(
|
||||
item: Block<ChildDatabaseBlockObjectResponse>
|
||||
): ProsemirrorData {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
type: MentionType.Document,
|
||||
modelId: item.id,
|
||||
label: item.child_database.title,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static link_to_page(item: LinkToPageBlockObjectResponse) {
|
||||
if (item.link_to_page.type !== "page_id") {
|
||||
return undefined;
|
||||
|
||||
@@ -275,6 +275,19 @@ exports[`NotionConverter converts a page 1`] = `
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"label": "Test database",
|
||||
"modelId": "1b32c2bb-bca8-80b7-9c46-e78bf6050c82",
|
||||
"type": "document",
|
||||
},
|
||||
"type": "mention",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"level": 2,
|
||||
@@ -288,6 +301,19 @@ exports[`NotionConverter converts a page 1`] = `
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"label": "Child page of all possible nodes",
|
||||
"modelId": "1b32c2bb-bca8-80a4-aece-c773a563395b",
|
||||
"type": "document",
|
||||
},
|
||||
"type": "mention",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"level": 2,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import multipart from "@server/middlewares/multipart";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import timeout from "@server/middlewares/timeout";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Attachment } from "@server/models";
|
||||
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
||||
@@ -30,6 +31,7 @@ router.post(
|
||||
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
||||
auth(),
|
||||
validate(T.FilesCreateSchema),
|
||||
timeout(30 * 60 * 1000), // 30 minutes for large file uploads
|
||||
multipart({
|
||||
maximumFileSize: Math.max(
|
||||
env.FILE_STORAGE_UPLOAD_MAX_SIZE,
|
||||
|
||||
@@ -717,6 +717,9 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Maximum number of bytes to read from webhook response bodies. */
|
||||
private static readonly MAX_RESPONSE_BODY_SIZE = 1024;
|
||||
|
||||
private async sendWebhook({
|
||||
event,
|
||||
subscription,
|
||||
@@ -731,8 +734,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
let response, requestBody, requestHeaders;
|
||||
let response: Awaited<ReturnType<typeof fetch>> | undefined;
|
||||
let requestBody, requestHeaders;
|
||||
let status: WebhookDeliveryStatus;
|
||||
let responseBody = "";
|
||||
|
||||
try {
|
||||
requestBody = presentWebhook({
|
||||
event,
|
||||
@@ -771,12 +777,25 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
status = "failed";
|
||||
}
|
||||
|
||||
if (response) {
|
||||
try {
|
||||
// TODO: Use stream to avoid buffering large responses in memory.
|
||||
const text = await response.text();
|
||||
responseBody = text.slice(0, DeliverWebhookTask.MAX_RESPONSE_BODY_SIZE);
|
||||
} catch (err) {
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Failed to read webhook response body: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await delivery.update({
|
||||
status,
|
||||
statusCode: response ? response.status : null,
|
||||
requestBody,
|
||||
requestHeaders,
|
||||
responseBody: response ? await response.text() : "",
|
||||
responseBody,
|
||||
responseHeaders: response
|
||||
? Object.fromEntries(response.headers.entries())
|
||||
: {},
|
||||
|
||||
@@ -320,4 +320,36 @@ describe("documentCreator", () => {
|
||||
expect(document.sourceMetadata).toEqual(sourceMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom emoji handling", () => {
|
||||
it("should not escape custom emoji UUIDs in text", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const customEmojiId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const text = `Check this :${customEmojiId}: custom emoji`;
|
||||
|
||||
const document = await withAPIContext(user, (ctx) =>
|
||||
documentCreator(ctx, {
|
||||
title: "Custom Emoji Test",
|
||||
text,
|
||||
collectionId: collection.id,
|
||||
})
|
||||
);
|
||||
|
||||
// The custom emoji should be preserved in the text without escaping
|
||||
expect(document.text).toContain(`:${customEmojiId}:`);
|
||||
expect(document.text).not.toContain(`\\:${customEmojiId}:`);
|
||||
expect(document.text).not.toContain(`\\:${customEmojiId}\\:`);
|
||||
|
||||
// The JSON content should include an emoji node with the UUID
|
||||
expect(document.content).toBeDefined();
|
||||
const contentStr = JSON.stringify(document.content);
|
||||
expect(contentStr).toContain('"type":"emoji"');
|
||||
expect(contentStr).toContain(`"data-name":"${customEmojiId}"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,10 +118,10 @@ export const createSubscriptionsForDocument = async (
|
||||
document: Document,
|
||||
event: DocumentEvent | RevisionEvent
|
||||
): Promise<void> => {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const users = await document.collaborators({ transaction });
|
||||
const users = await document.collaborators();
|
||||
|
||||
for (const user of users) {
|
||||
for (const user of users) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await subscriptionCreator({
|
||||
ctx: createContext({
|
||||
user,
|
||||
@@ -133,6 +133,6 @@ export const createSubscriptionsForDocument = async (
|
||||
event: SubscriptionType.Document,
|
||||
resubscribe: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -173,6 +173,13 @@ export default async function userProvisioner(
|
||||
);
|
||||
});
|
||||
|
||||
if (avatarUrl && !existingUser.getFlag(UserFlag.AvatarUpdated)) {
|
||||
await new UploadUserAvatarTask().schedule({
|
||||
userId: existingUser.id,
|
||||
avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (isInvite) {
|
||||
const inviter = await existingUser.$get("invitedBy");
|
||||
if (inviter) {
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { User } from "@server/models";
|
||||
import type { APIContext } from "@server/types";
|
||||
import { AuthenticationType } from "@server/types";
|
||||
|
||||
/**
|
||||
* Factory to create a new API context.
|
||||
*/
|
||||
export function createContext({
|
||||
user,
|
||||
authType = AuthenticationType.APP,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import data from "@emoji-mart/data";
|
||||
import type { EmojiMartData } from "@emoji-mart/data";
|
||||
import { Schema } from "prosemirror-model";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import { populateEmojiData } from "@shared/editor/lib/emoji";
|
||||
import {
|
||||
basicExtensions,
|
||||
richExtensions,
|
||||
@@ -7,6 +10,8 @@ import {
|
||||
} from "@shared/editor/nodes";
|
||||
import Mention from "@shared/editor/nodes/Mention";
|
||||
|
||||
populateEmojiData(data as EmojiMartData);
|
||||
|
||||
const extensions = withComments(richExtensions);
|
||||
export const extensionManager = new ExtensionManager(extensions);
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export class Mailer {
|
||||
* @returns Message ID header from SMTP server
|
||||
*/
|
||||
sendMail = async (data: SendMailOptions): Promise<void> => {
|
||||
const { transporter } = this;
|
||||
const transporter = this.transporter;
|
||||
|
||||
if (env.isDevelopment) {
|
||||
Logger.debug(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user