mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f06eda36b | |||
| 2dcfe4be0c | |||
| 8b56b47eb0 | |||
| b20f70da42 | |||
| 315992d55b | |||
| 8427778c46 | |||
| fd4dab23f2 | |||
| edcdb6f8c0 | |||
| 41a5097240 | |||
| bc248dc190 | |||
| f3eec09125 | |||
| afb849ac98 | |||
| 9b67d55f76 | |||
| b792945d01 | |||
| 7c8ba7d2c1 | |||
| 54a90b05a8 | |||
| 3e38164366 | |||
| f28ce8f0cd |
@@ -119,11 +119,6 @@ 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' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
|
||||
if: ${{ needs.changes.outputs.app == '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.createTemplate
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
to: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
|
||||
@@ -201,6 +201,48 @@ 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.
|
||||
*
|
||||
@@ -940,7 +982,7 @@ export const printDocument = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: () => {
|
||||
setTimeout(window.print, 0);
|
||||
queueMicrotask(window.print);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1008,7 +1050,7 @@ export const createTemplateFromDocument = createAction({
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createTemplate
|
||||
stores.policies.abilities(activeCollectionId).updateDocument
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
@@ -1339,7 +1381,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.set({ rightSidebar: "comments" });
|
||||
stores.ui.toggleComments();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,13 +18,7 @@ import {
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
settingsPath,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { newDocumentPath, newTemplatePath, urlify } from "~/utils/routeHelpers";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
@@ -63,7 +57,6 @@ export const deleteTemplate = createAction({
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await template.delete();
|
||||
history.push(settingsPath("templates"));
|
||||
toast.success(t("Template deleted"));
|
||||
}}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
@@ -224,7 +217,7 @@ export const printTemplate = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
|
||||
perform: () => {
|
||||
setTimeout(window.print, 0);
|
||||
queueMicrotask(window.print);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
matchPath,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
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";
|
||||
@@ -16,6 +23,8 @@ import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
@@ -23,6 +32,12 @@ 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")
|
||||
);
|
||||
@@ -33,7 +48,9 @@ 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();
|
||||
@@ -75,20 +92,50 @@ 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>
|
||||
<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>
|
||||
<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>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,9 +23,12 @@ const Container = styled.div<Props>`
|
||||
type ContentProps = { $maxWidth?: string };
|
||||
|
||||
const Content = styled.div<ContentProps>`
|
||||
max-width: ${(props: ContentProps) =>
|
||||
props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
max-width: ${(props) => props.$maxWidth ?? "46em"};
|
||||
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
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -22,7 +22,6 @@ 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";
|
||||
@@ -59,7 +58,6 @@ 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);
|
||||
@@ -161,7 +159,7 @@ function DocumentListItem(
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && !isMobile && <StarButton document={document} />}
|
||||
{canStar && <StarButton document={document} />}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -8,7 +7,6 @@ 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";
|
||||
@@ -21,15 +19,16 @@ 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 }: Props,
|
||||
{ title, children, sidebar, sidebarRight }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
const sidebarRight = useRightSidebarContent();
|
||||
|
||||
return (
|
||||
<Container column auto ref={ref}>
|
||||
@@ -62,7 +61,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
<AnimatePresence initial={false}>{sidebarRight}</AnimatePresence>
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -62,6 +61,11 @@ export const ContextMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -76,7 +80,7 @@ export const ContextMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
|
||||
@@ -13,7 +13,6 @@ 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,
|
||||
@@ -99,6 +98,11 @@ export const DropdownMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
@@ -125,7 +129,7 @@ export const DropdownMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
|
||||
@@ -42,6 +42,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
id={item.id}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -94,11 +95,13 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
return (
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
id={item.id}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<SubMenuContent
|
||||
id={item.id}
|
||||
ref={parentRef}
|
||||
onFocusOutside={preventCloseHandler}
|
||||
>
|
||||
|
||||
@@ -103,7 +103,6 @@ const StyledLink = styled(Link)`
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
pointer-events: none;
|
||||
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
@@ -20,55 +20,6 @@ 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;
|
||||
@@ -98,10 +49,16 @@ function Notifications(
|
||||
[t]
|
||||
);
|
||||
|
||||
const filteredNotifications = useStableOrderedNotifications(
|
||||
notifications.active,
|
||||
filter
|
||||
);
|
||||
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 unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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,7 +16,6 @@ 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";
|
||||
|
||||
@@ -29,112 +28,73 @@ 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 (!query) {
|
||||
setSearchResults(undefined);
|
||||
return;
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
setOpen(true);
|
||||
}
|
||||
}, [searchResults, query]);
|
||||
|
||||
const cached = cacheRef.current.get(query);
|
||||
if (cached !== undefined) {
|
||||
setSearchResults(cached);
|
||||
if (cached.length) {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
// Clear search results when the query changes to prevent stale results
|
||||
React.useEffect(() => {
|
||||
setSearchResults(undefined);
|
||||
}, [query]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({
|
||||
query: searchQuery,
|
||||
offset = 0,
|
||||
...options
|
||||
}: Record<string, any>) => {
|
||||
if (!searchQuery?.length) {
|
||||
return undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
return undefined;
|
||||
},
|
||||
[documents, shareId]
|
||||
);
|
||||
|
||||
const debouncedSetQuery = React.useMemo(
|
||||
const handleSearchInputChange = React.useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setQuery(value);
|
||||
setOpen(!!value);
|
||||
}, 250),
|
||||
[]
|
||||
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
const trimmedValue = value.trim();
|
||||
setQuery(trimmedValue);
|
||||
setOpen(!!trimmedValue);
|
||||
}, 300),
|
||||
[cachedQuery]
|
||||
);
|
||||
|
||||
const handleSearchInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debouncedSetQuery(event.target.value.trim());
|
||||
},
|
||||
[debouncedSetQuery]
|
||||
);
|
||||
|
||||
React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const handleEscapeList = React.useCallback(
|
||||
() => searchInputRef.current?.focus(),
|
||||
[]
|
||||
() => searchInputRef?.current?.focus(),
|
||||
[searchInputRef]
|
||||
);
|
||||
|
||||
const handleSearchInputFocus = React.useCallback(() => {
|
||||
focusRef.current = searchInputRef.current;
|
||||
}, []);
|
||||
}, [searchInputRef]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -146,7 +106,6 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (searchResults) {
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||
@@ -157,12 +116,12 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (atEnd) {
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
if (open || atEnd) {
|
||||
ev.preventDefault();
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
@@ -172,17 +131,21 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
|
||||
if (ev.currentTarget.value) {
|
||||
if (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();
|
||||
if (ev.key === "Escape") {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[open, searchResults]
|
||||
@@ -190,12 +153,11 @@ 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 (
|
||||
@@ -231,7 +193,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
align="start"
|
||||
shrink
|
||||
onEscapeKeyDown={handleEscapeList}
|
||||
onOpenAutoFocus={preventDefault}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target === searchInputRef.current) {
|
||||
@@ -241,13 +203,8 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{
|
||||
query,
|
||||
snippetMinWords: 10,
|
||||
snippetMaxWords: 11,
|
||||
limit: 10,
|
||||
}}
|
||||
items={searchResults}
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
onEscape={handleEscapeList}
|
||||
empty={
|
||||
@@ -261,7 +218,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ref={index === 0 ? firstSearchItem : undefined}
|
||||
document={item.document}
|
||||
context={item.context}
|
||||
highlight={query}
|
||||
highlight={cachedQuery}
|
||||
onClick={handleSearchItemClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -89,11 +89,7 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
const members = React.useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
Array.from(
|
||||
new Map(
|
||||
document.members.map((memberUser) => [memberUser.id, memberUser])
|
||||
).values()
|
||||
),
|
||||
document.members,
|
||||
(memberUser) =>
|
||||
(invitedInSession.includes(memberUser.id) ? "_" : "") +
|
||||
memberUser.name.toLocaleLowerCase(),
|
||||
@@ -128,19 +124,12 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(
|
||||
new Map(
|
||||
groupMemberships
|
||||
.inDocument(document.id)
|
||||
.map((membership) => [membership.group.id, membership])
|
||||
).values()
|
||||
)
|
||||
{groupMemberships
|
||||
.inDocument(document.id)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(
|
||||
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
|
||||
)
|
||||
).localeCompare(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
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion)}
|
||||
key={suggestion.id}
|
||||
onClick={() => removePendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
@@ -218,8 +218,8 @@ export const Suggestions = observer(
|
||||
...suggestionsWithPending.map((suggestion) => (
|
||||
<ListItem
|
||||
keyboardNavigation
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion as User)}
|
||||
key={suggestion.id}
|
||||
onClick={() => addPendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
|
||||
@@ -8,23 +8,19 @@ 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, skipInitialAnimation }: Props) {
|
||||
function Right({ children, border, className }: 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) => {
|
||||
@@ -71,20 +67,16 @@ function Right({ children, border, className, skipInitialAnimation }: Props) {
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: windowScrollbarWidth
|
||||
? `${ui.sidebarRightWidth - windowScrollbarWidth}px`
|
||||
: `${ui.sidebarRightWidth}px`,
|
||||
width: `${ui.sidebarRightWidth}px`,
|
||||
}),
|
||||
[ui.sidebarRightWidth, windowScrollbarWidth]
|
||||
[ui.sidebarRightWidth]
|
||||
);
|
||||
|
||||
const animationProps = {
|
||||
initial: skipInitialAnimation
|
||||
? false
|
||||
: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
initial: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
animate: {
|
||||
transition: isResizing
|
||||
? { duration: 0 }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -47,7 +48,7 @@ function SharedSidebar({ share }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar canCollapse={false}>
|
||||
<StyledSidebar $hoverTransition={!teamAvailable} canCollapse={false}>
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
@@ -89,7 +90,7 @@ function SharedSidebar({ share }: Props) {
|
||||
)}
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
</Sidebar>
|
||||
</StyledSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,4 +113,33 @@ 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,7 +28,6 @@ interface Props extends Omit<
|
||||
disabled?: boolean;
|
||||
/** Callback when the switch state changes */
|
||||
onChange?: (checked: boolean) => void;
|
||||
inForm?: boolean;
|
||||
}
|
||||
|
||||
function Switch(
|
||||
@@ -36,7 +35,6 @@ function Switch(
|
||||
width = 32,
|
||||
height = 18,
|
||||
labelPosition = "left",
|
||||
inForm = true,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
@@ -73,7 +71,7 @@ function Switch(
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Wrapper $inForm={inForm}>
|
||||
<Wrapper>
|
||||
<Label
|
||||
disabled={disabled}
|
||||
htmlFor={props.id}
|
||||
@@ -102,8 +100,8 @@ function Switch(
|
||||
return component;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div<{ $inForm?: boolean }>`
|
||||
padding-bottom: ${(props) => (props.$inForm ? 8 : 0)}px;
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
${undraggableOnDesktop()}
|
||||
`;
|
||||
|
||||
|
||||
@@ -95,13 +95,6 @@ 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();
|
||||
@@ -119,7 +112,6 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</TabButton>
|
||||
@@ -148,7 +140,6 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayoutGroup } from "framer-motion";
|
||||
import { AnimateSharedLayout } 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 (
|
||||
<LayoutGroup>
|
||||
<AnimateSharedLayout>
|
||||
<Sticky>
|
||||
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
|
||||
{children}
|
||||
</Nav>
|
||||
</Sticky>
|
||||
</LayoutGroup>
|
||||
</AnimateSharedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
|
||||
collections.orderedData.reduce<Option[]>((memo, collection) => {
|
||||
const canCollection = policies.abilities(collection.id);
|
||||
|
||||
if (canCollection.createTemplate) {
|
||||
if (canCollection.createDocument) {
|
||||
memo.push({
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
|
||||
@@ -23,24 +23,29 @@ const DrawerHandle = DrawerPrimitive.Handle;
|
||||
/** Drawer's content - renders the overlay and the actual content. */
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
|
||||
$hidden?: boolean;
|
||||
}
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
const { children, $hidden, ...rest } = props;
|
||||
const [measureRef, bounds] = useMeasure();
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Portal>
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
{!$hidden && (
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
)}
|
||||
<DrawerPrimitive.Content ref={ref} asChild>
|
||||
<StyledContent
|
||||
$hidden={$hidden}
|
||||
animate={{
|
||||
height: bounds.height,
|
||||
transition: { bounce: 0, duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<StyledInnerContent column ref={measureRef} {...rest}>
|
||||
<StyledInnerContent ref={measureRef} {...rest}>
|
||||
{children}
|
||||
</StyledInnerContent>
|
||||
</StyledContent>
|
||||
@@ -58,9 +63,9 @@ const DrawerTitle = React.forwardRef<
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<StyledText size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
{children}
|
||||
</StyledText>
|
||||
</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -75,12 +80,8 @@ const DrawerTitle = React.forwardRef<
|
||||
});
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(m.div)`
|
||||
const StyledContent = styled(m.div)<{ $hidden?: boolean }>`
|
||||
z-index: ${depths.menu};
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -94,9 +95,11 @@ const StyledContent = styled(m.div)`
|
||||
border-radius: 6px;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
|
||||
${({ $hidden }) => $hidden && "display: none;"}
|
||||
`;
|
||||
|
||||
const StyledInnerContent = styled(Flex)`
|
||||
const StyledInnerContent = styled.div`
|
||||
padding: 6px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
|
||||
type MenuVariant = "dropdown" | "context";
|
||||
type MenuVariant = "dropdown" | "context" | "inline";
|
||||
|
||||
const MenuContext = createContext<{
|
||||
type MenuContextType = {
|
||||
variant: MenuVariant;
|
||||
}>({
|
||||
activeSubmenu: string | null;
|
||||
setActiveSubmenu: (id: string | null) => void;
|
||||
submenuTriggerRefs: Record<string, RefObject<HTMLDivElement>>;
|
||||
addSubmenuTriggerRef: (id: string, ref: RefObject<HTMLDivElement>) => void;
|
||||
submenuContentRefs: Record<string, RefObject<HTMLDivElement | null>>;
|
||||
addSubmenuContentRef: (
|
||||
id: string,
|
||||
ref: RefObject<HTMLDivElement | null>
|
||||
) => void;
|
||||
mainMenuRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const MenuContext = createContext<MenuContextType>({
|
||||
variant: "dropdown",
|
||||
activeSubmenu: null,
|
||||
setActiveSubmenu: () => {},
|
||||
submenuTriggerRefs: {},
|
||||
addSubmenuTriggerRef: () => {},
|
||||
submenuContentRefs: {},
|
||||
addSubmenuContentRef: () => {},
|
||||
mainMenuRef: { current: null },
|
||||
});
|
||||
|
||||
export function MenuProvider({
|
||||
@@ -15,7 +42,54 @@ export function MenuProvider({
|
||||
variant: MenuVariant;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ctx = useMemo(() => ({ variant }), [variant]);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const [submenuTriggerRefs, setSubmenuTriggerRefs] = useState<
|
||||
Record<string, RefObject<HTMLDivElement>>
|
||||
>({});
|
||||
const [submenuContentRefs, setSubmenuContentRefs] = useState<
|
||||
Record<string, RefObject<HTMLDivElement | null>>
|
||||
>({});
|
||||
const mainMenuRef = useRef<HTMLDivElement>(null);
|
||||
const addSubmenuTriggerRef = useCallback(
|
||||
(key: string, ref: RefObject<HTMLDivElement>) => {
|
||||
setSubmenuTriggerRefs((prevRefs) => ({
|
||||
...prevRefs,
|
||||
[key]: ref,
|
||||
}));
|
||||
},
|
||||
[setSubmenuTriggerRefs]
|
||||
);
|
||||
const addSubmenuContentRef = useCallback(
|
||||
(key: string, ref: RefObject<HTMLDivElement | null>) => {
|
||||
setSubmenuContentRefs((prevRefs) => ({
|
||||
...prevRefs,
|
||||
[key]: ref,
|
||||
}));
|
||||
},
|
||||
[setSubmenuContentRefs]
|
||||
);
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({
|
||||
variant,
|
||||
activeSubmenu,
|
||||
setActiveSubmenu,
|
||||
submenuTriggerRefs,
|
||||
addSubmenuTriggerRef,
|
||||
submenuContentRefs,
|
||||
addSubmenuContentRef,
|
||||
mainMenuRef,
|
||||
}),
|
||||
[
|
||||
variant,
|
||||
activeSubmenu,
|
||||
mainMenuRef,
|
||||
submenuTriggerRefs,
|
||||
addSubmenuTriggerRef,
|
||||
submenuContentRefs,
|
||||
addSubmenuContentRef,
|
||||
]
|
||||
);
|
||||
|
||||
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -3,18 +3,31 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import * as Components from "../components/Menu";
|
||||
import type { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { useMenuContext } from "./MenuContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { Drawer, DrawerContent } from "../Drawer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Portal as ReactPortal } from "~/components/Portal";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import { MenuType } from "@shared/editor/types";
|
||||
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
|
||||
import { useEditor } from "~/editor/components/EditorContext";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
|
||||
type MenuProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Root
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
|
||||
|
||||
const Menu = ({ children, ...rest }: MenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const Root =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Root
|
||||
@@ -31,6 +44,10 @@ type SubMenuProps = React.ComponentPropsWithoutRef<
|
||||
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
const Sub =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Sub
|
||||
@@ -68,16 +85,77 @@ MenuTrigger.displayName = "MenuTrigger";
|
||||
type ContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Content
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
|
||||
pos?: {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
};
|
||||
|
||||
const MenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>
|
||||
| HTMLDivElement,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { variant, mainMenuRef, activeSubmenu } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
const { view } = useEditor();
|
||||
|
||||
const { children, ...rest } = props;
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
const contentProps = {
|
||||
maxHeightVar: "--radix-dropdown-menu-content-available-height",
|
||||
transformOriginVar: "--radix-dropdown-menu-content-transform-origin",
|
||||
};
|
||||
const { pos } = props;
|
||||
|
||||
return isMobile ? (
|
||||
<Drawer
|
||||
open={true}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeMenu(view);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DrawerContent $hidden={!!activeSubmenu} {...rest}>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<ReactPortal>
|
||||
<InlineMenuContentWrapper
|
||||
ref={(node) => {
|
||||
// Set the main menu ref for submenu positioning
|
||||
if (mainMenuRef) {
|
||||
(
|
||||
mainMenuRef as React.MutableRefObject<HTMLElement | null>
|
||||
).current = node;
|
||||
}
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
|
||||
node;
|
||||
}
|
||||
}}
|
||||
{...contentProps}
|
||||
{...rest}
|
||||
hiddenScrollbars
|
||||
style={{
|
||||
top: pos?.top,
|
||||
left: pos?.left,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InlineMenuContentWrapper>
|
||||
</ReactPortal>
|
||||
);
|
||||
}
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Portal
|
||||
@@ -120,11 +198,45 @@ type SubMenuTriggerProps = BaseItemProps &
|
||||
|
||||
const SubMenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>
|
||||
| HTMLDivElement,
|
||||
SubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
const { variant, setActiveSubmenu, addSubmenuTriggerRef } = useMenuContext();
|
||||
const { label, icon, disabled, id, ...rest } = props;
|
||||
const triggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobile();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (id && triggerRef.current) {
|
||||
addSubmenuTriggerRef(id, triggerRef);
|
||||
}
|
||||
}, [triggerRef, id, addSubmenuTriggerRef]);
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return (
|
||||
<Components.MenuSubTrigger
|
||||
ref={triggerRef}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled && id && isMobile) {
|
||||
setActiveSubmenu(id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled && id && !isMobile) {
|
||||
setActiveSubmenu(id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel style={{ marginRight: 20 }}>
|
||||
{label}
|
||||
</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuSubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
@@ -143,6 +255,12 @@ const SubMenuTrigger = React.forwardRef<
|
||||
});
|
||||
SubMenuTrigger.displayName = "SubMenuTrigger";
|
||||
|
||||
const MARGIN_RIGHT_FOR_UX = 20; // Margin for better UX
|
||||
const NESTED_OFFSET_LEFT = 95; // Offset for nested submenu when it renders on the left
|
||||
const TOP_OFFSET_LEFT = 75; // Offset for top submenu when it renders on the left
|
||||
const NESTED_OFFSET_RIGHT = 75; // Offset for nested submenu when it renders on the right
|
||||
const TOP_OFFSET_RIGHT = 65; // Offset for top submenu when it renders on the right
|
||||
|
||||
type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.SubContent
|
||||
> &
|
||||
@@ -150,11 +268,166 @@ type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
|
||||
const SubMenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>
|
||||
| HTMLDivElement,
|
||||
SubMenuContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
const submenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const {
|
||||
variant,
|
||||
activeSubmenu,
|
||||
submenuTriggerRefs,
|
||||
submenuContentRefs,
|
||||
addSubmenuContentRef,
|
||||
mainMenuRef,
|
||||
setActiveSubmenu,
|
||||
} = useMenuContext();
|
||||
const { children, id, ...rest } = props;
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
||||
const isMobile = useMobile();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (id) {
|
||||
addSubmenuContentRef(id, submenuRef);
|
||||
}
|
||||
}, [id, addSubmenuContentRef]);
|
||||
|
||||
const handleClickOutside = React.useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
const isInsideDescendant =
|
||||
id &&
|
||||
Object.entries(submenuContentRefs).some(
|
||||
([refId, contentRef]) =>
|
||||
refId !== id &&
|
||||
refId.startsWith(id + "-") &&
|
||||
contentRef.current?.contains(event.target as Node)
|
||||
);
|
||||
if (isInsideDescendant) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk up the id hierarchy to find the deepest ancestor submenu containing the click.
|
||||
let targetSubmenu: string | null = null;
|
||||
if (id) {
|
||||
const parts = id.split("-");
|
||||
for (let len = parts.length - 1; len >= 2; len--) {
|
||||
const ancestorId = parts.slice(0, len).join("-");
|
||||
const ancestorRef = submenuContentRefs[ancestorId];
|
||||
if (ancestorRef?.current?.contains(event.target as Node)) {
|
||||
targetSubmenu = ancestorId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveSubmenu(targetSubmenu);
|
||||
},
|
||||
[id, submenuContentRefs, setActiveSubmenu]
|
||||
);
|
||||
|
||||
// the submenu drawer handles its own click outside logic
|
||||
useOnClickOutside(submenuRef, isMobile ? undefined : handleClickOutside);
|
||||
|
||||
React.useEffect(() => {
|
||||
const trigger = submenuTriggerRefs[id ?? ""];
|
||||
|
||||
if (trigger?.current) {
|
||||
const triggerRect = trigger.current.getBoundingClientRect();
|
||||
const parentId = id ? getParentSubmenuId(id) : null;
|
||||
const anchorRect = (
|
||||
parentId ? submenuContentRefs[parentId]?.current : mainMenuRef.current
|
||||
)?.getBoundingClientRect();
|
||||
const subMenuRect = submenuRef.current?.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
const spaceOnRight = viewportWidth - triggerRect.right;
|
||||
const anchorWidth = anchorRect?.width;
|
||||
const submenuWidth = subMenuRect?.width;
|
||||
|
||||
const offsetLeft = parentId ? NESTED_OFFSET_LEFT : TOP_OFFSET_LEFT;
|
||||
const offsetRight = parentId ? NESTED_OFFSET_RIGHT : TOP_OFFSET_RIGHT;
|
||||
|
||||
let left = triggerRect.left - offsetLeft;
|
||||
|
||||
// Check if there's enough space on the right
|
||||
if (
|
||||
submenuWidth &&
|
||||
anchorWidth &&
|
||||
spaceOnRight < submenuWidth + MARGIN_RIGHT_FOR_UX
|
||||
) {
|
||||
left = triggerRect.left - submenuWidth - anchorWidth - offsetRight;
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: triggerRect.top,
|
||||
left,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
variant,
|
||||
activeSubmenu,
|
||||
submenuTriggerRefs,
|
||||
mainMenuRef,
|
||||
id,
|
||||
submenuContentRefs,
|
||||
]);
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
const isVisible =
|
||||
activeSubmenu === id ||
|
||||
(id !== undefined && activeSubmenu?.startsWith(id + "-"));
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentProps = {
|
||||
maxHeightVar: "--inline-menu-max-height",
|
||||
transformOriginVar: "--inline-menu-transform-origin",
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
if (activeSubmenu !== id) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenuDrawer
|
||||
setActiveSubmenu={setActiveSubmenu}
|
||||
submenuRef={submenuRef}
|
||||
forwardedRef={ref}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</SubMenuDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactPortal>
|
||||
<InlineMenuContentWrapper
|
||||
ref={(node) => {
|
||||
submenuRef.current = node;
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
|
||||
node;
|
||||
}
|
||||
}}
|
||||
{...contentProps}
|
||||
{...rest}
|
||||
hiddenScrollbars
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
zIndex: 1001,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InlineMenuContentWrapper>
|
||||
</ReactPortal>
|
||||
);
|
||||
}
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
@@ -203,7 +476,8 @@ type MenuGroupProps = {
|
||||
|
||||
const MenuGroup = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>
|
||||
| HTMLDivElement,
|
||||
MenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
@@ -224,6 +498,7 @@ const MenuGroup = React.forwardRef<
|
||||
MenuGroup.displayName = "MenuGroup";
|
||||
|
||||
type BaseItemProps = {
|
||||
id?: string;
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
@@ -248,7 +523,9 @@ const MenuButton = React.forwardRef<
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuButtonProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { variant, activeSubmenu, setActiveSubmenu } = useMenuContext();
|
||||
const { view } = useEditor();
|
||||
const [active, setActive] = React.useState(false);
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
@@ -260,28 +537,63 @@ const MenuButton = React.forwardRef<
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon size={18} /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
const button = (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
setActive(true);
|
||||
if (props.id) {
|
||||
// Close any nested submenu that is deeper than this button's parent level.
|
||||
const parentId = getParentSubmenuId(props.id);
|
||||
if (activeSubmenu && activeSubmenu !== parentId) {
|
||||
setActiveSubmenu(parentId);
|
||||
}
|
||||
} else if (activeSubmenu) {
|
||||
setActiveSubmenu(null);
|
||||
}
|
||||
}, [setActive, props.id, activeSubmenu, setActiveSubmenu]);
|
||||
|
||||
const button =
|
||||
variant === MenuType.inline ? (
|
||||
<Components.MenuButton
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
$active={active}
|
||||
onClick={(e) => {
|
||||
onClick(e);
|
||||
closeMenu(view);
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon size={18} /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
{buttonContent}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
) : (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuButton
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
>
|
||||
{buttonContent}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
@@ -375,11 +687,16 @@ type MenuSeparatorProps = React.ComponentPropsWithoutRef<
|
||||
|
||||
const MenuSeparator = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>
|
||||
| HTMLDivElement,
|
||||
MenuSeparatorProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return <Components.MenuSeparator ref={ref as React.Ref<HTMLHRElement>} />;
|
||||
}
|
||||
|
||||
const Separator =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Separator
|
||||
@@ -419,6 +736,82 @@ const MenuLabel = React.forwardRef<
|
||||
});
|
||||
MenuLabel.displayName = "MenuLabel";
|
||||
|
||||
const DRAWER_ANIMATION_DURATION_MS = 300;
|
||||
|
||||
type SubMenuDrawerProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
setActiveSubmenu: (id: string | null) => void;
|
||||
submenuRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
forwardedRef: React.ForwardedRef<HTMLDivElement>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenuDrawer = ({
|
||||
setActiveSubmenu,
|
||||
submenuRef,
|
||||
forwardedRef,
|
||||
children,
|
||||
...rest
|
||||
}: SubMenuDrawerProps) => {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
const { view } = useEditor();
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
setIsOpen(false);
|
||||
// Let slide-down animation play out before tearing down the tree.
|
||||
setTimeout(() => {
|
||||
setActiveSubmenu(null);
|
||||
closeMenu(view);
|
||||
}, DRAWER_ANIMATION_DURATION_MS);
|
||||
}
|
||||
},
|
||||
[setActiveSubmenu, view]
|
||||
);
|
||||
|
||||
useOnClickOutside(submenuRef, () => handleOpenChange(false));
|
||||
|
||||
return (
|
||||
<Drawer open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent
|
||||
ref={(node) => {
|
||||
submenuRef.current = node;
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(node);
|
||||
} else if (forwardedRef) {
|
||||
(
|
||||
forwardedRef as React.MutableRefObject<HTMLDivElement | null>
|
||||
).current = node;
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
const getParentSubmenuId = (id: string): string | null => {
|
||||
const parts = id.split("-");
|
||||
return parts.length > 2 ? parts.slice(0, -1).join("-") : null;
|
||||
};
|
||||
|
||||
const closeMenu = (view: EditorView) => {
|
||||
collapseSelection()(view.state, view.dispatch);
|
||||
};
|
||||
|
||||
const InlineMenuContentWrapper = styled(Components.MenuContent)`
|
||||
position: absolute;
|
||||
height: fit-content;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
// Styled scrollable for mobile drawer content
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export {
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
|
||||
@@ -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);
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ 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};
|
||||
@@ -108,6 +107,25 @@ export const MenuExternalLink = styled.a`
|
||||
|
||||
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
|
||||
${BaseMenuItemCSS}
|
||||
|
||||
${(props) =>
|
||||
!props.disabled &&
|
||||
`
|
||||
&:hover {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.$dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg:not([data-fixed-color]) {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const MenuSeparator = styled.hr`
|
||||
|
||||
@@ -20,7 +20,6 @@ function BlockMenu(props: Props) {
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
disclosure={options.disclosure}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
|
||||
@@ -39,6 +39,7 @@ 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);
|
||||
|
||||
@@ -36,14 +36,16 @@ const defaultPosition = {
|
||||
visible: false,
|
||||
};
|
||||
|
||||
function usePosition({
|
||||
export function usePosition({
|
||||
menuRef,
|
||||
active,
|
||||
align = "center",
|
||||
inline = false,
|
||||
}: {
|
||||
menuRef: React.RefObject<HTMLDivElement>;
|
||||
active?: boolean;
|
||||
align?: Props["align"];
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
@@ -90,19 +92,12 @@ function usePosition({
|
||||
} as DOMRect);
|
||||
|
||||
// position at the top right of code blocks
|
||||
const isCodeNodeSelection =
|
||||
selection instanceof NodeSelection && isCode(selection.node);
|
||||
const codeBlock = isCodeNodeSelection
|
||||
? { pos: selection.from, node: selection.node }
|
||||
: findParentNode(isCode)(view.state.selection);
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
const noticeBlock = findParentNode(
|
||||
(node) => node.type.name === "container_notice"
|
||||
)(view.state.selection);
|
||||
|
||||
if (
|
||||
(codeBlock || noticeBlock) &&
|
||||
(view.state.selection.empty || isCodeNodeSelection)
|
||||
) {
|
||||
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
|
||||
const position = codeBlock
|
||||
? codeBlock.pos
|
||||
: noticeBlock
|
||||
@@ -127,13 +122,14 @@ function usePosition({
|
||||
selection instanceof ColumnSelection && selection.isColSelection();
|
||||
const isRowSelection =
|
||||
selection instanceof RowSelection && selection.isRowSelection();
|
||||
let colWidth = 0;
|
||||
|
||||
if (isTableSelected(view.state)) {
|
||||
const rect = selectedRect(view.state);
|
||||
const table = view.domAtPos(rect.tableStart);
|
||||
const bounds = (table.node as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top - 16;
|
||||
selectionBounds.left = bounds.left - 10;
|
||||
selectionBounds.top = bounds.top - (inline ? 160 : 16);
|
||||
selectionBounds.left = bounds.left;
|
||||
selectionBounds.right = bounds.left - 10;
|
||||
} else if (isColSelection) {
|
||||
const rect = selectedRect(view.state);
|
||||
@@ -143,6 +139,7 @@ function usePosition({
|
||||
);
|
||||
if (element instanceof HTMLElement) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
colWidth = bounds.width;
|
||||
selectionBounds.top = bounds.top - 16;
|
||||
selectionBounds.left = bounds.left;
|
||||
selectionBounds.right = bounds.right;
|
||||
@@ -155,8 +152,8 @@ function usePosition({
|
||||
);
|
||||
if (element instanceof HTMLElement) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.left - 10;
|
||||
selectionBounds.top = bounds.top + (inline ? 55 : 0);
|
||||
selectionBounds.left = bounds.left - (inline ? 410 : 10);
|
||||
selectionBounds.right = bounds.left - 10;
|
||||
}
|
||||
}
|
||||
@@ -205,11 +202,13 @@ function usePosition({
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
isColSelection && colWidth < 300
|
||||
? selectionBounds.right + margin
|
||||
: align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
)
|
||||
);
|
||||
const top = Math.max(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import { Menu } from "~/components/primitives/Menu";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MenuContent } from "~/components/primitives/Menu";
|
||||
import { toMenuItems } from "~/components/Menu/transformer";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { mapMenuItems } from "./ToolbarMenu";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePosition } from "./FloatingToolbar";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
type Props = {
|
||||
items: MenuItem[];
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
/*
|
||||
* Renders an inline menu in the floating toolbar, which does not require a trigger.
|
||||
*/
|
||||
const InlineMenu: React.FC<Props> = ({ items, containerRef }) => {
|
||||
const { t } = useTranslation();
|
||||
const { commands, view } = useEditor();
|
||||
const fallbackRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuRef = containerRef || fallbackRef;
|
||||
const isMobile = useMobile();
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
|
||||
const position = usePosition({
|
||||
menuRef,
|
||||
active: true,
|
||||
inline: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const menuRect = menuRef.current?.getBoundingClientRect();
|
||||
|
||||
let left = position.left;
|
||||
if (menuRef.current && menuRect) {
|
||||
const spaceOnRight = viewportWidth - left;
|
||||
if (spaceOnRight < menuRect.right) {
|
||||
left = left - spaceOnRight; // double the space on the right
|
||||
}
|
||||
}
|
||||
|
||||
setPos((prevPos) => {
|
||||
if (prevPos.top !== position.top || prevPos.left !== left) {
|
||||
return {
|
||||
top: position.top,
|
||||
left,
|
||||
};
|
||||
}
|
||||
return prevPos;
|
||||
});
|
||||
}, [menuRef, position]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
ev.stopImmediatePropagation();
|
||||
}, []);
|
||||
|
||||
const mappedItems = useMemo(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
const children =
|
||||
typeof item.children === "function" ? item.children() : item.children;
|
||||
|
||||
return {
|
||||
...item,
|
||||
children: children
|
||||
? mapMenuItems(children, commands, view.state)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
[items, commands, view.state]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<MenuProvider variant="inline">
|
||||
<Menu>
|
||||
<MenuContent
|
||||
pos={pos}
|
||||
align="end"
|
||||
aria-label={t("Options")}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<EventBoundary>
|
||||
{mappedItems.map((item) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{toMenuItems(item.children || [])}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</EventBoundary>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
|
||||
return isMobile ? content : <Portal>{content}</Portal>;
|
||||
};
|
||||
|
||||
export default InlineMenu;
|
||||
@@ -82,153 +82,154 @@ 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
|
||||
? 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,
|
||||
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,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
type: MentionType.Document,
|
||||
modelId: uuidv4(),
|
||||
actorId,
|
||||
label: user.name,
|
||||
label: search,
|
||||
},
|
||||
}) 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,
|
||||
])
|
||||
: [];
|
||||
} as MentionItem,
|
||||
])
|
||||
: [];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
|
||||
@@ -67,10 +67,7 @@ function useItems({
|
||||
|
||||
const singleUrl =
|
||||
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
|
||||
const matchedEmbed = singleUrl
|
||||
? getMatchingEmbed(embeds, singleUrl)?.embed
|
||||
: null;
|
||||
const embed = matchedEmbed?.disabled ? null : matchedEmbed;
|
||||
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
|
||||
|
||||
// Check embeddability for single URL
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getRowIndex,
|
||||
isTableSelected,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MenuType, type MenuItem } from "@shared/editor/types";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -40,6 +40,9 @@ import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import InlineMenu from "./InlineMenu";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** Whether the text direction is right-to-left */
|
||||
@@ -240,10 +243,7 @@ export function SelectionToolbar(props: Props) {
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
|
||||
if (
|
||||
isCodeSelection &&
|
||||
(selection.empty || selection instanceof NodeSelection)
|
||||
) {
|
||||
if (isCodeSelection && selection.empty) {
|
||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||
align = "end";
|
||||
} else if (isTableSelected(state)) {
|
||||
@@ -272,6 +272,8 @@ export function SelectionToolbar(props: Props) {
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
}
|
||||
|
||||
const isInline = items[0].type === MenuType.inline;
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
items = items.filter((item) => {
|
||||
if (item.name === "separator") {
|
||||
@@ -318,6 +320,14 @@ export function SelectionToolbar(props: Props) {
|
||||
setActiveToolbar(null);
|
||||
};
|
||||
|
||||
if (isInline && items.length) {
|
||||
return (
|
||||
<InlineMenuWrapper ref={menuRef}>
|
||||
<InlineMenu items={items} containerRef={menuRef} />
|
||||
</InlineMenuWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
@@ -362,3 +372,20 @@ export function SelectionToolbar(props: Props) {
|
||||
</FloatingToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
const InlineMenuWrapper = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
line-height: 0;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -6,26 +6,21 @@ import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import styled 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 { s } from "@shared/styles";
|
||||
import { depths, 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";
|
||||
@@ -34,6 +29,38 @@ 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;
|
||||
@@ -53,7 +80,6 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
index: number,
|
||||
options: {
|
||||
selected: boolean;
|
||||
disclosure?: boolean;
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
) => React.ReactNode;
|
||||
@@ -66,65 +92,23 @@ 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(() => {
|
||||
@@ -137,21 +121,81 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSubmenu(null);
|
||||
const calculatePosition = React.useCallback(
|
||||
(props: Props) => {
|
||||
if (!props.isActive) {
|
||||
return defaultPosition;
|
||||
}
|
||||
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedIndex(0);
|
||||
setInsertItem(undefined);
|
||||
}, [props.isActive]);
|
||||
// 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),
|
||||
};
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setSubmenu(null);
|
||||
}, [props.search]);
|
||||
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]
|
||||
);
|
||||
|
||||
const handleClearSearch = React.useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -182,6 +226,26 @@ 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;
|
||||
@@ -397,7 +461,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const embedItems: EmbedDescriptor[] = [];
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title && embed.visible !== false && !embed.disabled) {
|
||||
if (embed.title && embed.visible !== false) {
|
||||
embedItems.push(
|
||||
new EmbedDescriptor({
|
||||
...embed,
|
||||
@@ -417,47 +481,11 @@ 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;
|
||||
}
|
||||
@@ -486,7 +514,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return matchesSearch(item);
|
||||
return (
|
||||
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.keywords || "").toLocaleLowerCase().includes(searchInput)
|
||||
);
|
||||
});
|
||||
|
||||
return filterExcessSeparators(
|
||||
@@ -509,40 +541,18 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
);
|
||||
}, [commands, props]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 handleMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
!menuRef.current ||
|
||||
menuRef.current.contains(event.target as Element)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
@@ -551,109 +561,18 @@ 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) {
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (children?.length) {
|
||||
openSubmenu(selectedIndex);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
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) ||
|
||||
@@ -717,16 +636,18 @@ 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, insertItem, openSubmenu, props, selectedIndex, submenu]);
|
||||
}, [close, filtered, handleClickItem, props, selectedIndex]);
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
@@ -754,23 +675,6 @@ 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;
|
||||
|
||||
@@ -789,10 +693,6 @@ 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) &&
|
||||
@@ -808,22 +708,6 @@ 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 = () => {
|
||||
@@ -838,37 +722,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const handleOnClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (hasChildren) {
|
||||
openSubmenu(index);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
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>
|
||||
@@ -922,152 +792,37 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
);
|
||||
<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
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
{fileInput}
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`;
|
||||
@@ -1084,13 +839,6 @@ 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`
|
||||
@@ -1112,4 +860,61 @@ 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,7 +5,6 @@ import styled from "styled-components";
|
||||
import { usePortalContext } from "~/components/Portal";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuDisclosure,
|
||||
MenuIconWrapper,
|
||||
MenuLabel,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
@@ -27,8 +26,6 @@ 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({
|
||||
@@ -40,7 +37,6 @@ function SuggestionsMenuItem({
|
||||
subtitle,
|
||||
shortcut,
|
||||
icon,
|
||||
disclosure,
|
||||
}: Props) {
|
||||
const portal = usePortalContext();
|
||||
const ref = React.useCallback(
|
||||
@@ -79,7 +75,6 @@ function SuggestionsMenuItem({
|
||||
)}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuLabel>
|
||||
{disclosure && <MenuDisclosure />}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,67 +48,10 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands[menuItem.name]) {
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
|
||||
children.map((child) => {
|
||||
if (child.name === "separator") {
|
||||
return { type: "separator", visible: child.visible };
|
||||
}
|
||||
if ("content" in child) {
|
||||
return {
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
visible: child.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapChildren(resolvedChildren),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected:
|
||||
child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedItemChildren = resolveChildren(item.children);
|
||||
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
|
||||
return resolvedItemChildren
|
||||
? mapMenuItems(resolvedItemChildren, commands, state)
|
||||
: [];
|
||||
}, [isOpen, commands]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
@@ -220,6 +163,78 @@ function ToolbarMenu(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
export const mapMenuItems = (
|
||||
children: MenuItem[],
|
||||
commands: Record<string, Function>,
|
||||
state: any,
|
||||
parentId = "0"
|
||||
): TMenuItem[] => {
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands[menuItem.name]) {
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return children.map((child, idx) => {
|
||||
const id = `${parentId}-${idx}`;
|
||||
|
||||
if (child.name === "separator") {
|
||||
return { id, type: "separator", visible: child.visible };
|
||||
}
|
||||
|
||||
if ("content" in child) {
|
||||
return {
|
||||
id,
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
id,
|
||||
type: "submenu",
|
||||
title: child.label || child.tooltip,
|
||||
icon: child.icon,
|
||||
visible: child.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapMenuItems(resolvedChildren, commands, state, id),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected: child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const FlexibleWrapper = styled.div`
|
||||
color: ${s("textSecondary")};
|
||||
overflow: hidden;
|
||||
|
||||
@@ -55,10 +55,12 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
Decoration.widget(
|
||||
parent.pos,
|
||||
() => {
|
||||
button.onclick = action(() => {
|
||||
this.state.query = "";
|
||||
this.state.open = true;
|
||||
});
|
||||
button.addEventListener(
|
||||
"click",
|
||||
action(() => {
|
||||
this.state.open = true;
|
||||
})
|
||||
);
|
||||
return button;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,8 +45,9 @@ export default class SelectionToolbarExtension extends Extension {
|
||||
}
|
||||
|
||||
if (
|
||||
isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)
|
||||
(isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)) &&
|
||||
selection.from > 0
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
+18
-5
@@ -692,14 +692,19 @@ 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;
|
||||
}
|
||||
|
||||
@@ -713,7 +718,10 @@ export class Editor extends React.PureComponent<
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, attrs);
|
||||
markRemoved = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -731,8 +739,13 @@ 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
|
||||
);
|
||||
@@ -745,6 +758,7 @@ export class Editor extends React.PureComponent<
|
||||
...attrs,
|
||||
});
|
||||
tr.removeMark(from, to, mark).addMark(from, to, newMark);
|
||||
markUpdated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -760,7 +774,10 @@ export class Editor extends React.PureComponent<
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, newAttrs);
|
||||
markUpdated = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -894,11 +911,7 @@ const EditorContainer = styled(Styles)<{
|
||||
css`
|
||||
span#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
text-decoration: underline 2px ${props.theme.commentMarkBackground};
|
||||
|
||||
* {
|
||||
background: transparent !important;
|
||||
}
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
}
|
||||
a#comment-${props.focusedCommentId}
|
||||
~ span.component-image
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CopyIcon, EditIcon, ExpandedIcon, TextWrapIcon } from "outline-icons";
|
||||
import { CopyIcon, EditIcon, ExpandedIcon } 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,
|
||||
@@ -20,10 +19,7 @@ export default function codeMenuItems(
|
||||
readOnly: boolean | undefined,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const node =
|
||||
state.selection instanceof NodeSelection
|
||||
? state.selection.node
|
||||
: state.selection.$from.node();
|
||||
const node = state.selection.$from.node();
|
||||
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
@@ -48,9 +44,6 @@ export default function codeMenuItems(
|
||||
]
|
||||
: remainingLangMenuItems;
|
||||
|
||||
const isEditingMermaid = !!(mermaidPluginKey.getState(state) as MermaidState)
|
||||
?.editingId;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "copyToClipboard",
|
||||
@@ -67,17 +60,10 @@ export default function codeMenuItems(
|
||||
name: "edit_mermaid",
|
||||
icon: <EditIcon />,
|
||||
tooltip: dictionary.editDiagram,
|
||||
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleCodeBlockWrap",
|
||||
icon: <TextWrapIcon />,
|
||||
tooltip: dictionary.wrapText,
|
||||
active: () => node.attrs.wrap,
|
||||
visible: !readOnly && (!isMermaid(node) || isEditingMermaid),
|
||||
visible:
|
||||
!(mermaidPluginKey.getState(state) as MermaidState)?.editingId &&
|
||||
isMermaid(node) &&
|
||||
!readOnly,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
+37
-31
@@ -7,7 +7,7 @@ import {
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { TableLayout } from "@shared/editor/types";
|
||||
import { MenuType, TableLayout } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function tableMenuItems(
|
||||
@@ -26,36 +26,42 @@ export default function tableMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setTableAttr",
|
||||
tooltip: isFullWidth
|
||||
? dictionary.alignDefaultWidth
|
||||
: dictionary.alignFullWidth,
|
||||
icon: <AlignFullWidthIcon />,
|
||||
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
|
||||
active: () => isFullWidth,
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
tooltip: dictionary.distributeColumns,
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteTable",
|
||||
tooltip: dictionary.deleteTable,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
tooltip: dictionary.exportAsCSV,
|
||||
label: "CSV",
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
type: MenuType.inline,
|
||||
children: [
|
||||
{
|
||||
name: "setTableAttr",
|
||||
label: isFullWidth
|
||||
? dictionary.alignDefaultWidth
|
||||
: dictionary.alignFullWidth,
|
||||
icon: <AlignFullWidthIcon />,
|
||||
attrs: isFullWidth
|
||||
? { layout: null }
|
||||
: { layout: TableLayout.fullWidth },
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
label: dictionary.distributeColumns,
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
label: dictionary.exportAsCSV,
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteTable",
|
||||
dangerous: true,
|
||||
label: dictionary.deleteTable,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+130
-108
@@ -5,7 +5,6 @@ import {
|
||||
AlignCenterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
@@ -24,7 +23,11 @@ import {
|
||||
isMultipleCellSelection,
|
||||
tableHasRowspan,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import {
|
||||
MenuType,
|
||||
type MenuItem,
|
||||
type NodeAttrMark,
|
||||
} from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
@@ -88,119 +91,138 @@ export default function tableColMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: dictionary.alignLeft,
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: dictionary.alignCenter,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: dictionary.alignRight,
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortAsc,
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortDesc,
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : colColors.size === 1 ? (
|
||||
<CircleIcon color={colColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
type: MenuType.inline,
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => colColors.size === 1 && colColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.align,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleColumnBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.alignLeft,
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.alignCenter,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.alignRight,
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
label: dictionary.sort,
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
children: [
|
||||
{
|
||||
name: "sortTable",
|
||||
label: dictionary.sortAsc,
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
label: dictionary.sortDesc,
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
label: dictionary.background,
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : colColors.size === 1 ? (
|
||||
<CircleIcon color={colColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => colColors.size === 1 && colColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleColumnBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: dictionary.toggleHeader,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
TrashIcon,
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderRowIcon,
|
||||
TableSplitCellsIcon,
|
||||
@@ -15,7 +14,11 @@ import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import {
|
||||
MenuType,
|
||||
type MenuItem,
|
||||
type NodeAttrMark,
|
||||
} from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
@@ -77,66 +80,66 @@ export default function tableRowMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : rowColors.size === 1 ? (
|
||||
<CircleIcon color={rowColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
type: MenuType.inline,
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
label: dictionary.background,
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : rowColors.size === 1 ? (
|
||||
<CircleIcon color={rowColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleRowBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleRowBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderRow",
|
||||
label: dictionary.toggleHeader,
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function useDictionary() {
|
||||
moveColumnRight: t("Move right"),
|
||||
addRowAfter: t("Insert after"),
|
||||
addRowBefore: t("Insert before"),
|
||||
align: t("Align"),
|
||||
alignCenter: t("Align center"),
|
||||
alignLeft: t("Align left"),
|
||||
alignRight: t("Align right"),
|
||||
@@ -93,6 +94,7 @@ export default function useDictionary() {
|
||||
strikethrough: t("Strikethrough"),
|
||||
strong: t("Bold"),
|
||||
subheading: t("Subheading"),
|
||||
sort: t("Sort"),
|
||||
sortAsc: t("Sort ascending"),
|
||||
sortDesc: t("Sort descending"),
|
||||
table: t("Table"),
|
||||
@@ -123,7 +125,6 @@ export default function useDictionary() {
|
||||
uploadImage: t("Upload an image"),
|
||||
formattingControls: t("Formatting controls"),
|
||||
distributeColumns: t("Distribute columns"),
|
||||
wrapText: t("Wrap text"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
+2
-10
@@ -1,10 +1,9 @@
|
||||
import find from "lodash/find";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { IntegrationType, TeamPreference } from "@shared/types";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import type Integration from "~/models/Integration";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import useStores from "./useStores";
|
||||
|
||||
/**
|
||||
@@ -15,7 +14,6 @@ import useStores from "./useStores";
|
||||
*/
|
||||
export default function useEmbeds(loadIfMissing = false) {
|
||||
const { integrations } = useStores();
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchEmbedIntegrations() {
|
||||
@@ -33,9 +31,6 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
}
|
||||
}, [integrations, loadIfMissing]);
|
||||
|
||||
const disabledEmbeds =
|
||||
(team?.getPreference(TeamPreference.DisabledEmbeds) as string[]) || [];
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
embeds.map((e) => {
|
||||
@@ -47,11 +42,8 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
e.settings = integration.settings;
|
||||
}
|
||||
|
||||
e.disabled = disabledEmbeds.includes(e.id);
|
||||
|
||||
return e;
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[integrations.orderedData, team?.preferences]
|
||||
[integrations.orderedData]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,14 +63,9 @@ 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 (!wasDefaultPrevented && event.defaultPrevented) {
|
||||
for (const registered of callbacks.reverse()) {
|
||||
if (event.defaultPrevented === true) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
PlusIcon,
|
||||
InternetIcon,
|
||||
SmileyIcon,
|
||||
BrowserIcon,
|
||||
BuildingBlocksIcon,
|
||||
} 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 APIAndAccess = lazy(() => import("~/scenes/Settings/APIAndAccess"));
|
||||
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
|
||||
const Authentication = lazy(() => import("~/scenes/Settings/Authentication"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
@@ -48,7 +48,6 @@ 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;
|
||||
@@ -108,13 +107,13 @@ const useSettingsConfig = () => {
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API & Access"),
|
||||
path: settingsPath("api-and-access"),
|
||||
component: APIAndAccess.Component,
|
||||
preload: APIAndAccess.preload,
|
||||
name: t("API & Apps"),
|
||||
path: settingsPath("api-and-apps"),
|
||||
component: APIAndApps.Component,
|
||||
preload: APIAndApps.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: PadlockIcon,
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
// Workspace
|
||||
{
|
||||
@@ -176,7 +175,7 @@ const useSettingsConfig = () => {
|
||||
path: settingsPath("templates"),
|
||||
component: Templates.Component,
|
||||
preload: Templates.preload,
|
||||
enabled: can.readTemplate,
|
||||
enabled: can.createTemplate,
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
@@ -235,18 +234,6 @@ 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,7 +5,6 @@ import type Template from "~/models/Template";
|
||||
import { ActionSeparator, createAction } from "~/actions";
|
||||
import {
|
||||
copyTemplate,
|
||||
createDocumentFromTemplate,
|
||||
deleteTemplate,
|
||||
moveTemplate,
|
||||
} from "~/actions/definitions/templates";
|
||||
@@ -50,7 +49,6 @@ export function useTemplateSettingsActions(
|
||||
}),
|
||||
moveTemplate,
|
||||
ActionSeparator,
|
||||
createDocumentFromTemplate,
|
||||
copyTemplate,
|
||||
ActionSeparator,
|
||||
deleteTemplate,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
// oxlint-disable-next-line import/no-unresolved
|
||||
import "vite/modulepreload-polyfill";
|
||||
import { LazyMotion, domMax } from "framer-motion";
|
||||
import { LazyMotion } from "framer-motion";
|
||||
import { KBarProvider } from "kbar";
|
||||
import { Provider } from "mobx-react";
|
||||
import { configure as configureMobx } from "mobx";
|
||||
@@ -45,6 +45,9 @@ 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,
|
||||
@@ -64,7 +67,7 @@ if (element) {
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={domMax}>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
|
||||
@@ -33,7 +33,7 @@ function NewTemplateMenu() {
|
||||
name: collection.name,
|
||||
section: DocumentSection,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
visible: !!canCollection.createTemplate,
|
||||
visible: !!canCollection.createDocument,
|
||||
to: newTemplatePath(collection.id),
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -124,7 +125,13 @@ export default class Collection extends ParanoidModel {
|
||||
* @returns boolean
|
||||
*/
|
||||
get isPrivate(): boolean {
|
||||
return this.permission === null;
|
||||
return !this.permission;
|
||||
}
|
||||
|
||||
/** Returns whether the collection description is not empty. */
|
||||
@computed
|
||||
get hasDescription(): boolean {
|
||||
return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false;
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -96,6 +97,11 @@ 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 set.
|
||||
* @param value The value to set.
|
||||
* @param key The TeamPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference<T extends TeamPreference>(key: T, value: TeamPreferences[T]) {
|
||||
setPreference(key: TeamPreference, value: boolean) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
|
||||
@@ -9,13 +9,6 @@ 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.
|
||||
@@ -23,6 +16,13 @@ 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,6 +40,11 @@ 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,9 +3,7 @@ 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";
|
||||
@@ -18,32 +16,16 @@ 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,
|
||||
isEditing,
|
||||
}: Props) {
|
||||
export const Header = observer(function Header_({ collection }: 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;
|
||||
@@ -51,7 +33,7 @@ export const Header = observer(function Header_({
|
||||
return (
|
||||
<StyledHeading>
|
||||
<IconTitleWrapper>
|
||||
{canEdit ? (
|
||||
{can.update ? (
|
||||
<Suspense fallback={fallbackIcon}>
|
||||
<IconPicker
|
||||
icon={collection.icon ?? "collection"}
|
||||
@@ -69,16 +51,7 @@ export const Header = observer(function Header_({
|
||||
fallbackIcon
|
||||
)}
|
||||
</IconTitleWrapper>
|
||||
{canEdit ? (
|
||||
<ContentEditable
|
||||
value={collection.name}
|
||||
onChange={handleTitleChange}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
dir="auto"
|
||||
/>
|
||||
) : (
|
||||
collection.name
|
||||
)}
|
||||
{collection.name}
|
||||
</StyledHeading>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ function Overview({ collection, readOnly }: Props) {
|
||||
return (
|
||||
<>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{(can.update || readOnly) && (
|
||||
{(collection.hasDescription || can.update) && (
|
||||
<Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<MeasuredContainer name="document">
|
||||
<Editor
|
||||
|
||||
@@ -12,7 +12,6 @@ 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(
|
||||
@@ -65,7 +64,6 @@ function ShareButton({ collection }: Props) {
|
||||
minHeight={175}
|
||||
side="bottom"
|
||||
align="end"
|
||||
onEscapeKeyDown={preventDefault}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SharePopover
|
||||
|
||||
@@ -49,7 +49,6 @@ 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 }>();
|
||||
@@ -68,17 +67,14 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
const id = params.collectionSlug || "";
|
||||
const urlId = id.split("-").pop() ?? "";
|
||||
|
||||
const collection = collections.get(id);
|
||||
const collection: Collection | null | undefined = 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}`,
|
||||
hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
|
||||
collection?.hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
|
||||
{
|
||||
listen: false,
|
||||
}
|
||||
@@ -134,7 +130,7 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const showOverview = can.update || hasDescription;
|
||||
const showOverview = can.update || collection?.hasDescription;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -176,10 +172,7 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
<Notices collection={collection} />
|
||||
<Header
|
||||
collection={collection}
|
||||
isEditing={isEditRoute && !!user?.separateEditMode}
|
||||
/>
|
||||
<Header collection={collection} />
|
||||
|
||||
<PinnedDocuments
|
||||
pins={pins}
|
||||
@@ -212,9 +205,7 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
{showOverview ? (
|
||||
<Overview
|
||||
collection={collection}
|
||||
readOnly={
|
||||
!can.update || (!isEditRoute && !!user?.separateEditMode)
|
||||
}
|
||||
readOnly={!isEditRoute && !!user?.separateEditMode}
|
||||
/>
|
||||
) : (
|
||||
<Redirect
|
||||
|
||||
@@ -15,7 +15,6 @@ 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.
|
||||
@@ -90,10 +89,6 @@ 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>
|
||||
@@ -160,7 +155,9 @@ function Changesets() {
|
||||
{showChangeset && (
|
||||
<>
|
||||
<Heading>Changeset</Heading>
|
||||
<Pre>{JSON.stringify(changeset?.changes, null, 2)}</Pre>
|
||||
<Pre>
|
||||
{JSON.stringify(mockDiffRevision.changeset?.changes, null, 2)}
|
||||
</Pre>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -312,27 +312,25 @@ function CommentForm({
|
||||
{highlightedText && (
|
||||
<HighlightedText>{highlightedText}</HighlightedText>
|
||||
)}
|
||||
<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>
|
||||
<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")}…`)
|
||||
}
|
||||
/>
|
||||
{(inputFocused || draft) && (
|
||||
<Flex justify="space-between" reverse={dir === "rtl"} gap={8}>
|
||||
<HStack>
|
||||
|
||||
@@ -27,9 +27,7 @@ import { resolveCommentFactory } from "~/actions/definitions/comments";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const CommentEditor = lazyWithRetry(() => import("./CommentEditor"));
|
||||
import CommentEditor from "./CommentEditor";
|
||||
import { HighlightedText } from "./HighlightText";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
|
||||
@@ -233,18 +231,16 @@ function CommentThreadItem({
|
||||
<HighlightedText>{highlightedText}</HighlightedText>
|
||||
)}
|
||||
<Body ref={formRef} onSubmit={handleSubmit}>
|
||||
<React.Suspense fallback={null}>
|
||||
<StyledCommentEditor
|
||||
key={String(isEditing)}
|
||||
readOnly={!isEditing}
|
||||
value={comment.data}
|
||||
defaultValue={data}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
autoFocus
|
||||
/>
|
||||
</React.Suspense>
|
||||
<StyledCommentEditor
|
||||
key={String(isEditing)}
|
||||
readOnly={!isEditing}
|
||||
value={comment.data}
|
||||
defaultValue={data}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
autoFocus
|
||||
/>
|
||||
{isEditing && (
|
||||
<Flex align="flex-end" gap={8}>
|
||||
<ButtonSmall type="submit" borderOnHover>
|
||||
|
||||
@@ -31,8 +31,7 @@ import useMobile from "~/hooks/useMobile";
|
||||
function Comments() {
|
||||
const { ui, comments, documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const { editor, isEditorInitialized, setFocusedCommentId } =
|
||||
useDocumentContext();
|
||||
const { editor, isEditorInitialized } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
@@ -49,7 +48,7 @@ function Comments() {
|
||||
const isAtBottom = useRef(true);
|
||||
const [showJumpToRecentBtn, setShowJumpToRecentBtn] = useState(false);
|
||||
|
||||
useKeyDown("Escape", () => document && ui.set({ rightSidebar: null }));
|
||||
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
|
||||
|
||||
// Account for the resolved status of the comment changing
|
||||
useEffect(() => {
|
||||
@@ -204,10 +203,7 @@ function Comments() {
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => {
|
||||
ui.set({ rightSidebar: null });
|
||||
setFocusedCommentId(null);
|
||||
}}
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
scrollable={false}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -27,7 +27,6 @@ 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";
|
||||
|
||||
@@ -90,8 +89,6 @@ 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,12 +60,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
pathname: documentPath(document as Document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
onClick={() =>
|
||||
ui.set({
|
||||
rightSidebar:
|
||||
ui.rightSidebar === "comments" ? null : "comments",
|
||||
})
|
||||
}
|
||||
onClick={() => ui.toggleComments()}
|
||||
>
|
||||
<CommentIcon size={18} />
|
||||
{commentsCount
|
||||
|
||||
@@ -25,9 +25,7 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
|
||||
import IconPicker from "~/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({ rightSidebar: "comments" });
|
||||
ui.set({ commentsExpanded: true });
|
||||
}
|
||||
}, [focusedComment, ui, document.id, params]);
|
||||
|
||||
@@ -250,9 +250,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
commentingEnabled && can.comment ? handleRemoveComment : undefined
|
||||
}
|
||||
onOpenCommentsSidebar={
|
||||
commentingEnabled
|
||||
? () => ui.set({ rightSidebar: "comments" })
|
||||
: undefined
|
||||
commentingEnabled ? ui.toggleComments : undefined
|
||||
}
|
||||
onInit={handleInit}
|
||||
onDestroy={handleDestroy}
|
||||
|
||||
@@ -217,13 +217,7 @@ function History() {
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
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%",
|
||||
}}
|
||||
style={{ height: "100%" }}
|
||||
auto
|
||||
>
|
||||
<Empty>{t("No history yet")}</Empty>
|
||||
@@ -241,7 +235,6 @@ 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,7 +13,6 @@ 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 */
|
||||
@@ -45,18 +44,15 @@ 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 changeset = ChangesetHelper.getChangeset(
|
||||
revision.data,
|
||||
revision.before?.data
|
||||
);
|
||||
return [
|
||||
const extensions = React.useMemo(
|
||||
() => [
|
||||
...withComments(richExtensions),
|
||||
...(showChanges && changeset?.changes
|
||||
? [new Diff({ changes: changeset?.changes })]
|
||||
...(showChanges && revision.changeset?.changes
|
||||
? [new Diff({ changes: revision.changeset?.changes })]
|
||||
: []),
|
||||
];
|
||||
}, [revision.data, showChanges]);
|
||||
],
|
||||
[revision.changeset, showChanges]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
|
||||
@@ -11,7 +11,6 @@ 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(
|
||||
@@ -59,7 +58,6 @@ function ShareButton({ document }: Props) {
|
||||
minHeight={175}
|
||||
side="bottom"
|
||||
align="end"
|
||||
onEscapeKeyDown={preventDefault}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SharePopover
|
||||
|
||||
@@ -6,18 +6,17 @@ 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 RightSidebar from "~/components/Sidebar/Right";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
import RightSidebar from "~/components/Sidebar/Right";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
import { PortalContext } from "~/components/Portal";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||
/* The title of the sidebar */
|
||||
@@ -25,7 +24,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;
|
||||
};
|
||||
@@ -33,7 +32,6 @@ 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);
|
||||
|
||||
@@ -45,21 +43,17 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
children
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer onClose={onClose} defaultOpen>
|
||||
<DrawerContent ref={setDrawerElement}>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<PortalContext.Provider value={drawerElement}>
|
||||
{content}
|
||||
</PortalContext.Provider>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
return isMobile ? (
|
||||
<Drawer onClose={onClose} defaultOpen>
|
||||
<DrawerContent ref={setDrawerElement}>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<PortalContext.Provider value={drawerElement}>
|
||||
{content}
|
||||
</PortalContext.Provider>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<RightSidebar>
|
||||
<Header>
|
||||
<Title>{title}</Title>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
@@ -72,14 +66,8 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
</Tooltip>
|
||||
</Header>
|
||||
{content}
|
||||
</>
|
||||
</RightSidebar>
|
||||
);
|
||||
|
||||
if (isWrapped) {
|
||||
return inner;
|
||||
}
|
||||
|
||||
return <RightSidebar>{inner}</RightSidebar>;
|
||||
}
|
||||
|
||||
const ForwardIcon = styled(BackIcon)`
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
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: string) => (
|
||||
<span key={param}>
|
||||
{missingParams.map((param) => (
|
||||
<>
|
||||
{param}
|
||||
<br />
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
</Pre>
|
||||
</Text>
|
||||
|
||||
@@ -28,7 +28,6 @@ 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";
|
||||
@@ -40,12 +39,10 @@ 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();
|
||||
@@ -187,10 +184,6 @@ function Search() {
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
updateLocation(ev.currentTarget.value);
|
||||
return;
|
||||
@@ -236,23 +229,16 @@ 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")}
|
||||
actions={isMobile ? sortInput : null}
|
||||
>
|
||||
<Scene textTitle={query ? `${query} – ${t("Search")}` : t("Search")}>
|
||||
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
|
||||
{loading && <LoadingIndicator />}
|
||||
<ResultsWrapper column auto>
|
||||
<form method="GET" action={searchPath()} onSubmit={preventDefault}>
|
||||
<form
|
||||
method="GET"
|
||||
action={searchPath()}
|
||||
onSubmit={(ev) => ev.preventDefault()}
|
||||
>
|
||||
<SearchInput
|
||||
name="query"
|
||||
key={query ? "search" : "recent"}
|
||||
@@ -267,8 +253,9 @@ function Search() {
|
||||
onKeyDown={handleKeyDown}
|
||||
defaultValue={query ?? ""}
|
||||
/>
|
||||
|
||||
<Filters>
|
||||
<Flex align="center" gap={4} wrap>
|
||||
<Flex align="center" gap={4}>
|
||||
{filterVisibility.document && (
|
||||
<DocumentFilter
|
||||
document={document!}
|
||||
@@ -314,11 +301,18 @@ function Search() {
|
||||
handleFilterChange({ titleFilter: checked });
|
||||
}}
|
||||
checked={titleFilter}
|
||||
inForm={false}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{isMobile ? null : sortInput}
|
||||
{filterVisibility.sort && (
|
||||
<SortInput
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
onSelect={(sort, direction) =>
|
||||
handleFilterChange({ sort, direction })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Filters>
|
||||
</form>
|
||||
{isSearchable ? (
|
||||
@@ -418,9 +412,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,5 +1,6 @@
|
||||
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";
|
||||
@@ -58,10 +59,14 @@ const StyledInput = styled.input`
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
border: 0;
|
||||
background: ${s("inputBackground")};
|
||||
background: #14171f;
|
||||
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 { PadlockIcon } from "outline-icons";
|
||||
import { BuildingBlocksIcon } 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 APIAndAccess() {
|
||||
function APIAndApps() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
@@ -28,8 +28,8 @@ function APIAndAccess() {
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API & Access")}
|
||||
icon={<PadlockIcon />}
|
||||
title={t("API & Apps")}
|
||||
icon={<BuildingBlocksIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
@@ -44,7 +44,7 @@ function APIAndAccess() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API & Access")}</Heading>
|
||||
<Heading>{t("API & Apps")}</Heading>
|
||||
<h2>{t("API keys")}</h2>
|
||||
{can.createApiKey ? (
|
||||
<Text as="p" type="secondary">
|
||||
@@ -98,4 +98,4 @@ function APIAndAccess() {
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(APIAndAccess);
|
||||
export default observer(APIAndApps);
|
||||
@@ -1,157 +0,0 @@
|
||||
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, { Card } from "./components/IntegrationCard";
|
||||
import IntegrationCard from "./components/IntegrationCard";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
@@ -62,9 +62,6 @@ function Integrations() {
|
||||
{groupedItems.available?.map((item) => (
|
||||
<IntegrationCard key={item.path} integration={item} />
|
||||
))}
|
||||
{groupedItems.available?.length % 2 === 1 && (
|
||||
<Card style={{ visibility: "hidden" }} />
|
||||
)}
|
||||
</Cards>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ function Security() {
|
||||
|
||||
const [data, setData] = useState({
|
||||
sharing: team.sharing,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
defaultUserRole: team.defaultUserRole,
|
||||
memberCollectionCreate: team.memberCollectionCreate,
|
||||
memberTeamCreate: team.memberTeamCreate,
|
||||
@@ -106,6 +107,13 @@ 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 });
|
||||
@@ -319,6 +327,19 @@ 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,7 +10,6 @@ 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";
|
||||
@@ -30,7 +29,7 @@ const LoadingState = observer(function LoadingState() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { templates, ui } = useStores();
|
||||
const template = templates.get(id);
|
||||
const { request, error } = useRequest(() => templates.fetch(id));
|
||||
const { request } = useRequest(() => templates.fetch(id));
|
||||
|
||||
useEffect(() => {
|
||||
if (!template) {
|
||||
@@ -47,10 +46,6 @@ 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.readTemplate && (
|
||||
{can.createTemplate && (
|
||||
<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>
|
||||
{t("Configure")}
|
||||
{isConnected ? t("Configure") : t("Connect")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -39,7 +39,7 @@ function IntegrationCard({ integration, isConnected }: Props) {
|
||||
|
||||
export default IntegrationCard;
|
||||
|
||||
export const Card = styled.div`
|
||||
const Card = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
||||
@@ -12,18 +12,21 @@ type Props = {
|
||||
name: string;
|
||||
visible?: boolean;
|
||||
border?: boolean;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const Row = styled(Flex)<{ $border?: boolean; $compact?: boolean }>`
|
||||
padding: ${(props) => (props.$compact ? "12px 0" : "22px 0")};
|
||||
align-items: ${(props) => (props.$compact ? "center" : "initial")};
|
||||
const Row = styled(Flex)<{ $border?: boolean }>`
|
||||
display: block;
|
||||
padding: 22px 0;
|
||||
border-bottom: 1px solid
|
||||
${(props) =>
|
||||
props.$border === false
|
||||
? "transparent"
|
||||
: transparentize(0.5, props.theme.divider)};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
@@ -36,25 +39,11 @@ const Column = styled.div`
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
min-width: 50%;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
min-width: 65%;
|
||||
`}
|
||||
min-width: 65%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
min-width: 0;
|
||||
|
||||
> * {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
> * {
|
||||
align-self: initial;
|
||||
}
|
||||
`}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -71,7 +60,6 @@ const Label = styled(Text)`
|
||||
const SettingRow: React.FC<Props> = ({
|
||||
visible,
|
||||
description,
|
||||
compact,
|
||||
name,
|
||||
label,
|
||||
border,
|
||||
@@ -80,9 +68,8 @@ const SettingRow: React.FC<Props> = ({
|
||||
if (visible === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gap={32} $border={border} $compact={compact}>
|
||||
<Row gap={32} $border={border}>
|
||||
<Column>
|
||||
<Label as="h3">
|
||||
<label htmlFor={name}>{label}</label>
|
||||
|
||||
@@ -25,7 +25,6 @@ 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;
|
||||
@@ -55,6 +54,7 @@ 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,7 +81,21 @@ export function TemplatesTable(props: Props) {
|
||||
header: t("Title"),
|
||||
accessor: (template) => template.titleWithDefault,
|
||||
component: (template) => (
|
||||
<TemplateLink template={template} onClick={handleOpen} />
|
||||
<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>
|
||||
),
|
||||
width: "4fr",
|
||||
},
|
||||
@@ -141,44 +155,6 @@ 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();
|
||||
|
||||
|
||||
+10
-7
@@ -28,7 +28,7 @@ export enum SystemTheme {
|
||||
type PersistedData = Pick<
|
||||
UiStore,
|
||||
| "languagePromptDismissed"
|
||||
| "rightSidebar"
|
||||
| "commentsExpanded"
|
||||
| "theme"
|
||||
| "sidebarWidth"
|
||||
| "sidebarRightWidth"
|
||||
@@ -78,7 +78,7 @@ class UiStore {
|
||||
sidebarCollapsed = false;
|
||||
|
||||
@observable
|
||||
rightSidebar: "comments" | "history" | null = null;
|
||||
commentsExpanded = false;
|
||||
|
||||
@observable
|
||||
sidebarIsResizing = false;
|
||||
@@ -111,7 +111,7 @@ class UiStore {
|
||||
this.sidebarRightWidth =
|
||||
data.sidebarRightWidth || defaultTheme.sidebarRightWidth;
|
||||
this.tocVisible = data.tocVisible;
|
||||
this.rightSidebar = data.rightSidebar ?? null;
|
||||
this.commentsExpanded = !!data.commentsExpanded;
|
||||
this.theme = data.theme || Theme.System;
|
||||
|
||||
// system theme listeners
|
||||
@@ -340,6 +340,11 @@ class UiStore {
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
toggleComments = () => {
|
||||
this.set({ commentsExpanded: !this.commentsExpanded });
|
||||
};
|
||||
|
||||
@action
|
||||
toggleCollapsedSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
@@ -393,9 +398,7 @@ class UiStore {
|
||||
get readyToShow() {
|
||||
return (
|
||||
!this.rootStore.auth.user ||
|
||||
(this.rootStore.collections.isLoaded &&
|
||||
this.rootStore.stars.isLoaded &&
|
||||
this.rootStore.userMemberships.isLoaded)
|
||||
(this.rootStore.collections.isLoaded && this.rootStore.documents.isLoaded)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -430,7 +433,7 @@ class UiStore {
|
||||
sidebarWidth: this.sidebarWidth,
|
||||
sidebarRightWidth: this.sidebarRightWidth,
|
||||
languagePromptDismissed: this.languagePromptDismissed,
|
||||
rightSidebar: this.rightSidebar,
|
||||
commentsExpanded: this.commentsExpanded,
|
||||
theme: this.theme,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,15 +22,6 @@ 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) {
|
||||
@@ -83,9 +74,7 @@ class UnfurlsStore extends Store<Unfurl<any>> {
|
||||
data,
|
||||
} as Unfurl<UnfurlType>);
|
||||
} catch (err) {
|
||||
Logger.warn(`Failed to unfurl url ${url}`, {
|
||||
message: err.message,
|
||||
});
|
||||
Logger.error(`Failed to unfurl url ${url}`, err);
|
||||
return;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
|
||||
@@ -21,6 +21,7 @@ export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
|
||||
Required<Pick<T, K>>;
|
||||
|
||||
export type MenuItemButton = {
|
||||
id?: string;
|
||||
type: "button";
|
||||
title: React.ReactNode;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
|
||||
@@ -33,6 +34,7 @@ export type MenuItemButton = {
|
||||
};
|
||||
|
||||
export type MenuItemWithChildren = {
|
||||
id?: string;
|
||||
type: "submenu";
|
||||
title: React.ReactNode;
|
||||
visible?: boolean;
|
||||
|
||||
Vendored
-1
@@ -159,7 +159,6 @@ declare module "styled-components" {
|
||||
titleBarDivider: string;
|
||||
inputBorder: string;
|
||||
inputBorderFocused: string;
|
||||
inputBackground: string;
|
||||
listItemHoverBackground: string;
|
||||
mentionBackground: string;
|
||||
mentionHoverBackground: string;
|
||||
|
||||
+1
-20
@@ -44,9 +44,6 @@ 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";
|
||||
}
|
||||
@@ -283,23 +280,7 @@ class ApiClient {
|
||||
path: string,
|
||||
data?: JSONObject | FormData | undefined,
|
||||
options?: FetchOptions
|
||||
): 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;
|
||||
};
|
||||
) => this.fetch<T>(path, "POST", data, options);
|
||||
}
|
||||
|
||||
export const client = new ApiClient();
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { domMax } from "framer-motion";
|
||||
|
||||
export default domMax;
|
||||
+3
-5
@@ -78,7 +78,6 @@
|
||||
"@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",
|
||||
@@ -133,7 +132,7 @@
|
||||
"fetch-retry": "^5.0.6",
|
||||
"form-data": "^4.0.5",
|
||||
"fractional-index": "^1.0.0",
|
||||
"framer-motion": "^6.5.1",
|
||||
"framer-motion": "^4.1.17",
|
||||
"franc": "^6.2.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fuzzy-search": "^3.2.1",
|
||||
@@ -148,7 +147,6 @@
|
||||
"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",
|
||||
@@ -179,7 +177,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^4.1.0",
|
||||
"outline-icons": "^4.0.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"pako": "^2.1.0",
|
||||
"passport": "^0.7.0",
|
||||
@@ -384,7 +382,7 @@
|
||||
"d3": "^7.0.0",
|
||||
"debug": "4.3.4",
|
||||
"node-fetch": "^2.7.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^3.14.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 = 100;
|
||||
private pageSize = 25;
|
||||
private maxRetries = 3;
|
||||
private retryDelay = 1000;
|
||||
private skipChildrenForBlock = [
|
||||
|
||||
@@ -24,8 +24,7 @@ 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,
|
||||
"Databases with multiple data sources are not supported in this API version", // https://github.com/outline/outline/issues/11573#issuecomment-3993691460
|
||||
"does not contain any data sources accessible by this API bot", // error msg for linked database views
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,6 @@ import type {
|
||||
BookmarkBlockObjectResponse,
|
||||
BreadcrumbBlockObjectResponse,
|
||||
BulletedListItemBlockObjectResponse,
|
||||
ChildDatabaseBlockObjectResponse,
|
||||
ChildPageBlockObjectResponse,
|
||||
DividerBlockObjectResponse,
|
||||
Heading1BlockObjectResponse,
|
||||
Heading2BlockObjectResponse,
|
||||
@@ -62,11 +60,8 @@ export class NotionConverter {
|
||||
const mapChild = (
|
||||
child: Block
|
||||
): ProsemirrorData | ProsemirrorData[] | undefined => {
|
||||
if (child.type === "child_page") {
|
||||
return this.child_page(child);
|
||||
}
|
||||
if (child.type === "child_database") {
|
||||
return this.child_database(child);
|
||||
if (child.type === "child_page" || child.type === "child_database") {
|
||||
return; // this will be created as a nested page, no need to handle/convert.
|
||||
}
|
||||
|
||||
// @ts-expect-error Not all blocks have an interface
|
||||
@@ -511,42 +506,6 @@ 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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user