Compare commits

..

18 Commits

Author SHA1 Message Date
Salihu 6f06eda36b minor fixes 2026-02-26 22:48:29 +01:00
Salihu 2dcfe4be0c minor fixes 2026-02-26 18:16:59 +01:00
Salihu 8b56b47eb0 fix mobile menu closing functionality 2026-02-24 18:50:02 +01:00
Salihu b20f70da42 add comment 2026-02-24 14:10:41 +01:00
Salihu 315992d55b some changes 2026-02-23 22:52:21 +01:00
Salihu 8427778c46 nested submenu 2026-02-22 22:16:36 +01:00
Salihu fd4dab23f2 nested submenu 2026-02-22 22:13:37 +01:00
Salihu edcdb6f8c0 minor fix 2026-02-22 16:19:56 +01:00
Salihu 41a5097240 revert unnecessary lint changes 2026-02-22 15:53:58 +01:00
Salihu bc248dc190 Merge remote-tracking branch 'upstream/main' into feat/inline-menu 2026-02-22 15:28:07 +01:00
Salihu f3eec09125 Merge remote-tracking branch 'upstream/main' into feat/inline-menu 2026-02-22 00:38:25 +01:00
Salihu afb849ac98 improve menu positioning 2026-02-21 21:58:13 +01:00
Salihu 9b67d55f76 add submenu 2026-02-20 16:33:15 +01:00
Salihu b792945d01 table mens should be inline menus 2026-02-13 21:54:19 +01:00
Salihu 7c8ba7d2c1 render inline menu just outside the table 2026-02-10 15:49:48 +01:00
Salihu 54a90b05a8 separate inline menu from floating toolbar 2026-02-10 00:43:46 +01:00
Salihu 3e38164366 minor fixes 2026-01-07 17:41:57 +01:00
Salihu f28ce8f0cd inline menu 2026-01-06 23:26:59 +01:00
241 changed files with 3916 additions and 7552 deletions
-5
View File
@@ -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 ––––––––––
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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);
+45 -3
View File
@@ -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();
},
});
+2 -9
View File
@@ -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);
},
});
+61 -14
View File
@@ -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>
);
};
+5 -2
View File
@@ -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> = ({
+1 -1
View File
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
key={collaborator.id}
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
+1 -3
View File
@@ -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 && (
+4 -5
View File
@@ -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>
);
+6 -2
View File
@@ -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>
+6 -2
View File
@@ -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}
+3
View File
@@ -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)}
`;
+10 -53
View File
@@ -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;
-57
View File
@@ -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);
+58 -101
View File
@@ -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") {
+7 -15
View File
@@ -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 }
+32 -2
View File
@@ -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);
+3 -5
View File
@@ -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()}
`;
-9
View File
@@ -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}
/>
)}
</>
+3 -3
View File
@@ -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,
+17 -14
View File
@@ -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%;
`;
+79 -5
View File
@@ -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>;
}
+418 -25
View File
@@ -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,
+1 -1
View File
@@ -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
}
`;
+19 -1
View File
@@ -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`
-1
View File
@@ -20,7 +20,6 @@ function BlockMenu(props: Props) {
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
disclosure={options.disclosure}
/>
),
[]
+1
View File
@@ -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);
+18 -19
View File
@@ -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(
+110
View File
@@ -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;
+143 -142
View File
@@ -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) => {
+1 -4
View File
@@ -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(() => {
+32 -5
View File
@@ -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;
}
`;
+237 -432
View File
@@ -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>
);
}
+75 -60
View File
@@ -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;
+6 -4
View File
@@ -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;
},
{
+3 -2
View File
@@ -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
View File
@@ -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
+6 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+57 -54
View File
@@ -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,
+2 -1
View File
@@ -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
View File
@@ -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]
);
}
+2 -7
View File
@@ -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;
}
+8 -21
View File
@@ -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"),
-2
View File
@@ -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,
-35
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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),
});
}),
+8 -1
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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,
+12 -7
View File
@@ -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 -30
View File
@@ -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
+5 -14
View File
@@ -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
+3 -6
View File
@@ -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 */
+2 -4
View File
@@ -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}
+1 -8
View File
@@ -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]
);
}
+3 -3
View File
@@ -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>
+18 -24
View File
@@ -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);
+6 -1
View File
@@ -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);
-157
View File
@@ -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);
+1 -4
View File
@@ -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>
);
+21
View File
@@ -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}
+1 -6
View File
@@ -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 />;
}
+1 -1
View File
@@ -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"> &middot; {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;
+9 -22
View File
@@ -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
View File
@@ -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,
};
}
+1 -12
View File
@@ -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;
+2
View File
@@ -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;
-1
View File
@@ -159,7 +159,6 @@ declare module "styled-components" {
titleBarDivider: string;
inputBorder: string;
inputBorderFocused: string;
inputBackground: string;
listItemHoverBackground: string;
mentionBackground: string;
mentionHoverBackground: string;
+1 -20
View File
@@ -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();
-8
View File
@@ -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();
};
+3
View File
@@ -0,0 +1,3 @@
import { domMax } from "framer-motion";
export default domMax;
+3 -5
View File
@@ -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",
+1 -1
View File
@@ -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
];
/**
+2 -43
View File
@@ -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