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
417 changed files with 5862 additions and 15546 deletions
+3 -7
View File
@@ -119,18 +119,14 @@ 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 ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF these is required for a
# working installation or you'll have no sign-in options.
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
+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
-3
View File
@@ -1,6 +1,3 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
npmPreapprovedPackages:
- outline-icons
+1 -1
View File
@@ -70,7 +70,7 @@ yarn install
### Exports
- Exported members must appear at the top of the file.
- Always use named exports for new components & classes.
- Prefer named exports for components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.6.1
Licensed Work: Outline 1.5.0
The Licensed Work is (c) 2026 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-03-18
Change Date: 2030-02-15
Change License: Apache License, Version 2.0
+2 -2
View File
@@ -33,9 +33,9 @@ There is a short guide for [setting up a development environment](https://docs.g
## Contributing
Outline is built and maintained by a small team your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
+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);
+48 -66
View File
@@ -32,8 +32,6 @@ import {
CaseSensitiveIcon,
RestoreIcon,
EditIcon,
EmbedIcon,
OpenIcon,
} from "outline-icons";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
@@ -75,7 +73,6 @@ import {
searchPath,
documentPath,
urlify,
desktopify,
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
@@ -89,8 +86,6 @@ import type {
} from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import { isMac, isWindows } from "@shared/utils/browser";
import isCloudHosted from "~/utils/isCloudHosted";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
@@ -206,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.
*
@@ -340,15 +377,8 @@ export const createNewDocument = createActionWithChildren({
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return (
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
@@ -577,10 +607,7 @@ export const shareDocument = createAction({
section: ActiveDocumentSection,
icon: <PadlockIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
return false;
}
const can = stores.policies.abilities(activeDocumentId);
const can = stores.policies.abilities(activeDocumentId!);
return can.manageUsers || can.share;
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -955,50 +982,7 @@ export const printDocument = createAction({
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
setTimeout(window.print, 0);
},
});
export const openDocumentInDesktop = createAction({
name: ({ t }) => t("Open in desktop app"),
analyticsName: "Open in desktop",
section: ActiveDocumentSection,
icon: <OpenIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
);
},
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
window.location.href = desktopify(documentPath(document));
}
},
});
export const presentDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["Meta+Alt+p"],
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
stores.ui.setPresentingDocument(document);
queueMicrotask(window.print);
},
});
@@ -1066,7 +1050,7 @@ export const createTemplateFromDocument = createAction({
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createTemplate
stores.policies.abilities(activeCollectionId).updateDocument
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -1397,7 +1381,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.set({ rightSidebar: "comments" });
stores.ui.toggleComments();
},
});
@@ -1545,13 +1529,11 @@ export const rootDocumentActions = [
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
presentDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
shareDocument,
];
+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);
},
});
+66 -21
View File
@@ -1,10 +1,17 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route } 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();
@@ -57,17 +74,15 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
history.push(newDocumentPath(activeCollectionId));
};
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
history.replace(postLoginPath);
}
}, [spendPostLoginPath]);
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
@@ -77,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>
);
};
-7
View File
@@ -3,8 +3,6 @@ import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import type { HapticInput } from "web-haptics";
import { useWebHaptics } from "web-haptics/react";
import { s } from "@shared/styles";
import type { Props as ActionButtonProps } from "~/components/ActionButton";
import ActionButton from "~/components/ActionButton";
@@ -154,8 +152,6 @@ export type Props<T> = ActionButtonProps & {
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
haptic?: HapticInput;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
@@ -180,13 +176,11 @@ const Button = <T extends React.ElementType = "button">(
hideIcon,
fullwidth,
danger,
haptic,
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const hasIcon = ic !== undefined;
const { trigger } = useWebHaptics();
return (
<RealButton
@@ -197,7 +191,6 @@ const Button = <T extends React.ElementType = "button">(
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
{...rest}
>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
+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}
+40 -91
View File
@@ -6,8 +6,8 @@ import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission, TeamPreference } from "@shared/types";
import type { Option } from "~/components/InputSelect";
import type { CollectionPermission } from "@shared/types";
import { TeamPreference } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
@@ -15,7 +15,6 @@ import type Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Input from "~/components/Input";
import { InputSelect } from "~/components/InputSelect";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
@@ -35,7 +34,6 @@ export interface FormData {
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
templateManagement: CollectionPermission;
}
const useIconColor = (collection?: Collection) => {
@@ -70,22 +68,6 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const templateManagementOptions = useMemo<Option[]>(
() => [
{
type: "item",
label: t("Managers"),
value: CollectionPermission.Admin,
},
{
type: "item",
label: t("Members"),
value: CollectionPermission.ReadWrite,
},
],
[t]
);
const iconColor = useIconColor(collection);
const fallbackIcon = (
<Icon
@@ -111,8 +93,6 @@ export const CollectionForm = observer(function CollectionForm_({
sharing: collection?.sharing ?? true,
permission: collection?.permission,
commenting: collection?.commenting ?? true,
templateManagement:
collection?.templateManagement ?? CollectionPermission.Admin,
color: iconColor,
},
});
@@ -155,71 +135,6 @@ export const CollectionForm = observer(function CollectionForm_({
const initial = values.name.charAt(0).toUpperCase();
const options = (
<>
<Controller
control={control}
name="templateManagement"
render={({ field }) => (
<>
<InputSelect
value={field.value}
onChange={(value: string) => {
field.onChange(value as CollectionPermission);
}}
options={templateManagementOptions}
label={t("Manage templates")}
/>
<Text
type="secondary"
size="small"
as="p"
style={{ paddingTop: 4 }}
>
{t(
"Choose who can create and edit templates in this collection."
)}
</Text>
</>
)}
/>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
</>
);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
@@ -275,10 +190,44 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{collection ? (
options
) : (
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
<Collapsible label={t("Advanced options")}>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t(
"Allow commenting on documents within this collection."
)}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
</Collapsible>
)}
<HStack justify="flex-end">
+1 -8
View File
@@ -128,14 +128,7 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
if (document.activeElement === contentRef.current) {
// Don't reset content while the user is actively editing. Update
// lastValue so that the next input or blur event will push the
// current DOM text back to the model via onChange.
lastValue.current = value;
} else {
setInnerValue(value);
}
setInnerValue(value);
}
}, [value, contentRef]);
@@ -12,6 +12,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
@@ -41,28 +42,6 @@ type Props = {
showDocuments?: boolean;
};
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
function DocumentExplorer({
onSubmit,
onSelect,
@@ -88,6 +67,8 @@ function DocumentExplorer({
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
@@ -110,6 +91,9 @@ function DocumentExplorer({
);
const listRef = React.useRef<List<NavigationNode[]>>(null);
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const searchIndex = React.useMemo(
() =>
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
@@ -160,8 +144,7 @@ function DocumentExplorer({
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);
}, [defaultValue, selectedNode, nodes]);
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
@@ -169,9 +152,17 @@ function DocumentExplorer({
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const scrollNodeIntoView = React.useCallback((node: number) => {
listRef.current?.scrollToItem(node, "smart");
}, []);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
if (itemRefs[node] && itemRefs[node].current) {
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
behavior: "auto",
block: "center",
});
}
},
[itemRefs]
);
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(ev.target.value);
@@ -179,16 +170,16 @@ function DocumentExplorer({
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
const preserveScrollOffset = (itemCount: number) => {
const calculateInitialScrollOffset = (itemCount: number) => {
if (listRef.current) {
const { height, itemSize } = listRef.current.props;
const { scrollOffset } = listRef.current.state as {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
setTimeout(() => listRef.current?.scrollTo(offset), 0);
return itemsHeight < Number(height) ? 0 : scrollOffset;
}
return 0;
};
const collapse = (node: number) => {
@@ -199,7 +190,8 @@ function DocumentExplorer({
// remove children
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
preserveScrollOffset(newNodes.length);
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
const expand = (node: number) => {
@@ -208,7 +200,8 @@ function DocumentExplorer({
// add children
const newNodes = nodes.slice();
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
preserveScrollOffset(newNodes.length);
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
React.useEffect(() => {
@@ -232,8 +225,7 @@ function DocumentExplorer({
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 ||
(showDocuments !== false && nodes[node].type === "collection");
nodes[node].children.length > 0 || showDocuments !== false;
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -395,6 +387,25 @@ function DocumentExplorer({
}
};
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
<ListSearch
@@ -414,12 +425,14 @@ function DocumentExplorer({
<Flex role="listbox" column>
<List
ref={listRef}
key={nodes.length}
width={width}
height={height}
itemData={nodes}
itemCount={nodes.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results) => results[index].id}
>
{ListItem}
@@ -40,8 +40,10 @@ function DocumentExplorerNode(
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const DISCLOSURE = 24;
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
const OFFSET = 12;
const DISCLOSURE = 20;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
return (
<Node
@@ -78,7 +80,7 @@ const Title = styled(Text)`
const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin: 2px 0;
margin-top: 2px;
`;
const Spacer = styled(Flex)<{ width: number }>`
@@ -1,6 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "./DocumentExplorerNode";
@@ -31,8 +32,22 @@ function DocumentExplorerSearchResult({
}: Props) {
const { t } = useTranslation();
const ref = React.useCallback(
(node: HTMLSpanElement | null) => {
if (active && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
});
}
},
[active]
);
return (
<SearchResult
ref={ref}
selected={selected}
active={active}
onClick={onClick}
+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 && (
-1
View File
@@ -88,7 +88,6 @@ function Header(
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
haptic="light"
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
+6 -4
View File
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
white-space: nowrap;
`;
export const Description = styled(StyledText)<{ $margin?: string }>`
export const Description = styled(StyledText)`
${sharedVars}
margin-top: ${(props) => props.$margin ?? "0.5em"};
margin-top: 0.5em;
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
overflow: hidden;
@@ -64,6 +64,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
width: fit-content;
border-radius: 2em;
padding: 1px 8px 1px 20px;
margin-right: 0.5em;
margin-top: 0.5em;
position: relative;
flex-shrink: 0;
@@ -73,8 +75,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: ${(props) =>
props.color || props.theme.backgroundSecondary};
@@ -17,7 +17,6 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewProject from "./HoverPreviewProject";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 500;
@@ -193,18 +192,6 @@ const HoverPreviewDesktop = observer(
createdAt={data.createdAt}
state={data.state}
/>
) : data.type === UnfurlResourceType.Project ? (
<HoverPreviewProject
ref={cardRef}
url={data.url}
name={data.name}
color={data.color}
lead={data.lead}
labels={data.labels}
description={data.description}
state={data.state}
targetDate={data.targetDate}
/>
) : (
<HoverPreviewLink
ref={cardRef}
@@ -75,7 +75,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Description>
)}
<Flex wrap gap={6} style={{ marginTop: 8 }}>
<Flex wrap>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
@@ -1,148 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Backticks } from "@shared/components/Backticks";
import Squircle from "@shared/components/Squircle";
import Editor from "~/components/Editor";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Card,
CardContent,
Label,
Description,
} from "./Components";
import { richExtensions } from "@shared/editor/nodes";
type Props = Pick<
UnfurlResponse[UnfurlResourceType.Project],
| "url"
| "name"
| "color"
| "lead"
| "labels"
| "state"
| "targetDate"
| "description"
>;
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
{ url, name, color, lead, labels, state, description, targetDate }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={4} column>
<Title>
<StyledSquircle color={color} size={16} />
<span>
<Backticks content={name} />
</span>
</Title>
{description && (
<Description as="div" $margin="0">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Text
type="tertiary"
size="small"
style={{ textTransform: "capitalize" }}
>
{state.name}
</Text>
{(lead || targetDate) && (
<>
<Divider />
{lead && (
<MetadataRow>
<MetadataLabel>{t("Lead")}</MetadataLabel>
<Flex align="center" gap={6}>
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
<Text size="small">{lead.name}</Text>
</Flex>
</MetadataRow>
)}
{targetDate && (
<MetadataRow>
<MetadataLabel>{t("Target date")}</MetadataLabel>
<Text size="small">
<Time dateTime={targetDate} addSuffix />
</Text>
</MetadataRow>
)}
</>
)}
{labels.length > 0 && (
<>
<Divider />
<MetadataRow>
<MetadataLabel>{t("Labels")}</MetadataLabel>
<Flex wrap gap={6}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
</Label>
))}
</Flex>
</MetadataRow>
</>
)}
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
const StyledSquircle = styled(Squircle)`
flex-shrink: 0;
margin-top: 4px;
`;
const Divider = styled.div`
height: 1px;
background: ${s("divider")};
margin: 4px 0;
`;
const MetadataRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
`;
const MetadataLabel = styled(Text).attrs({
type: "tertiary",
size: "small",
})`
flex-shrink: 0;
min-width: 80px;
`;
export default HoverPreviewProject;
+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>
);
+17 -22
View File
@@ -9,44 +9,39 @@ export interface LazyComponent<T extends React.ComponentType<any>> {
interface LazyLoadOptions {
retries?: number;
interval?: number;
/** If provided, picks this named export from the module instead of `default`. */
exportName?: string;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
* Supports both default and named exports.
*
* @param factory A function that returns a promise of a module.
* @param options Optional configuration for retry behavior and export name.
* @returns An object containing the lazy Component and a preload function.
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
*
* @example
* ```typescript
* // Default export
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* // Named export
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
* exportName: 'MyComponent',
* });
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<Record<string, T>>,
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval, exportName } = options;
const wrappedFactory = exportName
? () =>
factory().then((m) => ({
default: m[exportName],
}))
: (factory as () => Promise<{ default: T }>);
const { retries, interval } = options;
return {
Component: lazyWithRetry(wrappedFactory, retries, interval),
preload: wrappedFactory,
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
};
}
+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;
-32
View File
@@ -1,32 +0,0 @@
import { observer } from "mobx-react";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const PresentationMode = lazyWithRetry(
() => import("~/scenes/Document/components/PresentationMode")
);
function Presentation() {
const { ui } = useStores();
if (!ui.presentationData) {
return null;
}
return (
<Suspense fallback={null}>
<PresentationMode
title={ui.presentationData.title}
icon={ui.presentationData.icon}
iconColor={ui.presentationData.color}
data={ui.presentationData.data}
onClose={() => {
ui.setPresentingDocument(null);
}}
/>
</Suspense>
);
}
export default observer(Presentation);
-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);
+1 -4
View File
@@ -1,5 +1,4 @@
import { useKBar } from "kbar";
import { observer } from "mobx-react";
import { useEffect, useRef } from "react";
import { Minute } from "@shared/utils/time";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
@@ -15,7 +14,7 @@ interface CacheEntry {
// Cache configuration
const cacheTTL = Minute.ms * 5;
function SearchActions() {
export default function SearchActions() {
const { searches, documents } = useStores();
// Cache structure: Map of search queries to timestamp of last search
@@ -59,5 +58,3 @@ function SearchActions() {
return null;
}
export default observer(SearchActions);
+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 }
+1 -2
View File
@@ -76,8 +76,7 @@ function SettingsSidebar() {
to={item.path}
onClickIntent={item.preload}
active={
item.path.startsWith(settingsPath("templates")) ||
item.path.startsWith(settingsPath("groups"))
item.path.startsWith(settingsPath("templates"))
? location.pathname.startsWith(item.path)
: undefined
}
+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);
+1 -8
View File
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { useLocation } from "react-router-dom";
import styled, { css, useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -54,7 +53,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
const collapsed = ui.sidebarIsClosed && canCollapse;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const { trigger } = useWebHaptics();
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
@@ -226,11 +224,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
[width]
);
const handleCloseSidebar = () => {
trigger("light");
ui.toggleMobileSidebar();
};
return (
<TooltipProvider>
<Container
@@ -282,7 +275,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={handleCloseSidebar} />}
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</TooltipProvider>
);
});
@@ -152,7 +152,7 @@ function SidebarLink(
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
style={active ? activeStyle : style}
style={style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
onActiveClick={handleDisclosureClick}
+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,
+1 -18
View File
@@ -1,28 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Toaster, useSonner } from "sonner";
import { Toaster } from "sonner";
import styled, { useTheme } from "styled-components";
import { useWebHaptics } from "web-haptics/react";
import useStores from "~/hooks/useStores";
function Toasts() {
const { ui } = useStores();
const theme = useTheme();
const { toasts } = useSonner();
const { trigger } = useWebHaptics();
const prevCountRef = React.useRef(toasts.length);
React.useEffect(() => {
if (toasts.length > prevCountRef.current) {
const latest = toasts[toasts.length - 1];
if (latest.type === "error") {
void trigger("error");
} else if (latest.type === "success") {
void trigger("success");
}
}
prevCountRef.current = toasts.length;
}, [toasts, trigger]);
return (
<StyledToaster
+2 -2
View File
@@ -2,7 +2,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { transparentize } from "polished";
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { s, depths } from "@shared/styles";
import { s } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -267,7 +267,7 @@ const StyledContent = styled(TooltipPrimitive.Content)`
white-space: normal;
outline: 0;
padding: 5px 9px;
z-index: ${depths.tooltip};
z-index: 9999;
max-width: calc(100vw - 10px);
/* Animation */
+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`
+3 -113
View File
@@ -1,126 +1,17 @@
import { DocumentIcon, ShapesIcon } from "outline-icons";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import type { MenuItem } from "@shared/editor/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useCallback } from "react";
import useDictionary from "~/hooks/useDictionary";
import useStores from "~/hooks/useStores";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
/**
* Hook that returns a template menu item with children for inserting template
* content into the editor, or undefined if no templates are available.
*/
function useTemplateMenuItem(): MenuItem | undefined {
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const { documents, templates: templatesStore } = useStores();
const editor = useEditor();
const documentId = editor.props.id;
const document = documentId ? documents.get(documentId) : undefined;
const collectionId = document?.collectionId;
return useMemo(() => {
if (!user) {
return undefined;
}
const allTemplates = templatesStore.orderedData.filter(
(template) => template.isActive
);
const hasTemplates = allTemplates.some(
(template) =>
template.isWorkspaceTemplate || template.collectionId === collectionId
);
if (!hasTemplates) {
return undefined;
}
const toMenuItem = (template: (typeof allTemplates)[0]): MenuItem => ({
name: "noop",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
) : (
<DocumentIcon />
),
keywords: template.titleWithDefault,
onClick: () => {
const data = cloneDeep(template.data);
ProsemirrorHelper.replaceTemplateVariables(data, user);
editor.insertContent(data);
},
});
const children = (): MenuItem[] => {
const collectionTemplates = allTemplates.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === collectionId
);
const workspaceTemplates = allTemplates.filter(
(tmpl) => tmpl.isWorkspaceTemplate
);
const items: MenuItem[] = collectionTemplates.map(toMenuItem);
if (collectionTemplates.length && workspaceTemplates.length) {
items.push({ name: "separator" });
}
if (workspaceTemplates.length) {
for (const template of workspaceTemplates) {
items.push(toMenuItem(template));
}
}
return items;
};
return {
name: "noop",
title: t("Templates"),
icon: <ShapesIcon />,
keywords: "template",
children,
} satisfies MenuItem;
}, [user, templatesStore.orderedData, collectionId, editor, t]);
}
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
const templateMenuItem = useTemplateMenuItem();
const items = useMemo(() => {
const baseItems = getMenuItems(dictionary, elementRef);
if (!templateMenuItem) {
return baseItems;
}
return [...baseItems, { name: "separator" } as MenuItem, templateMenuItem];
}, [dictionary, elementRef, templateMenuItem]);
const renderMenuItem = useCallback(
(item, _index, options) => (
@@ -129,7 +20,6 @@ function BlockMenu(props: Props) {
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
disclosure={options.disclosure}
/>
),
[]
@@ -141,9 +31,9 @@ function BlockMenu(props: Props) {
filterable
trigger="/"
renderMenuItem={renderMenuItem}
items={items}
items={getMenuItems(dictionary, elementRef)}
/>
);
}
export default observer(BlockMenu);
export default BlockMenu;
+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;
+144 -166
View File
@@ -1,12 +1,7 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import {
DocumentIcon,
PlusIcon,
NewDocumentIcon,
CollectionIcon,
} from "outline-icons";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -87,171 +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,
{
name: "link",
icon: <NewDocumentIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a nested doc"),
visible: !!search && !isEmail(search) && !!documentId,
priority: -2,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
nested: true,
},
} as MentionItem,
])
: [];
} as MentionItem,
])
: [];
const handleSelect = useCallback(
async (item: MentionItem) => {
+6 -12
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isInternalUrl, isUrl } from "@shared/utils/urls";
import { isUrl } from "@shared/utils/urls";
import type Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -67,15 +67,11 @@ function useItems({
const singleUrl =
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
const isInternal = singleUrl ? isInternalUrl(singleUrl) : false;
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(() => {
if (!singleUrl || !embed || isInternal) {
if (!singleUrl || !embed) {
setEmbedCheck({ loading: false });
return;
}
@@ -102,7 +98,7 @@ function useItems({
return () => {
cancelled = true;
};
}, [singleUrl, embed, isInternal]);
}, [singleUrl, embed]);
// single item is pasted.
if (typeof pastedText === "string") {
@@ -144,10 +140,8 @@ function useItems({
name: "embed",
title: t("Embed"),
subtitle:
embedCheck.embeddable === false || isInternal
? t("Not supported")
: undefined,
disabled: isInternal || embedCheck.loading || !embedCheck.embeddable,
embedCheck.embeddable === false ? t("Not supported") : undefined,
disabled: embedCheck.loading || !embedCheck.embeddable,
icon: embed?.icon,
keywords: embed?.keywords,
},
+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;
}
`;
+248 -454
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,70 +92,25 @@ 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) {
// Save the selection position when the menu opens and as the user types.
// On mobile, the editor may lose focus/selection when tapping on menu
// items, so we restore it. The position must stay current as the search
// text grows, otherwise the deletion range calculated in handleClearSearch
// will be wrong.
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(() => {
const { from, to } = view.state.selection;
selectionRef.current = { from, to };
@@ -138,23 +119,83 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
selectionRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isActive, props.search]);
React.useEffect(() => {
setSubmenu(null);
if (!props.isActive) {
return;
}
setSelectedIndex(0);
setInsertItem(undefined);
}, [props.isActive]);
React.useEffect(() => {
setSelectedIndex(0);
setSubmenu(null);
}, [props.search]);
const calculatePosition = React.useCallback(
(props: Props) => {
if (!props.isActive) {
return defaultPosition;
}
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,
};
}
// 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),
};
};
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;
@@ -185,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;
@@ -213,9 +274,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
if (item.name === "noop") {
if ("onClick" in item) {
item.onClick?.();
}
// Do nothing
} else if (command) {
command(attrs);
} else {
@@ -245,13 +304,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
...item,
name: "mention",
});
void editorProps.onCreateLink?.(
{
title: item.attrs.label,
id: item.attrs.modelId,
},
!!item.attrs.nested
);
void editorProps.onCreateLink?.({
title: item.attrs.label,
id: item.attrs.modelId,
});
return;
case "image":
return triggerFilePick(
@@ -405,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,
@@ -425,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;
}
@@ -494,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(
@@ -517,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;
@@ -559,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) ||
@@ -725,25 +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;
@@ -760,7 +664,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const fileInput = uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Upload file</Trans>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
@@ -771,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;
@@ -806,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) &&
@@ -825,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 = () => {
@@ -855,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>
@@ -939,146 +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>
)}
</BouncyPopoverContent>
</Popover>
{fileInput}
{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;
`;
@@ -1095,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`
@@ -1123,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;
},
{
@@ -12,10 +12,6 @@ export default class ClipboardTextSerializer extends Extension {
return "clipboardTextSerializer";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const mdSerializer = this.editor.extensions.serializer();
-4
View File
@@ -33,10 +33,6 @@ export default class HoverPreviews extends Extension {
return "hover-previews";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const isHoverTarget = (target: Element | null, view: EditorView) =>
target instanceof HTMLElement &&
-4
View File
@@ -25,10 +25,6 @@ export default class Multiplayer extends Extension {
return "multiplayer";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
+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;
}
+32 -60
View File
@@ -133,10 +133,7 @@ export type Props = {
/** Callback when file upload progress changes */
onFileUploadProgress?: (id: string, fractionComplete: number) => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
onCreateLink?: (params: Properties<Document>) => Promise<string>;
/** Callback when user clicks on any link in the document */
onClickLink: (
href: string,
@@ -253,25 +250,17 @@ export class Editor extends React.PureComponent<
this.view.updateState(newState);
}
// When transitioning from readOnly to editable, reinitialize to create
// editing extensions, keymaps, input rules, and commands that were skipped.
if (prevProps.readOnly && !this.props.readOnly) {
const docJSON = this.view.state.doc.toJSON();
this.view.destroy();
this.init();
const newState = this.createState(docJSON);
this.view.updateState(newState);
} else if (!prevProps.readOnly && this.props.readOnly) {
// pass readOnly changes through to underlying editor instance
// pass readOnly changes through to underlying editor instance
if (prevProps.readOnly !== this.props.readOnly) {
this.view.update({
...this.view.props,
editable: () => false,
editable: () => !this.props.readOnly,
});
// NodeView will not automatically render when editable changes so we must trigger an update
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
Array.from(this.renderers).forEach((view) =>
view.setProp("isEditable", false)
view.setProp("isEditable", !this.props.readOnly)
);
}
@@ -312,24 +301,15 @@ export class Editor extends React.PureComponent<
this.nodes = this.createNodes();
this.marks = this.createMarks();
this.schema = this.createSchema();
this.widgets = this.createWidgets();
this.plugins = this.createPlugins();
this.rulePlugins = this.createRulePlugins();
this.keymaps = this.createKeymaps();
this.serializer = this.createSerializer();
this.parser = this.createParser();
this.pasteParser = this.createPasteParser();
this.inputRules = this.createInputRules();
this.nodeViews = this.createNodeViews();
this.widgets = this.createWidgets();
if (this.props.readOnly) {
this.keymaps = [];
this.inputRules = [];
this.pasteParser = this.parser;
} else {
this.keymaps = this.createKeymaps();
this.inputRules = this.createInputRules();
this.pasteParser = this.createPasteParser();
}
this.view = this.createView();
this.commands = this.createCommands();
}
@@ -431,20 +411,12 @@ export class Editor extends React.PureComponent<
private createState(value?: string | ProsemirrorData | ProsemirrorNode) {
const doc = this.createDocument(value || this.props.defaultValue);
if (this.props.readOnly) {
return EditorState.create({
schema: this.schema,
doc,
plugins: [...this.plugins, anchorPlugin()],
});
}
return EditorState.create({
schema: this.schema,
doc,
plugins: [
...this.plugins,
...this.keymaps,
...this.plugins,
anchorPlugin(),
dropCursor({
color: this.props.theme.cursor,
@@ -648,25 +620,12 @@ export class Editor extends React.PureComponent<
window?.getSelection()?.removeAllRanges();
};
/**
* Insert content into the editor, replacing the block at the current selection.
*
* @param content The prosemirror data to insert.
*/
public insertContent = (content: ProsemirrorData) => {
const doc = ProsemirrorNode.fromJSON(this.schema, content);
const { $from } = this.view.state.selection;
const start = $from.before($from.depth);
const end = $from.after($from.depth);
this.view.dispatch(this.view.state.tr.replaceWith(start, end, doc.content));
};
/**
* Insert files at the current selection.
*
* @param event The source event.
* @param files The files to insert.
* @returns True if the files were inserted.
* =
* @param event The source event
* @param files The files to insert
* @returns True if the files were inserted
*/
public insertFiles = (
event: React.ChangeEvent<HTMLInputElement>,
@@ -733,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;
}
@@ -754,7 +718,10 @@ export class Editor extends React.PureComponent<
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, attrs);
markRemoved = true;
}
return;
});
dispatch(tr);
@@ -772,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
);
@@ -786,6 +758,7 @@ export class Editor extends React.PureComponent<
...attrs,
});
tr.removeMark(from, to, mark).addMark(from, to, newMark);
markUpdated = true;
return;
}
@@ -801,7 +774,10 @@ export class Editor extends React.PureComponent<
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, newAttrs);
markUpdated = true;
}
return;
});
dispatch(tr);
@@ -935,11 +911,7 @@ const EditorContainer = styled(Styles)<{
css`
span#comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)};
text-decoration: underline 2px ${props.theme.commentMarkBackground};
* {
background: transparent !important;
}
border-bottom: 2px solid ${props.theme.commentMarkBackground};
}
a#comment-${props.focusedCommentId}
~ span.component-image
+1 -11
View File
@@ -1,4 +1,4 @@
import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons";
import { TrashIcon, DownloadIcon, ReplaceIcon } from "outline-icons";
import type { EditorState } from "prosemirror-state";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
@@ -17,9 +17,6 @@ export default function attachmentMenuItems(
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
preview: true,
});
const isPdfAttachment = isNodeActive(schema.nodes.attachment, {
contentType: "application/pdf",
});
return [
{
@@ -32,13 +29,6 @@ export default function attachmentMenuItems(
tooltip: dictionary.deleteAttachment,
icon: <TrashIcon />,
},
{
name: "toggleAttachmentPreview",
tooltip: dictionary.previewAttachment,
icon: <PDFIcon />,
active: isAttachmentWithPreview,
visible: isPdfAttachment(state),
},
{
name: "separator",
},
+6 -7
View File
@@ -126,7 +126,6 @@ export default function blockMenuItems(
accept: "application/pdf",
width: 300,
height: 424,
preview: true,
},
},
{
@@ -165,12 +164,6 @@ export default function blockMenuItems(
icon: <MathIcon />,
keywords: "math katex latex",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
{
name: "hr",
title: dictionary.hr,
@@ -250,6 +243,12 @@ export default function blockMenuItems(
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
keywords: "diagram flowchart draw.io",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
];
// Filter out diagrams.net in desktop app
+9 -22
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,
@@ -14,17 +13,13 @@ import {
import { isMermaid } from "@shared/editor/lib/isCode";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
export default function codeMenuItems(
state: EditorState,
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();
@@ -49,9 +44,6 @@ export default function codeMenuItems(
]
: remainingLangMenuItems;
const isEditingMermaid = !!(mermaidPluginKey.getState(state) as MermaidState)
?.editingId;
return [
{
name: "copyToClipboard",
@@ -61,22 +53,17 @@ export default function codeMenuItems(
: undefined,
tooltip: dictionary.copy,
},
{
name: "edit_mermaid",
icon: <EditIcon />,
tooltip: dictionary.editDiagram,
shortcut: `${metaDisplay} Enter`,
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
},
{
name: "separator",
},
{
name: "toggleCodeBlockWrap",
icon: <TextWrapIcon />,
tooltip: dictionary.wrapText,
active: () => node.attrs.wrap,
visible: !readOnly && (!isMermaid(node) || isEditingMermaid),
name: "edit_mermaid",
icon: <EditIcon />,
tooltip: dictionary.editDiagram,
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 -4
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"),
@@ -32,7 +33,6 @@ export default function useDictionary() {
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
collapseCode: t("Collapse"),
comment: t("Comment"),
copy: t("Copy"),
createLink: t("Create link"),
@@ -45,7 +45,6 @@ export default function useDictionary() {
deleteRow: t("Delete"),
deleteTable: t("Delete table"),
deleteAttachment: t("Delete file"),
previewAttachment: t("Show preview"),
dimensions: `${t("Width")} × ${t("Height")}`,
download: t("Download"),
downloadAttachment: t("Download file"),
@@ -55,7 +54,6 @@ export default function useDictionary() {
replaceImage: t("Replace image"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
expandCode: t("Expand"),
file: t("File attachment"),
pdf: t("Embed PDF"),
enterLink: `${t("Enter a link")}`,
@@ -96,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"),
@@ -126,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]
);
-4
View File
@@ -24,10 +24,8 @@ import {
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
downloadDocument,
copyDocument,
presentDocument,
printDocument,
searchInDocument,
deleteDocument,
@@ -108,8 +106,6 @@ export function useDocumentMenuAction({
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
presentDocument,
downloadDocument,
copyDocument,
printDocument,
+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]
);
}
+22 -48
View File
@@ -1,11 +1,11 @@
import * as React from "react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import type Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -16,35 +16,27 @@ import {
} from "~/actions";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import { settingsPath } from "~/utils/routeHelpers";
interface Options {
/** Whether to hide the "Members" navigation action. */
hideMembers?: boolean;
}
/**
* Hook that constructs the action menu for group management operations.
*
*
* @param targetGroup - the group to build actions for, or null to skip.
* @param options - optional configuration for the menu.
* @returns action with children for use in menus, or undefined if group is null.
*/
export function useGroupMenuActions(
targetGroup: Group | null,
options?: Options
) {
export function useGroupMenuActions(targetGroup: Group | null) {
const { t } = useTranslation();
const { dialogs } = useStores();
const history = useHistory();
const can = usePolicy(targetGroup ?? ({} as Group));
const navigateToMembers = React.useCallback(() => {
const openMembersDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
history.push(settingsPath("groups", targetGroup.id, "members"));
}, [targetGroup, history]);
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={targetGroup} />,
});
}, [t, targetGroup, dialogs]);
const openEditDialog = React.useCallback(() => {
if (!targetGroup) {
@@ -53,10 +45,7 @@ export function useGroupMenuActions(
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetGroup, dialogs]);
@@ -68,10 +57,7 @@ export function useGroupMenuActions(
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetGroup, dialogs]);
@@ -81,30 +67,26 @@ export function useGroupMenuActions(
!targetGroup
? []
: [
...(options?.hideMembers
? []
: [
createAction({
name: t("Members"),
icon: <GroupIcon />,
section: GroupSection,
visible: can.read,
perform: navigateToMembers,
}),
ActionSeparator,
]),
createAction({
name: `${t("Members")}`,
icon: <GroupIcon />,
section: GroupSection,
visible: !!(targetGroup && can.read),
perform: openMembersDialog,
}),
ActionSeparator,
createAction({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: can.update,
visible: !!(targetGroup && can.update),
perform: openEditDialog,
}),
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: can.delete,
visible: !!(targetGroup && can.delete),
dangerous: true,
perform: openDeleteDialog,
}),
@@ -116,13 +98,6 @@ export function useGroupMenuActions(
disabled: true,
url: "",
}),
createExternalLinkAction({
name: `External ID: ${targetGroup.externalGroup?.externalId ?? ""}`,
section: GroupSection,
visible: !!targetGroup.externalGroup?.externalId,
disabled: true,
url: "",
}),
],
[
t,
@@ -130,8 +105,7 @@ export function useGroupMenuActions(
can.read,
can.update,
can.delete,
options?.hideMembers,
navigateToMembers,
openMembersDialog,
openEditDialog,
openDeleteDialog,
]
+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;
}
-1
View File
@@ -39,7 +39,6 @@ export const useLocaleTime = ({
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
de_DE: "d. MMMM yyyy 'um' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
+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 -4
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";
@@ -11,7 +11,6 @@ import { Router } from "react-router-dom";
import stores from "~/stores";
import Analytics from "~/components/Analytics";
import Dialogs from "~/components/Dialogs";
import Presentation from "~/components/Presentation";
import ErrorBoundary from "~/components/ErrorBoundary";
import PageTheme from "~/components/PageTheme";
import ScrollToTop from "~/components/ScrollToTop";
@@ -46,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,
@@ -65,7 +67,7 @@ if (element) {
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={domMax}>
<LazyMotion features={loadFeatures}>
<PageScroll>
<PageTheme />
<ScrollToTop>
@@ -73,7 +75,6 @@ if (element) {
</ScrollToTop>
<Toasts />
<Dialogs />
<Presentation />
<Desktop />
</PageScroll>
</LazyMotion>
+2 -4
View File
@@ -8,13 +8,11 @@ import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
type Props = {
group: Group;
/** Whether to hide the "Members" navigation action. */
hideMembers?: boolean;
};
function GroupMenu({ group, hideMembers }: Props) {
function GroupMenu({ group }: Props) {
const { t } = useTranslation();
const rootAction = useGroupMenuActions(group, { hideMembers });
const rootAction = useGroupMenuActions(group);
return (
<DropdownMenu
+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),
});
}),
-9
View File
@@ -1,5 +1,4 @@
import { computed, observable } from "mobx";
import type { AuthenticationProviderSettings } from "@shared/types";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterDelete } from "./decorators/Lifecycle";
@@ -14,10 +13,6 @@ class AuthenticationProvider extends Model {
providerId: string;
groupSyncSupported: boolean;
groupSyncUsesClaim: boolean;
@observable
isConnected: boolean;
@@ -25,10 +20,6 @@ class AuthenticationProvider extends Model {
@observable
isEnabled: boolean;
@Field
@observable
settings: AuthenticationProviderSettings | undefined;
@computed
get isActive() {
return this.isEnabled && this.isConnected;
+8 -6
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";
@@ -67,11 +68,6 @@ export default class Collection extends ParanoidModel {
direction: "asc" | "desc";
};
/** The minimum permission level required to manage templates in this collection. */
@Field
@observable
templateManagement: CollectionPermission;
/**
* Whether commenting is enabled for the collection.
*/
@@ -129,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
-27
View File
@@ -5,22 +5,6 @@ import Field from "./decorators/Field";
import { GroupPermission } from "@shared/types";
import type { Searchable } from "./interfaces/Searchable";
/**
* Information about a group that is managed by an external provider.
*/
interface ExternalGroupInfo {
/** The unique identifier of the external group record in Outline. */
id: string;
/** The unique identifier of the group in the external provider. */
externalId: string;
/** The name of the external provider (e.g. google, slack, azure). */
provider: string;
/** The display name of the group in the external provider. */
displayName: string;
/** The date and time the group was last synced from the external provider. */
lastSyncedAt: string | null;
}
class Group extends Model implements Searchable {
static modelName = "Group";
@@ -42,17 +26,6 @@ class Group extends Model implements Searchable {
@observable
disableMentions: boolean;
@observable
externalGroup: ExternalGroupInfo | undefined;
/**
* Whether this group's membership is managed by an external authentication provider.
*/
@computed
get isExternallyManaged(): boolean {
return !!this.externalGroup;
}
/**
* Returns the users that are members of this group.
*/
+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)
);
+4 -12
View File
@@ -1,15 +1,12 @@
import { Switch } from "react-router-dom";
import Error404 from "~/scenes/Errors/Error404";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import { observer } from "mobx-react";
const Application = lazy(() => import("~/scenes/Settings/Application"));
const GroupMembers = lazy(() => import("~/scenes/Settings/GroupMembers"), {
exportName: "GroupMembersScene",
});
const Template = lazy(() => import("~/scenes/Settings/Template"));
const TemplateNew = lazy(() => import("~/scenes/Settings/TemplateNew"));
@@ -27,25 +24,20 @@ function SettingsRoutes() {
/>
))}
{/* TODO: Refactor these exceptions into config? */}
<Route
exact
path={settingsPath("groups", ":id", "members")}
component={GroupMembers.Component}
/>
<Route
exact
path={settingsPath("applications", ":id")}
component={Application.Component}
component={Application}
/>
<Route
exact
path={settingsPath("templates", "new")}
component={TemplateNew.Component}
component={TemplateNew}
/>
<Route
exact
path={settingsPath("templates", ":id")}
component={Template.Component}
component={Template}
/>
<Route component={Error404} />
</Switch>
+2 -9
View File
@@ -66,12 +66,7 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
shortcut="e"
placement="bottom"
>
<Button
icon={<EditIcon />}
onClick={goToEdit}
haptic="light"
neutral
>
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
{t("Edit")}
</Button>
</Tooltip>
@@ -80,9 +75,7 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
{isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="Escape" handler={goBack} />
<Button onClick={goBack} haptic="medium">
{t("Done editing")}
</Button>
<Button onClick={goBack}>{t("Done editing")}</Button>
</Action>
)}
{can.createDocument && (
+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>
);
});
@@ -61,7 +61,7 @@ function Overview({ collection, readOnly }: Props) {
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
@@ -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>
</>
)}
</>

Some files were not shown because too many files have changed in this diff Show More