mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c0f375901 | |||
| 4894e92d6b | |||
| 5e6eebd0ec | |||
| a4badbea9c | |||
| f22bc4a0b2 | |||
| 5693618de4 | |||
| a0039b2a09 | |||
| fa17f78ae6 | |||
| beec9f5675 | |||
| 5256cdc185 | |||
| 1bd6ad830e | |||
| 9efcb2d534 | |||
| 14fc3b01db | |||
| 05eac5bc3b | |||
| 64dc5e8ea7 | |||
| f03ac1f8de | |||
| 07099bb4f6 | |||
| 4673ff0840 | |||
| 500c3f91b0 | |||
| f8098ab464 | |||
| 3740e09e5c | |||
| 62cfd4e9bc | |||
| 85072dab92 | |||
| 1e8d9b5f80 | |||
| 613877714b | |||
| cc1c4b22d4 | |||
| a9401c9bb6 | |||
| 1345471338 | |||
| 0ddddac9c9 | |||
| 24954204ea | |||
| 1a893b0e45 | |||
| 255efe9844 | |||
| 20e55141de | |||
| 9940f48efa | |||
| b1a192c078 | |||
| 22138957ab | |||
| ff0a1766f8 | |||
| d1203408b5 | |||
| 576117e27b | |||
| 4bc0f15323 | |||
| 36d555f3fb | |||
| 350f69e194 | |||
| a92a1785ff | |||
| 631a4b0efa | |||
| 52077e4d47 | |||
| 79fc0b90b9 | |||
| ea4fbdb7bb | |||
| 88f7ef9d03 | |||
| 951fb8a34a | |||
| 0b5bd31017 | |||
| 48c7bd990a | |||
| 54f2994b13 | |||
| 8d9cd25b4e | |||
| 16a4b8417e | |||
| c993305c1b | |||
| 70891d5fa7 | |||
| 89511d4026 | |||
| bd573c44c1 | |||
| 2e50fb0344 | |||
| fee9791cc9 | |||
| e913075d75 | |||
| bb3d72cb83 | |||
| 0d8d9a1798 | |||
| 0c6e4f349b | |||
| a8b701aff3 | |||
| 83977f85bd | |||
| 9f1e6d8b40 | |||
| 257d01af89 | |||
| 1a54625cdb | |||
| 1a380f844c | |||
| 03a78ab6d6 | |||
| b63225fa73 | |||
| 3066b7ba4e | |||
| aeb6d12f17 | |||
| db19a5cf0d | |||
| c875930430 | |||
| 3d1c2a8759 | |||
| 2681a2cfaf | |||
| 1b88189f9c | |||
| ace351035a | |||
| 3222a77cc3 | |||
| d8b0e731ef | |||
| 80c1e5a10b | |||
| 5b89882e5e | |||
| f85ad1a7e1 | |||
| 38a3e651a7 |
+2
-3
@@ -129,9 +129,8 @@ FORCE_HTTPS=true
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# 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.
|
||||
# Third party signin credentials, at least ONE OF these 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,3 +1,6 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 86400
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- outline-icons
|
||||
|
||||
@@ -70,7 +70,7 @@ yarn install
|
||||
### Exports
|
||||
|
||||
- Exported members must appear at the top of the file.
|
||||
- Prefer named exports for components & classes.
|
||||
- Always use named exports for new components & classes.
|
||||
- Document ALL public/exported functions with JSDoc.
|
||||
|
||||
## React Usage
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.5.0
|
||||
Licensed Work: Outline 1.6.1
|
||||
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-02-15
|
||||
Change Date: 2030-03-18
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -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 – we'd love your help to fix bugs and add features!
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
CaseSensitiveIcon,
|
||||
RestoreIcon,
|
||||
EditIcon,
|
||||
EmbedIcon,
|
||||
OpenIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
@@ -73,6 +75,7 @@ import {
|
||||
searchPath,
|
||||
documentPath,
|
||||
urlify,
|
||||
desktopify,
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
@@ -86,6 +89,8 @@ 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(
|
||||
@@ -335,8 +340,15 @@ export const createNewDocument = createActionWithChildren({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
|
||||
});
|
||||
|
||||
@@ -565,7 +577,10 @@ export const shareDocument = createAction({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId!);
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const can = stores.policies.abilities(activeDocumentId);
|
||||
return can.manageUsers || can.share;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -944,6 +959,49 @@ export const printDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
@@ -1487,11 +1545,13 @@ export const rootDocumentActions = [
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
permanentlyDeleteDocumentsInTrash,
|
||||
presentDocument,
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
shareDocument,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
@@ -57,15 +57,17 @@ 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>
|
||||
|
||||
@@ -3,6 +3,8 @@ 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";
|
||||
@@ -152,6 +154,8 @@ 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;
|
||||
@@ -176,11 +180,13 @@ 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
|
||||
@@ -191,6 +197,7 @@ 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}>
|
||||
|
||||
@@ -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 type { CollectionPermission } from "@shared/types";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { CollectionPermission, TeamPreference } from "@shared/types";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
@@ -34,6 +35,7 @@ export interface FormData {
|
||||
sharing: boolean;
|
||||
permission: CollectionPermission | undefined;
|
||||
commenting?: boolean | null;
|
||||
templateManagement: CollectionPermission;
|
||||
}
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
@@ -68,6 +70,22 @@ 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
|
||||
@@ -93,6 +111,8 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
sharing: collection?.sharing ?? true,
|
||||
permission: collection?.permission,
|
||||
commenting: collection?.commenting ?? true,
|
||||
templateManagement:
|
||||
collection?.templateManagement ?? CollectionPermission.Admin,
|
||||
color: iconColor,
|
||||
},
|
||||
});
|
||||
@@ -135,6 +155,71 @@ 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">
|
||||
@@ -190,44 +275,10 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(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>
|
||||
{collection ? (
|
||||
options
|
||||
) : (
|
||||
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
|
||||
@@ -128,7 +128,14 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
setInnerValue(value);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ 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";
|
||||
@@ -42,6 +41,28 @@ 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,
|
||||
@@ -67,8 +88,6 @@ 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) {
|
||||
@@ -91,9 +110,6 @@ 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"], {
|
||||
@@ -144,7 +160,8 @@ function DocumentExplorer({
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, [defaultValue, selectedNode, nodes]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultValue]);
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
@@ -152,17 +169,9 @@ function DocumentExplorer({
|
||||
const normalizedBaseDepth =
|
||||
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
if (itemRefs[node] && itemRefs[node].current) {
|
||||
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
},
|
||||
[itemRefs]
|
||||
);
|
||||
const scrollNodeIntoView = React.useCallback((node: number) => {
|
||||
listRef.current?.scrollToItem(node, "smart");
|
||||
}, []);
|
||||
|
||||
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(ev.target.value);
|
||||
@@ -170,16 +179,16 @@ function DocumentExplorer({
|
||||
|
||||
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
|
||||
|
||||
const calculateInitialScrollOffset = (itemCount: number) => {
|
||||
const preserveScrollOffset = (itemCount: number) => {
|
||||
if (listRef.current) {
|
||||
const { height, itemSize } = listRef.current.props;
|
||||
const { scrollOffset } = listRef.current.state as {
|
||||
scrollOffset: number;
|
||||
};
|
||||
const itemsHeight = itemCount * itemSize;
|
||||
return itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
setTimeout(() => listRef.current?.scrollTo(offset), 0);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const collapse = (node: number) => {
|
||||
@@ -190,8 +199,7 @@ function DocumentExplorer({
|
||||
|
||||
// remove children
|
||||
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
preserveScrollOffset(newNodes.length);
|
||||
};
|
||||
|
||||
const expand = (node: number) => {
|
||||
@@ -200,8 +208,7 @@ function DocumentExplorer({
|
||||
// add children
|
||||
const newNodes = nodes.slice();
|
||||
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
preserveScrollOffset(newNodes.length);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -225,7 +232,8 @@ function DocumentExplorer({
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || showDocuments !== false;
|
||||
nodes[node].children.length > 0 ||
|
||||
(showDocuments !== false && nodes[node].type === "collection");
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -387,25 +395,6 @@ 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
|
||||
@@ -425,14 +414,12 @@ 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,10 +40,8 @@ function DocumentExplorerNode(
|
||||
ref: React.RefObject<HTMLSpanElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const OFFSET = 12;
|
||||
const DISCLOSURE = 20;
|
||||
|
||||
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
|
||||
const DISCLOSURE = 24;
|
||||
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
@@ -80,7 +78,7 @@ const Title = styled(Text)`
|
||||
const StyledDisclosure = styled(Disclosure)`
|
||||
position: relative;
|
||||
left: auto;
|
||||
margin-top: 2px;
|
||||
margin: 2px 0;
|
||||
`;
|
||||
|
||||
const Spacer = styled(Flex)<{ width: number }>`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
@@ -32,22 +31,8 @@ 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}
|
||||
|
||||
@@ -22,6 +22,7 @@ import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
@@ -58,6 +59,7 @@ function DocumentListItem(
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMobile = useMobile();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
@@ -159,7 +161,7 @@ function DocumentListItem(
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && <StarButton document={document} />}
|
||||
{canStar && !isMobile && <StarButton document={document} />}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
|
||||
@@ -88,6 +88,7 @@ function Header(
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
haptic="light"
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
|
||||
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Description = styled(StyledText)`
|
||||
export const Description = styled(StyledText)<{ $margin?: string }>`
|
||||
${sharedVars}
|
||||
margin-top: 0.5em;
|
||||
margin-top: ${(props) => props.$margin ?? "0.5em"};
|
||||
line-height: var(--line-height);
|
||||
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
|
||||
overflow: hidden;
|
||||
@@ -64,8 +64,6 @@ 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;
|
||||
|
||||
@@ -75,8 +73,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) =>
|
||||
props.color || props.theme.backgroundSecondary};
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
@@ -192,6 +193,18 @@ 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>
|
||||
<Flex wrap gap={6} style={{ marginTop: 8 }}>
|
||||
{labels.map((label, index) => (
|
||||
<Label key={index} color={label.color}>
|
||||
{label.name}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
+22
-17
@@ -9,39 +9,44 @@ 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 component (eg: () => import('./MyComponent'))
|
||||
* @param options Optional configuration for retry behavior
|
||||
* @returns An object containing the lazy Component and a preload function
|
||||
* @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.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Default export
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <Suspense fallback={<div>Loading...</div>}>
|
||||
* <MyComponent.Component />
|
||||
* </Suspense>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Preload when needed:
|
||||
* MyComponent.preload();
|
||||
* // Named export
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
|
||||
* exportName: 'MyComponent',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
factory: () => Promise<Record<string, T>>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
const { retries, interval } = options;
|
||||
const { retries, interval, exportName } = options;
|
||||
|
||||
const wrappedFactory = exportName
|
||||
? () =>
|
||||
factory().then((m) => ({
|
||||
default: m[exportName],
|
||||
}))
|
||||
: (factory as () => Promise<{ default: T }>);
|
||||
|
||||
return {
|
||||
Component: lazyWithRetry(factory, retries, interval),
|
||||
preload: factory,
|
||||
Component: lazyWithRetry(wrappedFactory, retries, interval),
|
||||
preload: wrappedFactory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import type { ActionVariant, ActionWithChildren } from "~/types";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
@@ -61,11 +62,6 @@ export const ContextMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -80,7 +76,7 @@ export const ContextMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type {
|
||||
ActionVariant,
|
||||
ActionWithChildren,
|
||||
@@ -98,11 +99,6 @@ export const DropdownMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
@@ -129,7 +125,7 @@ export const DropdownMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -14,7 +15,7 @@ interface CacheEntry {
|
||||
// Cache configuration
|
||||
const cacheTTL = Minute.ms * 5;
|
||||
|
||||
export default function SearchActions() {
|
||||
function SearchActions() {
|
||||
const { searches, documents } = useStores();
|
||||
|
||||
// Cache structure: Map of search queries to timestamp of last search
|
||||
@@ -58,3 +59,5 @@ export default function SearchActions() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(SearchActions);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type { SearchResult } from "~/types";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
@@ -230,7 +231,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
align="start"
|
||||
shrink
|
||||
onEscapeKeyDown={handleEscapeList}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onOpenAutoFocus={preventDefault}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target === searchInputRef.current) {
|
||||
|
||||
@@ -89,7 +89,11 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
const members = React.useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
document.members,
|
||||
Array.from(
|
||||
new Map(
|
||||
document.members.map((memberUser) => [memberUser.id, memberUser])
|
||||
).values()
|
||||
),
|
||||
(memberUser) =>
|
||||
(invitedInSession.includes(memberUser.id) ? "_" : "") +
|
||||
memberUser.name.toLocaleLowerCase(),
|
||||
@@ -124,12 +128,19 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupMemberships
|
||||
.inDocument(document.id)
|
||||
{Array.from(
|
||||
new Map(
|
||||
groupMemberships
|
||||
.inDocument(document.id)
|
||||
.map((membership) => [membership.group.id, membership])
|
||||
).values()
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
).localeCompare(
|
||||
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
|
||||
)
|
||||
)
|
||||
.map((membership) => {
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
|
||||
@@ -76,7 +76,8 @@ function SettingsSidebar() {
|
||||
to={item.path}
|
||||
onClickIntent={item.preload}
|
||||
active={
|
||||
item.path.startsWith(settingsPath("templates"))
|
||||
item.path.startsWith(settingsPath("templates")) ||
|
||||
item.path.startsWith(settingsPath("groups"))
|
||||
? location.pathname.startsWith(item.path)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -53,6 +54,7 @@ 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);
|
||||
@@ -224,6 +226,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
[width]
|
||||
);
|
||||
|
||||
const handleCloseSidebar = () => {
|
||||
trigger("light");
|
||||
ui.toggleMobileSidebar();
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Container
|
||||
@@ -275,7 +282,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={handleCloseSidebar} />}
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ function SidebarLink(
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
$disabled={disabled}
|
||||
style={style}
|
||||
style={active ? activeStyle : style}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
onClick={handleClick}
|
||||
onActiveClick={handleDisclosureClick}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Toaster } from "sonner";
|
||||
import * as React from "react";
|
||||
import { Toaster, useSonner } 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,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 } from "@shared/styles";
|
||||
import { s, depths } 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: 9999;
|
||||
z-index: ${depths.tooltip};
|
||||
max-width: calc(100vw - 10px);
|
||||
|
||||
/* Animation */
|
||||
|
||||
@@ -129,7 +129,7 @@ const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
|
||||
`}
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
!props.disabled &&
|
||||
`
|
||||
&[data-highlighted],
|
||||
&[data-state="open"],
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
|
||||
@@ -1,17 +1,126 @@
|
||||
import { useCallback } from "react";
|
||||
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 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) => (
|
||||
@@ -20,6 +129,7 @@ function BlockMenu(props: Props) {
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
disclosure={options.disclosure}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
@@ -31,9 +141,9 @@ function BlockMenu(props: Props) {
|
||||
filterable
|
||||
trigger="/"
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={getMenuItems(dictionary, elementRef)}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockMenu;
|
||||
export default observer(BlockMenu);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
|
||||
import {
|
||||
DocumentIcon,
|
||||
PlusIcon,
|
||||
NewDocumentIcon,
|
||||
CollectionIcon,
|
||||
} from "outline-icons";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -227,6 +232,24 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
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,
|
||||
])
|
||||
: [];
|
||||
|
||||
|
||||
@@ -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 { isUrl } from "@shared/utils/urls";
|
||||
import { isInternalUrl, isUrl } from "@shared/utils/urls";
|
||||
import type Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -67,6 +67,7 @@ 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;
|
||||
@@ -74,7 +75,7 @@ function useItems({
|
||||
|
||||
// Check embeddability for single URL
|
||||
useEffect(() => {
|
||||
if (!singleUrl || !embed) {
|
||||
if (!singleUrl || !embed || isInternal) {
|
||||
setEmbedCheck({ loading: false });
|
||||
return;
|
||||
}
|
||||
@@ -101,7 +102,7 @@ function useItems({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [singleUrl, embed]);
|
||||
}, [singleUrl, embed, isInternal]);
|
||||
|
||||
// single item is pasted.
|
||||
if (typeof pastedText === "string") {
|
||||
@@ -143,8 +144,10 @@ function useItems({
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
subtitle:
|
||||
embedCheck.embeddable === false ? t("Not supported") : undefined,
|
||||
disabled: embedCheck.loading || !embedCheck.embeddable,
|
||||
embedCheck.embeddable === false || isInternal
|
||||
? t("Not supported")
|
||||
: undefined,
|
||||
disabled: isInternal || embedCheck.loading || !embedCheck.embeddable,
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
|
||||
@@ -6,21 +6,26 @@ import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { MouseSafeArea } from "~/components/MouseSafeArea";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -29,38 +34,6 @@ import { useEditor } from "./EditorContext";
|
||||
import Input from "./Input";
|
||||
import { MenuHeader } from "~/components/primitives/components/Menu";
|
||||
|
||||
type TopAnchor = {
|
||||
top: number;
|
||||
bottom: undefined;
|
||||
};
|
||||
|
||||
type BottomAnchor = {
|
||||
top: undefined;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
type LeftAnchor = {
|
||||
left: number;
|
||||
right: undefined;
|
||||
};
|
||||
|
||||
type RightAnchor = {
|
||||
left: undefined;
|
||||
right: number;
|
||||
};
|
||||
|
||||
type Position = ((TopAnchor | BottomAnchor) & (LeftAnchor | RightAnchor)) & {
|
||||
isAbove: boolean;
|
||||
};
|
||||
|
||||
const defaultPosition: Position = {
|
||||
top: 0,
|
||||
bottom: undefined,
|
||||
left: -10000,
|
||||
right: undefined,
|
||||
isAbove: false,
|
||||
};
|
||||
|
||||
export type Props<T extends MenuItem = MenuItem> = {
|
||||
rtl: boolean;
|
||||
isActive: boolean;
|
||||
@@ -80,6 +53,7 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
index: number,
|
||||
options: {
|
||||
selected: boolean;
|
||||
disclosure?: boolean;
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
) => React.ReactNode;
|
||||
@@ -92,25 +66,70 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const dictionary = useDictionary();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const hasActivated = React.useRef(false);
|
||||
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
});
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
|
||||
const [position, setPosition] = React.useState<Position>(defaultPosition);
|
||||
const [insertItem, setInsertItem] = React.useState<
|
||||
MenuItem | EmbedDescriptor
|
||||
>();
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [submenu, setSubmenu] = React.useState<{
|
||||
index: number;
|
||||
items: MenuItem[];
|
||||
selectedIndex: number;
|
||||
} | null>(null);
|
||||
const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map());
|
||||
const submenuContentRef = React.useRef<HTMLDivElement>(null);
|
||||
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Stores the caret bounding rect, snapshotted when the menu opens
|
||||
const caretRectRef = React.useRef(new DOMRect());
|
||||
|
||||
// Stable virtual element for Radix PopoverAnchor – never replaced so the
|
||||
// popper does not trigger unnecessary anchor-change cycles.
|
||||
const caretRef = React.useRef({
|
||||
getBoundingClientRect: () => caretRectRef.current,
|
||||
});
|
||||
|
||||
// Compute and store the caret rect during render so it is available before
|
||||
// the Radix popper effect runs for the first time.
|
||||
const caretRect = React.useMemo(() => {
|
||||
if (!props.isActive) {
|
||||
return new DOMRect();
|
||||
}
|
||||
|
||||
try {
|
||||
const { selection } = view.state;
|
||||
const fromPos = view.coordsAtPos(selection.from);
|
||||
const toPos = view.coordsAtPos(selection.to, -1);
|
||||
const top = Math.min(fromPos.top, toPos.top);
|
||||
const bottom = Math.max(fromPos.bottom, toPos.bottom);
|
||||
const left = Math.min(fromPos.left, toPos.left);
|
||||
const right = Math.max(fromPos.right, toPos.right);
|
||||
return new DOMRect(left, top, right - left, bottom - top);
|
||||
} catch (err) {
|
||||
Logger.warn("Unable to calculate caret position", err);
|
||||
return new DOMRect();
|
||||
}
|
||||
}, [props.isActive, view]);
|
||||
|
||||
caretRectRef.current = caretRect;
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem["children"]
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.isActive) {
|
||||
hasActivated.current = true;
|
||||
// Save the selection position when the menu opens. On mobile, the editor
|
||||
// may lose focus/selection when tapping on menu items, so we restore it.
|
||||
// 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.
|
||||
requestAnimationFrame(() => {
|
||||
const { from, to } = view.state.selection;
|
||||
selectionRef.current = { from, to };
|
||||
@@ -119,83 +138,23 @@ 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]);
|
||||
|
||||
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]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setSubmenu(null);
|
||||
}, [props.search]);
|
||||
|
||||
const handleClearSearch = React.useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -226,26 +185,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
);
|
||||
}, [props.search, props.trigger, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset scroll position to top when opening menu as the contents are
|
||||
// hidden, not unrendered
|
||||
if (menuRef.current) {
|
||||
menuRef.current.scroll({ top: 0 });
|
||||
}
|
||||
|
||||
setPosition(calculatePosition(props));
|
||||
setSelectedIndex(0);
|
||||
setInsertItem(undefined);
|
||||
}, [calculatePosition, props.isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [props.search]);
|
||||
|
||||
const restoreSelection = React.useCallback(() => {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
@@ -274,7 +213,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
|
||||
|
||||
if (item.name === "noop") {
|
||||
// Do nothing
|
||||
if ("onClick" in item) {
|
||||
item.onClick?.();
|
||||
}
|
||||
} else if (command) {
|
||||
command(attrs);
|
||||
} else {
|
||||
@@ -304,10 +245,13 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
...item,
|
||||
name: "mention",
|
||||
});
|
||||
void editorProps.onCreateLink?.({
|
||||
title: item.attrs.label,
|
||||
id: item.attrs.modelId,
|
||||
});
|
||||
void editorProps.onCreateLink?.(
|
||||
{
|
||||
title: item.attrs.label,
|
||||
id: item.attrs.modelId,
|
||||
},
|
||||
!!item.attrs.nested
|
||||
);
|
||||
return;
|
||||
case "image":
|
||||
return triggerFilePick(
|
||||
@@ -481,11 +425,47 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
|
||||
const searchInput = search.toLowerCase();
|
||||
|
||||
const matchesSearch = (item: MenuItem | EmbedDescriptor) =>
|
||||
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.keywords || "").toLocaleLowerCase().includes(searchInput);
|
||||
|
||||
// When searching, flatten matching children into the top-level list so
|
||||
// they are directly navigable with the keyboard. If all children match,
|
||||
// exclude the parent item since it would be redundant.
|
||||
const fullyFlattenedParents = new Set<MenuItem | EmbedDescriptor>();
|
||||
if (search && filterable) {
|
||||
const flattened: (EmbedDescriptor | MenuItem)[] = [];
|
||||
for (const item of items) {
|
||||
if ("children" in item && item.children) {
|
||||
const children = resolveChildren(item.children);
|
||||
if (children) {
|
||||
const matching = children.filter(matchesSearch);
|
||||
if (matching.length > 0) {
|
||||
for (const child of matching) {
|
||||
const { children: _, ...flat } = child;
|
||||
flattened.push(flat);
|
||||
}
|
||||
if (matching.length === children.length) {
|
||||
fullyFlattenedParents.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items = items.concat(flattened);
|
||||
}
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
if (item.name === "separator") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fullyFlattenedParents.has(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.visible === false) {
|
||||
return false;
|
||||
}
|
||||
@@ -514,11 +494,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return (
|
||||
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.keywords || "").toLocaleLowerCase().includes(searchInput)
|
||||
);
|
||||
return matchesSearch(item);
|
||||
});
|
||||
|
||||
return filterExcessSeparators(
|
||||
@@ -541,18 +517,40 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
);
|
||||
}, [commands, props]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
!menuRef.current ||
|
||||
menuRef.current.contains(event.target as Element)
|
||||
) {
|
||||
const openSubmenu = React.useCallback(
|
||||
(index: number) => {
|
||||
const item = filtered[index];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (!children?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onClose();
|
||||
};
|
||||
const normalized = filterExcessSeparators(
|
||||
children.filter((child) => child.visible !== false)
|
||||
);
|
||||
const firstSelectable = normalized.findIndex(
|
||||
(child) =>
|
||||
child.name !== "separator" && !("disabled" in child && child.disabled)
|
||||
);
|
||||
if (firstSelectable === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmenu({
|
||||
index,
|
||||
items: normalized,
|
||||
selectedIndex: firstSelectable,
|
||||
});
|
||||
},
|
||||
[filtered]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
@@ -561,18 +559,109 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the link input's own handlers manage navigation keys
|
||||
if (insertItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Submenu open: route keys into it ---
|
||||
if (submenu) {
|
||||
if (event.key === "ArrowDown" || (event.ctrlKey && event.key === "n")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const total = submenu.items.length - 1;
|
||||
let next = submenu.selectedIndex + 1;
|
||||
while (next <= total) {
|
||||
const child = submenu.items[next];
|
||||
if (
|
||||
child?.name !== "separator" &&
|
||||
!("disabled" in child && child.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
next++;
|
||||
}
|
||||
if (next <= total) {
|
||||
setSubmenu((s) => (s ? { ...s, selectedIndex: next } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let prev = submenu.selectedIndex - 1;
|
||||
while (prev >= 0) {
|
||||
const child = submenu.items[prev];
|
||||
if (
|
||||
child?.name !== "separator" &&
|
||||
!("disabled" in child && child.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
prev--;
|
||||
}
|
||||
if (prev >= 0) {
|
||||
setSubmenu((s) => (s ? { ...s, selectedIndex: prev } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSubmenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const child = submenu.items[submenu.selectedIndex];
|
||||
if (child) {
|
||||
handleClickItem(child);
|
||||
setSubmenu(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Normal (no submenu) ---
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
const item = filtered[selectedIndex];
|
||||
|
||||
if (item) {
|
||||
handleClickItem(item);
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (children?.length) {
|
||||
openSubmenu(selectedIndex);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
} else {
|
||||
props.onClose(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
const item = filtered[selectedIndex];
|
||||
if (item) {
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (children?.length) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openSubmenu(selectedIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "ArrowUp" ||
|
||||
(event.key === "Tab" && event.shiftKey) ||
|
||||
@@ -636,18 +725,25 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("keydown", handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("keydown", handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [close, filtered, handleClickItem, props, selectedIndex]);
|
||||
}, [
|
||||
close,
|
||||
filtered,
|
||||
handleClickItem,
|
||||
insertItem,
|
||||
openSubmenu,
|
||||
props,
|
||||
selectedIndex,
|
||||
submenu,
|
||||
]);
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
@@ -664,7 +760,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const fileInput = uploadFile && (
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
<Trans>Import document</Trans>
|
||||
<Trans>Upload file</Trans>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
@@ -675,6 +771,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
</VisuallyHidden.Root>
|
||||
);
|
||||
|
||||
// Close submenu when parent selection moves away from the trigger
|
||||
React.useEffect(() => {
|
||||
if (submenu && submenu.index !== selectedIndex) {
|
||||
setSubmenu(null);
|
||||
}
|
||||
}, [selectedIndex, submenu]);
|
||||
|
||||
// Cleanup hover timer on unmount
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderItems = () => {
|
||||
let prevHeading: string | undefined;
|
||||
|
||||
@@ -693,6 +806,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasChildren = !!(
|
||||
"children" in item && resolveChildren(item.children)?.length
|
||||
);
|
||||
|
||||
const handlePointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
!("disabled" in item && item.disabled) &&
|
||||
@@ -708,6 +825,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
|
||||
// Hover to open submenu with delay
|
||||
if (hasChildren) {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
openSubmenu(index);
|
||||
}, 150);
|
||||
} else {
|
||||
// Close submenu when hovering a regular item
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
setSubmenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
@@ -722,23 +855,37 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const handleOnClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handleClickItem(item);
|
||||
if (hasChildren) {
|
||||
openSubmenu(index);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const itemRef = (node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
itemRefs.current.set(index, node);
|
||||
} else {
|
||||
itemRefs.current.delete(index);
|
||||
}
|
||||
};
|
||||
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== prevHeading && (
|
||||
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
|
||||
)}
|
||||
<ListItem
|
||||
ref={itemRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
disclosure: hasChildren,
|
||||
onClick: handleOnClick,
|
||||
})}
|
||||
</ListItem>
|
||||
@@ -792,37 +939,146 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Wrapper active={isActive} ref={menuRef} hiddenScrollbars {...position}>
|
||||
{(isActive || hasActivated.current) && (
|
||||
<>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
"placeholder" in insertItem && !!insertItem.placeholder
|
||||
? insertItem.placeholder
|
||||
: insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
<>
|
||||
<Popover open={isActive} onOpenChange={handleOpenChange} modal={false}>
|
||||
<PopoverAnchor virtualRef={caretRef} />
|
||||
<BouncyPopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
width={280}
|
||||
shrink
|
||||
style={{
|
||||
padding: 0,
|
||||
maxHeight:
|
||||
"min(324px, var(--radix-popover-content-available-height))",
|
||||
}}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
if (submenuContentRef.current?.contains(e.target as Node)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
"placeholder" in insertItem && !!insertItem.placeholder
|
||||
? insertItem.placeholder
|
||||
: insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
</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
|
||||
);
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
{fileInput}
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
pointerRef.current = {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const handleChildClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handleClickItem(child);
|
||||
setSubmenu(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`sub-${childIndex}-${child.name}`}
|
||||
onPointerMove={handleChildPointerMove}
|
||||
>
|
||||
{props.renderMenuItem(child as any, childIndex, {
|
||||
selected: childIndex === submenu.selectedIndex,
|
||||
onClick: handleChildClick,
|
||||
})}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</SubmenuPopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const bouncyFadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`;
|
||||
|
||||
const BouncyPopoverContent = styled(PopoverContent)`
|
||||
&[data-state="open"] {
|
||||
animation: ${bouncyFadeIn} 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
`;
|
||||
|
||||
const SubmenuPopoverContent = styled(PopoverContent)`
|
||||
max-height: min(324px, var(--radix-popover-content-available-height));
|
||||
`;
|
||||
|
||||
const LinkInputWrapper = styled.div`
|
||||
margin: 8px;
|
||||
`;
|
||||
@@ -839,6 +1095,13 @@ const List = styled.ol`
|
||||
height: 100%;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid ${s("divider")};
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
@@ -860,61 +1123,4 @@ const MobileScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Scrollable)<{
|
||||
active: boolean;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
isAbove: boolean;
|
||||
}>`
|
||||
color: ${s("textSecondary")};
|
||||
font-family: ${s("fontFamily")};
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
${(props) => props.top !== undefined && `top: ${props.top}px`};
|
||||
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
|
||||
left: ${(props) => props.left}px;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
|
||||
rgba(0, 0, 0, 0.08) 0px 4px 8px,
|
||||
rgba(0, 0, 0, 0.08) 0px 2px 4px;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transition-delay: 150ms;
|
||||
line-height: 0;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: 280px;
|
||||
height: auto;
|
||||
max-height: 324px;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid ${s("divider")};
|
||||
}
|
||||
|
||||
${({ active, isAbove }) =>
|
||||
active &&
|
||||
`
|
||||
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default SuggestionsMenu;
|
||||
|
||||
@@ -5,6 +5,7 @@ import styled from "styled-components";
|
||||
import { usePortalContext } from "~/components/Portal";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuDisclosure,
|
||||
MenuIconWrapper,
|
||||
MenuLabel,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
@@ -26,6 +27,8 @@ export type Props = {
|
||||
subtitle?: React.ReactNode;
|
||||
/** A string representing the keyboard shortcut for the item */
|
||||
shortcut?: string;
|
||||
/** Whether to show a disclosure arrow indicating a submenu */
|
||||
disclosure?: boolean;
|
||||
};
|
||||
|
||||
function SuggestionsMenuItem({
|
||||
@@ -37,6 +40,7 @@ function SuggestionsMenuItem({
|
||||
subtitle,
|
||||
shortcut,
|
||||
icon,
|
||||
disclosure,
|
||||
}: Props) {
|
||||
const portal = usePortalContext();
|
||||
const ref = React.useCallback(
|
||||
@@ -75,6 +79,7 @@ function SuggestionsMenuItem({
|
||||
)}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuLabel>
|
||||
{disclosure && <MenuDisclosure />}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,12 +55,10 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
Decoration.widget(
|
||||
parent.pos,
|
||||
() => {
|
||||
button.addEventListener(
|
||||
"click",
|
||||
action(() => {
|
||||
this.state.open = true;
|
||||
})
|
||||
);
|
||||
button.onclick = action(() => {
|
||||
this.state.query = "";
|
||||
this.state.open = true;
|
||||
});
|
||||
return button;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,6 +12,10 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
return "clipboardTextSerializer";
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const mdSerializer = this.editor.extensions.serializer();
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ 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 &&
|
||||
|
||||
@@ -25,6 +25,10 @@ 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);
|
||||
|
||||
+55
-14
@@ -133,7 +133,10 @@ 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>) => Promise<string>;
|
||||
onCreateLink?: (
|
||||
params: Properties<Document>,
|
||||
nested?: boolean
|
||||
) => Promise<string>;
|
||||
/** Callback when user clicks on any link in the document */
|
||||
onClickLink: (
|
||||
href: string,
|
||||
@@ -250,17 +253,25 @@ export class Editor extends React.PureComponent<
|
||||
this.view.updateState(newState);
|
||||
}
|
||||
|
||||
// pass readOnly changes through to underlying editor instance
|
||||
if (prevProps.readOnly !== this.props.readOnly) {
|
||||
// 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
|
||||
this.view.update({
|
||||
...this.view.props,
|
||||
editable: () => !this.props.readOnly,
|
||||
editable: () => false,
|
||||
});
|
||||
|
||||
// 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", !this.props.readOnly)
|
||||
view.setProp("isEditable", false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,15 +312,24 @@ 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();
|
||||
}
|
||||
@@ -411,12 +431,20 @@ 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.keymaps,
|
||||
...this.plugins,
|
||||
...this.keymaps,
|
||||
anchorPlugin(),
|
||||
dropCursor({
|
||||
color: this.props.theme.cursor,
|
||||
@@ -620,12 +648,25 @@ 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>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TrashIcon, DownloadIcon, ReplaceIcon } from "outline-icons";
|
||||
import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
@@ -17,6 +17,9 @@ export default function attachmentMenuItems(
|
||||
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
|
||||
preview: true,
|
||||
});
|
||||
const isPdfAttachment = isNodeActive(schema.nodes.attachment, {
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -29,6 +32,13 @@ export default function attachmentMenuItems(
|
||||
tooltip: dictionary.deleteAttachment,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "toggleAttachmentPreview",
|
||||
tooltip: dictionary.previewAttachment,
|
||||
icon: <PDFIcon />,
|
||||
active: isAttachmentWithPreview,
|
||||
visible: isPdfAttachment(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
|
||||
@@ -126,6 +126,7 @@ export default function blockMenuItems(
|
||||
accept: "application/pdf",
|
||||
width: 300,
|
||||
height: 424,
|
||||
preview: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -164,6 +165,12 @@ 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,
|
||||
@@ -243,12 +250,6 @@ 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
|
||||
|
||||
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -60,13 +61,11 @@ export default function codeMenuItems(
|
||||
: undefined,
|
||||
tooltip: dictionary.copy,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "edit_mermaid",
|
||||
icon: <EditIcon />,
|
||||
tooltip: dictionary.editDiagram,
|
||||
shortcut: `${metaDisplay} Enter`,
|
||||
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ 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"),
|
||||
@@ -44,6 +45,7 @@ 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"),
|
||||
@@ -53,6 +55,7 @@ export default function useDictionary() {
|
||||
replaceImage: t("Replace image"),
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
expandCode: t("Expand"),
|
||||
file: t("File attachment"),
|
||||
pdf: t("Embed PDF"),
|
||||
enterLink: `${t("Enter a link")}…`,
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
presentDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
deleteDocument,
|
||||
@@ -106,6 +108,8 @@ export function useDocumentMenuAction({
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
presentDocument,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
|
||||
@@ -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,27 +16,35 @@ 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) {
|
||||
export function useGroupMenuActions(
|
||||
targetGroup: Group | null,
|
||||
options?: Options
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(targetGroup ?? ({} as Group));
|
||||
|
||||
const openMembersDialog = React.useCallback(() => {
|
||||
const navigateToMembers = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={targetGroup} />,
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
history.push(settingsPath("groups", targetGroup.id, "members"));
|
||||
}, [targetGroup, history]);
|
||||
|
||||
const openEditDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
@@ -45,7 +53,10 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
<EditGroupDialog
|
||||
group={targetGroup}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
@@ -57,7 +68,10 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
<DeleteGroupDialog
|
||||
group={targetGroup}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
@@ -67,26 +81,30 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
!targetGroup
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.read),
|
||||
perform: openMembersDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
...(options?.hideMembers
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: t("Members"),
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: can.read,
|
||||
perform: navigateToMembers,
|
||||
}),
|
||||
ActionSeparator,
|
||||
]),
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.update),
|
||||
visible: can.update,
|
||||
perform: openEditDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.delete),
|
||||
visible: can.delete,
|
||||
dangerous: true,
|
||||
perform: openDeleteDialog,
|
||||
}),
|
||||
@@ -98,6 +116,13 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
createExternalLinkAction({
|
||||
name: `External ID: ${targetGroup.externalGroup?.externalId ?? ""}`,
|
||||
section: GroupSection,
|
||||
visible: !!targetGroup.externalGroup?.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
@@ -105,7 +130,8 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
openMembersDialog,
|
||||
options?.hideMembers,
|
||||
navigateToMembers,
|
||||
openEditDialog,
|
||||
openDeleteDialog,
|
||||
]
|
||||
|
||||
@@ -63,9 +63,14 @@ window.addEventListener("keydown", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track whether defaultPrevented was already set by an external handler (e.g.
|
||||
// Radix UI's DismissableLayer) so we only break on preventDefault calls made
|
||||
// by our own callbacks.
|
||||
const wasDefaultPrevented = event.defaultPrevented;
|
||||
|
||||
// reverse so that the last registered callbacks get executed first
|
||||
for (const registered of callbacks.reverse()) {
|
||||
if (event.defaultPrevented === true) {
|
||||
for (const registered of [...callbacks].reverse()) {
|
||||
if (!wasDefaultPrevented && event.defaultPrevented) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ 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) ??
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
@@ -72,6 +73,7 @@ if (element) {
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Presentation />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</LazyMotion>
|
||||
|
||||
@@ -8,11 +8,13 @@ import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
/** Whether to hide the "Members" navigation action. */
|
||||
hideMembers?: boolean;
|
||||
};
|
||||
|
||||
function GroupMenu({ group }: Props) {
|
||||
function GroupMenu({ group, hideMembers }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rootAction = useGroupMenuActions(group);
|
||||
const rootAction = useGroupMenuActions(group, { hideMembers });
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -13,6 +14,10 @@ class AuthenticationProvider extends Model {
|
||||
|
||||
providerId: string;
|
||||
|
||||
groupSyncSupported: boolean;
|
||||
|
||||
groupSyncUsesClaim: boolean;
|
||||
|
||||
@observable
|
||||
isConnected: boolean;
|
||||
|
||||
@@ -20,6 +25,10 @@ class AuthenticationProvider extends Model {
|
||||
@observable
|
||||
isEnabled: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
settings: AuthenticationProviderSettings | undefined;
|
||||
|
||||
@computed
|
||||
get isActive() {
|
||||
return this.isEnabled && this.isConnected;
|
||||
|
||||
@@ -67,6 +67,11 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,22 @@ 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";
|
||||
|
||||
@@ -26,6 +42,17 @@ 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.
|
||||
*/
|
||||
|
||||
+12
-4
@@ -1,12 +1,15 @@
|
||||
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"));
|
||||
|
||||
@@ -24,20 +27,25 @@ 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={Application.Component}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("templates", "new")}
|
||||
component={TemplateNew}
|
||||
component={TemplateNew.Component}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("templates", ":id")}
|
||||
component={Template}
|
||||
component={Template.Component}
|
||||
/>
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
|
||||
@@ -66,7 +66,12 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
|
||||
shortcut="e"
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
|
||||
<Button
|
||||
icon={<EditIcon />}
|
||||
onClick={goToEdit}
|
||||
haptic="light"
|
||||
neutral
|
||||
>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -75,7 +80,9 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
|
||||
{isEditing && user?.separateEditMode && (
|
||||
<Action>
|
||||
<RegisterKeyDown trigger="Escape" handler={goBack} />
|
||||
<Button onClick={goBack}>{t("Done editing")}</Button>
|
||||
<Button onClick={goBack} haptic="medium">
|
||||
{t("Done editing")}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.createDocument && (
|
||||
|
||||
@@ -61,7 +61,7 @@ function Overview({ collection, readOnly }: Props) {
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const SharePopover = lazyWithRetry(
|
||||
@@ -64,6 +65,7 @@ function ShareButton({ collection }: Props) {
|
||||
minHeight={175}
|
||||
side="bottom"
|
||||
align="end"
|
||||
onEscapeKeyDown={preventDefault}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SharePopover
|
||||
|
||||
@@ -31,7 +31,8 @@ import useMobile from "~/hooks/useMobile";
|
||||
function Comments() {
|
||||
const { ui, comments, documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const { editor, isEditorInitialized } = useDocumentContext();
|
||||
const { editor, isEditorInitialized, setFocusedCommentId } =
|
||||
useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
@@ -203,7 +204,10 @@ function Comments() {
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.set({ rightSidebar: null })}
|
||||
onClose={() => {
|
||||
ui.set({ rightSidebar: null });
|
||||
setFocusedCommentId(null);
|
||||
}}
|
||||
scrollable={false}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Properties } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
@@ -88,6 +89,7 @@ function DataLoader({ match, children }: Props) {
|
||||
const isEditing = isEditRoute || !user?.separateEditMode;
|
||||
const can = usePolicy(document);
|
||||
const location = useLocation<LocationState>();
|
||||
const query = useQuery();
|
||||
const missingPolicy = !can || Object.keys(can).length === 0;
|
||||
|
||||
useDocumentSidebar();
|
||||
@@ -205,6 +207,13 @@ function DataLoader({ match, children }: Props) {
|
||||
revisionId,
|
||||
]);
|
||||
|
||||
// Auto-enter presentation mode when ?present=true query param is set
|
||||
React.useEffect(() => {
|
||||
if (document && query.has("present") && !ui.presentationData) {
|
||||
ui.setPresentingDocument(document);
|
||||
}
|
||||
}, [document, query, ui]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? (
|
||||
<ErrorOffline />
|
||||
|
||||
@@ -669,9 +669,11 @@ const Main = styled.div<MainProps>`
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
max-width: calc(
|
||||
${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter}
|
||||
);
|
||||
max-width: ${({ fullWidth }: MainProps) =>
|
||||
fullWidth
|
||||
? `100%`
|
||||
: `calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -720,10 +722,10 @@ const EditorContainer = styled.div<EditorContainerProps>`
|
||||
|
||||
// Decides the editor column position & span
|
||||
grid-column: ${({
|
||||
docFullWidth,
|
||||
showContents,
|
||||
tocPosition,
|
||||
}: EditorContainerProps) =>
|
||||
docFullWidth,
|
||||
showContents,
|
||||
tocPosition,
|
||||
}: EditorContainerProps) =>
|
||||
docFullWidth
|
||||
? showContents
|
||||
? tocPosition === TOCPosition.Left
|
||||
|
||||
@@ -172,7 +172,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
@@ -158,6 +158,7 @@ function DocumentHeader({
|
||||
pathname: documentEditPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
haptic="light"
|
||||
neutral
|
||||
>
|
||||
{isMobile ? null : t("Edit")}
|
||||
@@ -283,6 +284,7 @@ function DocumentHeader({
|
||||
onClick={handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
haptic="medium"
|
||||
hideIcon
|
||||
>
|
||||
{isDraft ? t("Save draft") : t("Done editing")}
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShrinkIcon, GrowIcon, CloseIcon } from "outline-icons";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { canUseElementFullscreen } from "@shared/utils/browser";
|
||||
import { s, depths, hover } from "@shared/styles";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Editor from "~/components/Editor";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import Flex from "~/components/Flex";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
/** Activity events that reset the idle timer — excludes keyboard to stay idle during navigation. */
|
||||
const idleEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
];
|
||||
|
||||
type Slide =
|
||||
| {
|
||||
type: "title";
|
||||
title: string;
|
||||
icon?: string | null;
|
||||
iconColor?: string | null;
|
||||
}
|
||||
| { type: "content"; content: ProsemirrorData[] }
|
||||
| { type: "instructions" };
|
||||
|
||||
interface Props {
|
||||
/** The document title. */
|
||||
title: string;
|
||||
/** The document icon. */
|
||||
icon?: string | null;
|
||||
/** The document icon color. */
|
||||
iconColor?: string | null;
|
||||
/** The prosemirror data for the document. */
|
||||
data: ProsemirrorData;
|
||||
/** Callback when presentation mode is closed. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given content nodes contain no meaningful text or elements.
|
||||
*
|
||||
* @param nodes the prosemirror content nodes.
|
||||
* @returns true when every node is an empty paragraph.
|
||||
*/
|
||||
function isContentEmpty(nodes: ProsemirrorData[]): boolean {
|
||||
return nodes.every(
|
||||
(node) =>
|
||||
node.type === "paragraph" && (!node.content || node.content.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a ProseMirror document into slides based on heading and divider nodes.
|
||||
* A dedicated title slide is prepended. Each h1/h2 heading or horizontal rule
|
||||
* starts a new content slide. Divider nodes are consumed as separators and not
|
||||
* rendered on slides.
|
||||
*
|
||||
* @param data the prosemirror document data.
|
||||
* @param title the document title.
|
||||
* @param icon the document icon.
|
||||
* @param iconColor the document icon color.
|
||||
* @returns an array of slides.
|
||||
*/
|
||||
function splitIntoSlides(
|
||||
data: ProsemirrorData,
|
||||
title: string,
|
||||
icon?: string | null,
|
||||
iconColor?: string | null
|
||||
): Slide[] {
|
||||
const content = data.content ?? [];
|
||||
const slides: Slide[] = [{ type: "title", title, icon, iconColor }];
|
||||
let currentNodes: ProsemirrorData[] = [];
|
||||
|
||||
for (const node of content) {
|
||||
const isDivider = node.type === "horizontal_rule" || node.type === "hr";
|
||||
const isHeadingBreak =
|
||||
node.type === "heading" &&
|
||||
node.attrs &&
|
||||
typeof node.attrs.level === "number" &&
|
||||
node.attrs.level <= 2;
|
||||
|
||||
if (isDivider) {
|
||||
if (currentNodes.length > 0) {
|
||||
slides.push({ type: "content", content: currentNodes });
|
||||
currentNodes = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isHeadingBreak && currentNodes.length > 0) {
|
||||
slides.push({ type: "content", content: currentNodes });
|
||||
currentNodes = [];
|
||||
}
|
||||
|
||||
currentNodes.push(node);
|
||||
}
|
||||
|
||||
if (currentNodes.length > 0) {
|
||||
slides.push({ type: "content", content: currentNodes });
|
||||
}
|
||||
|
||||
return slides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen presentation mode that splits a document into slides by headings
|
||||
* and dividers, and allows navigating through them with keyboard controls.
|
||||
*/
|
||||
function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [currentSlide, setCurrentSlide] = React.useState(0);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const slideContentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||
const supportsFullscreen = React.useMemo(() => canUseElementFullscreen(), []);
|
||||
const isIdle = useIdle(3000, idleEvents);
|
||||
|
||||
const slides = React.useMemo(() => {
|
||||
const result = splitIntoSlides(data, title, icon, iconColor);
|
||||
const contentSlides = result.filter((s) => s.type === "content");
|
||||
const hasContent =
|
||||
contentSlides.length > 0 &&
|
||||
contentSlides.some(
|
||||
(s) => s.type === "content" && !isContentEmpty(s.content)
|
||||
);
|
||||
|
||||
if (!hasContent) {
|
||||
return [result[0], { type: "instructions" as const }];
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, title, icon, iconColor]);
|
||||
|
||||
const totalSlides = slides.length;
|
||||
|
||||
const goNext = React.useCallback(() => {
|
||||
setCurrentSlide((prev) => Math.min(prev + 1, totalSlides - 1));
|
||||
}, [totalSlides]);
|
||||
|
||||
const goPrev = React.useCallback(() => {
|
||||
setCurrentSlide((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const goFirst = React.useCallback(() => {
|
||||
setCurrentSlide(0);
|
||||
}, []);
|
||||
|
||||
const goLast = React.useCallback(() => {
|
||||
setCurrentSlide(totalSlides - 1);
|
||||
}, [totalSlides]);
|
||||
|
||||
const toggleFullscreen = React.useCallback(() => {
|
||||
if (!supportsFullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = containerRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
} else {
|
||||
el.requestFullscreen().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
}, [supportsFullscreen]);
|
||||
|
||||
useKeyDown("Escape", onClose);
|
||||
useKeyDown("ArrowRight", goNext);
|
||||
useKeyDown("ArrowDown", goNext);
|
||||
useKeyDown("PageDown", goNext);
|
||||
useKeyDown("ArrowLeft", goPrev);
|
||||
useKeyDown("ArrowUp", goPrev);
|
||||
useKeyDown("PageUp", goPrev);
|
||||
useKeyDown("Home", goFirst);
|
||||
useKeyDown("End", goLast);
|
||||
useKeyDown(" ", goNext);
|
||||
useKeyDown("f", toggleFullscreen);
|
||||
|
||||
// Prevent body scrolling while presentation is open
|
||||
React.useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track fullscreen state changes
|
||||
React.useEffect(() => {
|
||||
if (!supportsFullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [supportsFullscreen]);
|
||||
|
||||
// Measure natural size once per slide, then apply scale directly to the DOM
|
||||
// to avoid React re-render loops during window resize.
|
||||
const naturalSize = React.useRef({ width: 0, height: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = slideContentRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!el || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyScale = () => {
|
||||
const { width, height } = naturalSize.current;
|
||||
if (width === 0 || height === 0) {
|
||||
el.style.transform = "scale(1)";
|
||||
return;
|
||||
}
|
||||
|
||||
const availableWidth = container.clientWidth - 160;
|
||||
const availableHeight = container.clientHeight - 48 - 160;
|
||||
const scaleX = availableWidth / width;
|
||||
const scaleY = availableHeight / height;
|
||||
const newScale = Math.min(scaleX, scaleY, 1.5);
|
||||
el.style.transform = `scale(${Math.max(newScale, 0.5)})`;
|
||||
};
|
||||
|
||||
// Measure natural size with scale removed, then apply
|
||||
el.style.transform = "none";
|
||||
requestAnimationFrame(() => {
|
||||
naturalSize.current = {
|
||||
width: el.scrollWidth,
|
||||
height: el.scrollHeight,
|
||||
};
|
||||
applyScale();
|
||||
window.addEventListener("resize", applyScale);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", applyScale);
|
||||
};
|
||||
}, [currentSlide]);
|
||||
|
||||
const slide = slides[currentSlide];
|
||||
|
||||
const slideData: ProsemirrorData | undefined = React.useMemo(
|
||||
() =>
|
||||
slide.type === "content"
|
||||
? { type: "doc", content: slide.content }
|
||||
: undefined,
|
||||
[slide]
|
||||
);
|
||||
|
||||
const extensions = React.useMemo(() => richExtensions, []);
|
||||
|
||||
return createPortal(
|
||||
<Container ref={containerRef} $background={theme.background} $idle={isIdle}>
|
||||
<TopBar $idle={isIdle}>
|
||||
<Flex align="center" gap={12}>
|
||||
<Tooltip content={t("Previous slide")} delay={500}>
|
||||
<Button onClick={goPrev} disabled={currentSlide === 0}>
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<SlideCounter>
|
||||
{currentSlide + 1} / {totalSlides}
|
||||
</SlideCounter>
|
||||
<Tooltip content={t("Next slide")} delay={500}>
|
||||
<Button
|
||||
onClick={goNext}
|
||||
disabled={currentSlide === totalSlides - 1}
|
||||
>
|
||||
<ArrowRightIcon color="currentColor" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<RightButtons>
|
||||
{supportsFullscreen && (
|
||||
<Tooltip content={t("Toggle fullscreen")} delay={500}>
|
||||
<Button onClick={toggleFullscreen}>
|
||||
{isFullscreen ? (
|
||||
<ShrinkIcon color="currentColor" />
|
||||
) : (
|
||||
<GrowIcon color="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t("Close")} delay={500}>
|
||||
<Button onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</RightButtons>
|
||||
</TopBar>
|
||||
<SlideArea onClick={goNext}>
|
||||
<SlideContent ref={slideContentRef}>
|
||||
{slide.type === "title" ? (
|
||||
<TitleSlide>
|
||||
{slide.icon && (
|
||||
<TitleIcon>
|
||||
<Icon
|
||||
value={slide.icon}
|
||||
color={slide.iconColor ?? colorPalette[0]}
|
||||
size={64}
|
||||
initial={slide.title[0]}
|
||||
/>
|
||||
</TitleIcon>
|
||||
)}
|
||||
<TitleText>{slide.title}</TitleText>
|
||||
</TitleSlide>
|
||||
) : slide.type === "instructions" ? (
|
||||
<InstructionSlide>
|
||||
<InstructionHeading>
|
||||
{t("Create your presentation")}
|
||||
</InstructionHeading>
|
||||
<InstructionBody>
|
||||
{t(
|
||||
"Add content to your document, then use headings or dividers to separate it into slides."
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://docs.getoutline.com/s/guide/doc/present-mode-yMGzaY7A9L"
|
||||
target="_blank"
|
||||
>
|
||||
{t("Learn more")}
|
||||
</a>
|
||||
.
|
||||
</InstructionBody>
|
||||
</InstructionSlide>
|
||||
) : slideData ? (
|
||||
<Editor
|
||||
key={currentSlide}
|
||||
defaultValue={slideData}
|
||||
extensions={extensions}
|
||||
readOnly
|
||||
grow={false}
|
||||
placeholder=""
|
||||
/>
|
||||
) : null}
|
||||
</SlideContent>
|
||||
</SlideArea>
|
||||
</Container>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $background: string; $idle: boolean }>`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: ${depths.presentation};
|
||||
background: ${(props) => props.$background};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
cursor: ${(props) => (props.$idle ? "none" : "default")};
|
||||
|
||||
* {
|
||||
cursor: inherit;
|
||||
}
|
||||
`;
|
||||
|
||||
const SlideArea = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 80px;
|
||||
`;
|
||||
|
||||
const SlideContent = styled.div`
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
transform-origin: center center;
|
||||
|
||||
.ProseMirror {
|
||||
padding: 0;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.4em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
`;
|
||||
|
||||
const TitleSlide = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 24px;
|
||||
min-height: 200px;
|
||||
`;
|
||||
|
||||
const TitleIcon = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const TitleText = styled.h1`
|
||||
font-size: 3em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const TopBar = styled.div<{ $idle: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
opacity: ${(props) => (props.$idle ? 0 : 1)};
|
||||
transition: opacity 300ms ease;
|
||||
`;
|
||||
|
||||
const SlideCounter = styled(Text)`
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const RightButtons = styled(Flex).attrs({ align: "center", gap: 16 })`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
`;
|
||||
|
||||
const Button = styled(NudeButton).attrs({ size: 32 })`
|
||||
&:not(:disabled) {
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:${hover},
|
||||
&:active {
|
||||
color: ${s("text")};
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${s("textTertiary")};
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const InstructionSlide = styled(TitleSlide)`
|
||||
gap: 16px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const InstructionHeading = styled.h2`
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const InstructionBody = styled.p`
|
||||
font-size: 1.2em;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default PresentationMode;
|
||||
@@ -30,7 +30,7 @@ function References({ document }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShare) {
|
||||
void documents.fetchBacklinks(document.id);
|
||||
void documents.fetchRelationships(document.id);
|
||||
}
|
||||
}, [isShare, documents, document.id]);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "~/components/primitives/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const SharePopover = lazyWithRetry(
|
||||
@@ -58,6 +59,7 @@ function ShareButton({ document }: Props) {
|
||||
minHeight={175}
|
||||
side="bottom"
|
||||
align="end"
|
||||
onEscapeKeyDown={preventDefault}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SharePopover
|
||||
|
||||
@@ -108,6 +108,15 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
|
||||
),
|
||||
label: t("Go to link"),
|
||||
},
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key symbol>{metaDisplay}</Key> + <Key symbol>{altDisplay}</Key>{" "}
|
||||
+ <Key>p</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Present document"),
|
||||
},
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
|
||||
@@ -28,6 +28,7 @@ import usePaginatedRequest from "~/hooks/usePaginatedRequest";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { PaginationParams, SearchResult } from "~/types";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
import CollectionFilter from "./components/CollectionFilter";
|
||||
@@ -251,11 +252,7 @@ function Search() {
|
||||
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
|
||||
{loading && <LoadingIndicator />}
|
||||
<ResultsWrapper column auto>
|
||||
<form
|
||||
method="GET"
|
||||
action={searchPath()}
|
||||
onSubmit={(ev) => ev.preventDefault()}
|
||||
>
|
||||
<form method="GET" action={searchPath()} onSubmit={preventDefault}>
|
||||
<SearchInput
|
||||
name="query"
|
||||
key={query ? "search" : "recent"}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import type AuthenticationProvider from "~/models/AuthenticationProvider";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
import Scene from "~/components/Scene";
|
||||
@@ -21,6 +23,7 @@ import { settingsPath } from "~/utils/routeHelpers";
|
||||
import DomainManagement from "./components/DomainManagement";
|
||||
import Button from "~/components/Button";
|
||||
import { ConnectedIcon } from "~/components/Icons/ConnectedIcon";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { useTheme } from "styled-components";
|
||||
import { VStack } from "~/components/primitives/VStack";
|
||||
|
||||
@@ -97,6 +100,54 @@ function Authentication() {
|
||||
window.location.href = `/auth/${name}?host=${window.location.host}`;
|
||||
}, []);
|
||||
|
||||
const handleToggleGroupSync = React.useCallback(
|
||||
(provider: AuthenticationProvider, checked: boolean) => {
|
||||
if (checked) {
|
||||
void (async () => {
|
||||
try {
|
||||
await provider.save({
|
||||
settings: {
|
||||
...provider.settings,
|
||||
groupSyncEnabled: true,
|
||||
},
|
||||
});
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
dialogs.openModal({
|
||||
title: t("Disable group sync"),
|
||||
content: (
|
||||
<DisableGroupSyncDialog
|
||||
provider={provider}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
[t, dialogs]
|
||||
);
|
||||
|
||||
const handleGroupClaimChange = React.useCallback(
|
||||
async (provider: AuthenticationProvider, groupClaim: string) => {
|
||||
try {
|
||||
await provider.save({
|
||||
settings: {
|
||||
...provider.settings,
|
||||
groupClaim,
|
||||
},
|
||||
});
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const showSuccessMessage = React.useMemo(
|
||||
() => () => toast.success(t("Settings saved")),
|
||||
[t]
|
||||
@@ -115,58 +166,107 @@ function Authentication() {
|
||||
<Heading as="h2">{t("Sign In")}</Heading>
|
||||
|
||||
{authenticationProviders.orderedData.map((provider) => (
|
||||
<SettingRow
|
||||
key={provider.name}
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<PluginIcon id={provider.name} /> {provider.displayName}
|
||||
</Flex>
|
||||
}
|
||||
name={provider.name}
|
||||
description={
|
||||
provider.isConnected
|
||||
? t("Allow members to sign-in with {{ authProvider }}", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
: t("Connect {{ authProvider }} to allow members to sign-in", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
{provider.isConnected ? (
|
||||
<VStack align="start">
|
||||
<React.Fragment key={provider.name}>
|
||||
<SettingRow
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<PluginIcon id={provider.name} /> {provider.displayName}
|
||||
</Flex>
|
||||
}
|
||||
name={provider.name}
|
||||
description={
|
||||
provider.isConnected
|
||||
? t("Allow members to sign-in with {{ authProvider }}", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
: t("Connect {{ authProvider }} to allow members to sign-in", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
}
|
||||
border={!(provider.isActive && provider.groupSyncSupported)}
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
{provider.isConnected ? (
|
||||
<VStack align="start">
|
||||
<Button
|
||||
icon={
|
||||
provider.isEnabled ? (
|
||||
<ConnectedIcon />
|
||||
) : (
|
||||
<ConnectedIcon color={theme.textSecondary} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
!provider.isEnabled
|
||||
? handleToggleProvider(provider, true)
|
||||
: handleRemoveProvider(provider)
|
||||
}
|
||||
neutral
|
||||
>
|
||||
{provider.isEnabled ? t("Connected") : t("Disabled")}
|
||||
</Button>
|
||||
<Text type="tertiary" size="small">
|
||||
{provider.providerId}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<Button
|
||||
icon={
|
||||
provider.isEnabled ? (
|
||||
<ConnectedIcon />
|
||||
) : (
|
||||
<ConnectedIcon color={theme.textSecondary} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
!provider.isEnabled
|
||||
? handleToggleProvider(provider, true)
|
||||
: handleRemoveProvider(provider)
|
||||
}
|
||||
onClick={() => handleConnectProvider(provider.name)}
|
||||
neutral
|
||||
>
|
||||
{provider.isEnabled ? t("Connected") : t("Disabled")}
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
<Text type="tertiary" size="small">
|
||||
{provider.providerId}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleConnectProvider(provider.name)}
|
||||
neutral
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
{provider.isActive && provider.groupSyncSupported && (
|
||||
<SettingRow
|
||||
label={t("Group sync")}
|
||||
name={`groupSync-${provider.name}`}
|
||||
description={t(
|
||||
"Sync group memberships from {{ authProvider }} on each sign-in",
|
||||
{ authProvider: provider.displayName }
|
||||
)}
|
||||
border={
|
||||
!(
|
||||
provider.settings?.groupSyncEnabled &&
|
||||
provider.groupSyncUsesClaim
|
||||
)
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
id={`groupSync-${provider.name}`}
|
||||
checked={provider.settings?.groupSyncEnabled ?? false}
|
||||
onChange={(checked) => handleToggleGroupSync(provider, checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{provider.isActive &&
|
||||
provider.groupSyncSupported &&
|
||||
provider.groupSyncUsesClaim &&
|
||||
provider.settings?.groupSyncEnabled && (
|
||||
<SettingRow
|
||||
label={t("Group claim")}
|
||||
name={`groupClaim-${provider.name}`}
|
||||
description={t(
|
||||
"The claim in the provider response that contains group names (e.g. groups, roles)"
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
<Input
|
||||
id={`groupClaim-${provider.name}`}
|
||||
defaultValue={provider.settings?.groupClaim ?? "groups"}
|
||||
placeholder="groups"
|
||||
onBlur={(ev: React.FocusEvent<HTMLInputElement>) => {
|
||||
const value = ev.target.value.trim();
|
||||
if (value !== (provider.settings?.groupClaim ?? "")) {
|
||||
void handleGroupClaimChange(provider, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<SettingRow
|
||||
label={
|
||||
@@ -219,4 +319,87 @@ function Authentication() {
|
||||
);
|
||||
}
|
||||
|
||||
const DisableGroupSyncDialog = observer(function DisableGroupSyncDialog({
|
||||
provider,
|
||||
onSubmit,
|
||||
}: {
|
||||
provider: AuthenticationProvider;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [action, setAction] = React.useState("keep");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const options = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "item" as const,
|
||||
label: t("Keep synced groups"),
|
||||
description: t("Groups will remain but no longer update"),
|
||||
value: "keep",
|
||||
},
|
||||
{
|
||||
type: "item" as const,
|
||||
label: t("Delete synced groups"),
|
||||
description: t("Remove all groups created by sync"),
|
||||
value: "delete",
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await provider.save({
|
||||
settings: {
|
||||
...provider.settings,
|
||||
groupSyncEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (action === "delete") {
|
||||
await client.post("/groups.deleteAll", {
|
||||
authenticationProviderId: provider.id,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(t("Settings saved"));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[provider, action, onSubmit, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Flex gap={12} column>
|
||||
<Text type="secondary">
|
||||
{t(
|
||||
"Group memberships will no longer be synced from {{ authProvider }} when members sign in.",
|
||||
{ authProvider: provider.displayName }
|
||||
)}
|
||||
</Text>
|
||||
<InputSelect
|
||||
label={t("Existing groups")}
|
||||
options={options}
|
||||
value={action}
|
||||
onChange={setAction}
|
||||
/>
|
||||
<Flex justify="flex-end">
|
||||
<Button type="submit" disabled={isSaving} danger>
|
||||
{isSaving ? `${t("Disabling")}…` : t("Disable")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(Authentication);
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { ColumnSort } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon, HiddenIcon, PlusIcon } from "outline-icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { toast } from "sonner";
|
||||
import type User from "~/models/User";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Button from "~/components/Button";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import type { FetchPageParams, PaginatedResponse } from "~/stores/base/Store";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
|
||||
import GroupMenu from "~/menus/GroupMenu";
|
||||
import { AddPeopleToGroupDialog } from "./components/GroupDialogs";
|
||||
import GroupPermissionFilter from "./components/GroupPermissionFilter";
|
||||
import { GroupMembersTable } from "./components/GroupMembersTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
/**
|
||||
* Settings page that lists members of a specific group.
|
||||
*/
|
||||
function GroupMembers() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { groups } = useStores();
|
||||
const group = groups.get(id);
|
||||
const { request, error } = useRequest(() => groups.fetch(id));
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) {
|
||||
void request();
|
||||
}
|
||||
}, [group, request]);
|
||||
|
||||
if (error) {
|
||||
return <Error404 />;
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <GroupMembersPage groupId={group.id} />;
|
||||
}
|
||||
|
||||
const GroupMembersPage = observer(function GroupMembersPage({
|
||||
groupId,
|
||||
}: {
|
||||
groupId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { dialogs, groups, users, groupUsers } = useStores();
|
||||
const group = groups.get(groupId)!;
|
||||
const can = usePolicy(group);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const params = useQuery();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const reqParams = useMemo(
|
||||
() => ({
|
||||
id: group.id,
|
||||
query: params.get("query") || undefined,
|
||||
permission: params.get("permission") || undefined,
|
||||
sort: params.get("sort") || "name",
|
||||
direction: (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params, group.id]
|
||||
);
|
||||
|
||||
const sort: ColumnSort = useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
const fetchMembers = useCallback(
|
||||
async (fetchParams: FetchPageParams): Promise<PaginatedResponse<User>> => {
|
||||
const response = await groupUsers.fetchPage(fetchParams);
|
||||
const result = response.map((gu) => gu.user) as PaginatedResponse<User>;
|
||||
result[PAGINATION_SYMBOL] = response[PAGINATION_SYMBOL];
|
||||
return result;
|
||||
},
|
||||
[groupUsers]
|
||||
);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
let result = users.inGroup(group.id, reqParams.query);
|
||||
if (reqParams.permission) {
|
||||
const memberIds = new Set(
|
||||
groupUsers.orderedData
|
||||
.filter(
|
||||
(gu) =>
|
||||
gu.groupId === group.id && gu.permission === reqParams.permission
|
||||
)
|
||||
.map((gu) => gu.userId)
|
||||
);
|
||||
result = result.filter((user) => memberIds.has(user.id));
|
||||
}
|
||||
return result;
|
||||
}, [
|
||||
users,
|
||||
groupUsers.orderedData,
|
||||
group.id,
|
||||
reqParams.query,
|
||||
reqParams.permission,
|
||||
]);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: filteredUsers,
|
||||
sort,
|
||||
reqFn: fetchMembers,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
const updateParams = useCallback(
|
||||
(name: string, value: string) => {
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const updateQuery = useCallback(
|
||||
(value: string) => updateParams("query", value),
|
||||
[updateParams]
|
||||
);
|
||||
|
||||
const handlePermissionFilter = useCallback(
|
||||
(permission: string | null | undefined) =>
|
||||
updateParams("permission", permission ?? ""),
|
||||
[updateParams]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddPeople = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t(`Add people to {{groupName}}`, {
|
||||
groupName: group.name,
|
||||
}),
|
||||
content: <AddPeopleToGroupDialog group={group} />,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load group members"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => updateQuery(query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateQuery]);
|
||||
|
||||
const breadcrumbActions = useMemo(
|
||||
() => [
|
||||
createInternalLinkAction({
|
||||
name: t("Groups"),
|
||||
section: NavigationSection,
|
||||
icon: <GroupIcon />,
|
||||
to: settingsPath("groups"),
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={group.name}
|
||||
left={<Breadcrumb actions={breadcrumbActions} />}
|
||||
actions={
|
||||
<>
|
||||
{can.update && (
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddPeople}
|
||||
disabled={group.isExternallyManaged}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("Add people")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
<Action>
|
||||
<GroupMenu group={group} hideMembers />
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
wide
|
||||
>
|
||||
<Heading>
|
||||
{group.name}
|
||||
{group.disableMentions && (
|
||||
<>
|
||||
|
||||
<Tooltip content={t("This group is hidden")}>
|
||||
<HiddenIcon size={32} color={theme.textSecondary} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
{group.externalGroup && (
|
||||
<>
|
||||
{t("Synced to {{ provider }}", {
|
||||
provider: group.externalGroup.displayName,
|
||||
})}
|
||||
{group.description && <> · </>}
|
||||
</>
|
||||
)}
|
||||
{group.description || (!group.externalGroup && t("No description"))}
|
||||
</Text>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<LargeGroupPermissionFilter
|
||||
activeKey={reqParams.permission ?? ""}
|
||||
onSelect={handlePermissionFilter}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<GroupMembersTable
|
||||
group={group}
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</ConditionalFade>
|
||||
</Scene>
|
||||
);
|
||||
});
|
||||
|
||||
const LargeGroupPermissionFilter = styled(GroupPermissionFilter)`
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
export const GroupMembersScene = observer(GroupMembers);
|
||||
@@ -2,6 +2,7 @@ import type { ColumnSort } from "@tanstack/react-table";
|
||||
import deburr from "lodash/deburr";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon, GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
@@ -12,6 +13,7 @@ import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import styled from "styled-components";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
@@ -21,18 +23,30 @@ import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { CreateGroupDialog } from "./components/GroupDialogs";
|
||||
import GroupSourceFilter from "./components/GroupSourceFilter";
|
||||
import { GroupsTable } from "./components/GroupsTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
function getFilteredGroups(groups: Group[], query?: string) {
|
||||
if (!query?.length) {
|
||||
return groups;
|
||||
function getFilteredGroups(groups: Group[], query?: string, source?: string) {
|
||||
let filtered = groups;
|
||||
|
||||
if (query?.length) {
|
||||
const normalizedQuery = deburr(query.toLocaleLowerCase());
|
||||
filtered = filtered.filter((group) =>
|
||||
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedQuery = deburr(query.toLocaleLowerCase());
|
||||
return groups.filter((group) =>
|
||||
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
if (source === "manual") {
|
||||
filtered = filtered.filter((group) => !group.externalGroup);
|
||||
} else if (source) {
|
||||
filtered = filtered.filter(
|
||||
(group) => group.externalGroup?.provider === source
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Groups() {
|
||||
@@ -48,6 +62,7 @@ function Groups() {
|
||||
const reqParams = useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
source: params.get("source") || undefined,
|
||||
sort: params.get("sort") || "name",
|
||||
direction: (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
@@ -65,7 +80,11 @@ function Groups() {
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: getFilteredGroups(groups.orderedData, reqParams.query),
|
||||
data: getFilteredGroups(
|
||||
groups.orderedData,
|
||||
reqParams.query,
|
||||
reqParams.source
|
||||
),
|
||||
sort,
|
||||
reqFn: groups.fetchPage,
|
||||
reqParams,
|
||||
@@ -73,12 +92,12 @@ function Groups() {
|
||||
|
||||
const isEmpty = !loading && !groups.orderedData.length;
|
||||
|
||||
const updateQuery = useCallback(
|
||||
(value: string) => {
|
||||
const updateParams = useCallback(
|
||||
(name: string, value: string) => {
|
||||
if (value) {
|
||||
params.set("query", value);
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete("query");
|
||||
params.delete(name);
|
||||
}
|
||||
|
||||
history.replace({
|
||||
@@ -89,10 +108,18 @@ function Groups() {
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
}, []);
|
||||
const handleSourceFilter = useCallback(
|
||||
(source: string | null | undefined) => updateParams("source", source ?? ""),
|
||||
[updateParams]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleNewGroup = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
@@ -108,9 +135,9 @@ function Groups() {
|
||||
}, [t, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => updateQuery(query), 250);
|
||||
const timeout = setTimeout(() => updateParams("query", query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateQuery]);
|
||||
}, [query, updateParams]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -144,11 +171,18 @@ function Groups() {
|
||||
) : (
|
||||
<>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<HStack>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<LargeGroupSourceFilter
|
||||
activeKey={reqParams.source ?? ""}
|
||||
onSelect={handleSourceFilter}
|
||||
/>
|
||||
</HStack>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<GroupsTable
|
||||
@@ -167,4 +201,8 @@ function Groups() {
|
||||
);
|
||||
}
|
||||
|
||||
const LargeGroupSourceFilter = styled(GroupSourceFilter)`
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
export default observer(Groups);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { runInAction } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
@@ -24,19 +25,14 @@ import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Notifications() {
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
const options = [
|
||||
{
|
||||
@@ -151,6 +147,8 @@ function Notifications() {
|
||||
},
|
||||
];
|
||||
|
||||
const visibleOptions = options.filter((o) => o.visible !== false);
|
||||
|
||||
const showSuccessMessage = debounce(() => {
|
||||
toast.success(t("Notifications saved"));
|
||||
}, 500);
|
||||
@@ -163,6 +161,29 @@ function Notifications() {
|
||||
[user, showSuccessMessage]
|
||||
);
|
||||
|
||||
const handleToggleAll = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
runInAction(() => {
|
||||
const updated = { ...user.notificationSettings };
|
||||
for (const option of visibleOptions) {
|
||||
updated[option.event] = checked;
|
||||
}
|
||||
user.notificationSettings = updated;
|
||||
});
|
||||
await client.post(
|
||||
checked
|
||||
? `/users.notificationsSubscribe`
|
||||
: `/users.notificationsUnsubscribe`
|
||||
);
|
||||
showSuccessMessage();
|
||||
},
|
||||
[user, visibleOptions, showSuccessMessage]
|
||||
);
|
||||
|
||||
const allEnabled = visibleOptions.every((o) =>
|
||||
user.subscribedToEventType(o.event)
|
||||
);
|
||||
|
||||
const showSuccessNotice = window.location.search === "?success";
|
||||
|
||||
return (
|
||||
@@ -180,17 +201,18 @@ function Notifications() {
|
||||
<Trans>Manage when and where you receive email notifications.</Trans>
|
||||
</Text>
|
||||
|
||||
{env.EMAIL_ENABLED && can.manage && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The email integration is currently disabled. Please set the
|
||||
associated environment variables and restart the server to enable
|
||||
notifications.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<h2>{t("Notifications")}</h2>
|
||||
<SettingRow
|
||||
name="allNotifications"
|
||||
label={t("All notifications")}
|
||||
compact
|
||||
border={false}
|
||||
>
|
||||
<Switch
|
||||
id="allNotifications"
|
||||
checked={allEnabled}
|
||||
onChange={handleToggleAll}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{options.map((option) => {
|
||||
const setting = user.subscribedToEventType(option.event);
|
||||
@@ -199,13 +221,14 @@ function Notifications() {
|
||||
<SettingRow
|
||||
key={option.event}
|
||||
visible={option.visible}
|
||||
label={
|
||||
<HStack spacing={4}>
|
||||
{option.icon} {option.title}
|
||||
</HStack>
|
||||
}
|
||||
label={option.title}
|
||||
name={option.event}
|
||||
description={option.description}
|
||||
description={
|
||||
<Text size="small" type="secondary">
|
||||
{option.description}
|
||||
</Text>
|
||||
}
|
||||
compact
|
||||
>
|
||||
<Switch
|
||||
key={option.event}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -16,8 +15,6 @@ import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import type { Item } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { ListItem } from "~/components/Sharing/components/ListItem";
|
||||
@@ -34,6 +31,8 @@ import type { Permission } from "~/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import type GroupUser from "~/models/GroupUser";
|
||||
import Switch from "~/components/Switch";
|
||||
import history from "~/utils/history";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -63,17 +62,14 @@ export function CreateGroupDialog() {
|
||||
try {
|
||||
await group.save();
|
||||
dialogs.closeAllModals();
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
});
|
||||
history.push(settingsPath("groups", group.id, "members"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[t, dialogs, groups, name, description]
|
||||
[dialogs, groups, name, description]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -155,10 +151,17 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so too
|
||||
often might confuse your team mates.
|
||||
</Trans>
|
||||
{group.isExternallyManaged ? (
|
||||
<Trans>
|
||||
This group is managed by an external authentication provider. The
|
||||
name is synced automatically and cannot be changed.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so
|
||||
too often might confuse your team mates.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Flex column>
|
||||
<Input
|
||||
@@ -166,6 +169,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
disabled={group.isExternallyManaged}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
@@ -181,7 +185,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
/>
|
||||
<Switch
|
||||
id="mentions"
|
||||
label={t("Disable mentions")}
|
||||
label={t("Hidden")}
|
||||
note={t(
|
||||
"Prevent this group from being mentionable in documents or comments"
|
||||
)}
|
||||
@@ -225,195 +229,7 @@ export function DeleteGroupDialog({ group, onSubmit }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export const ViewGroupMembersDialog = observer(function ({
|
||||
group,
|
||||
}: Pick<Props, "group">) {
|
||||
const { dialogs, users, groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(group);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [permissionFilter, setPermissionFilter] = React.useState<
|
||||
GroupPermission | "all"
|
||||
>("all");
|
||||
|
||||
const handleAddPeople = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t(`Add people to {{groupName}}`, {
|
||||
groupName: group.name,
|
||||
}),
|
||||
content: <AddPeopleToGroupDialog group={group} />,
|
||||
replace: true,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleRemoveUser = React.useCallback(
|
||||
async (user: User) => {
|
||||
try {
|
||||
await groupUsers.delete({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
toast.success(
|
||||
t(`{{userName}} was removed from the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} catch (_err) {
|
||||
toast.error(t("Could not remove user"));
|
||||
}
|
||||
},
|
||||
[t, groupUsers, group.id]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePermissionFilterChange = React.useCallback((value: string) => {
|
||||
setPermissionFilter(value as GroupPermission | "all");
|
||||
}, []);
|
||||
|
||||
const permissionOptions: Item[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("All permissions"),
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Group admin"),
|
||||
value: GroupPermission.Admin,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Member"),
|
||||
value: GroupPermission.Member,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
let result = users.inGroup(group.id, query);
|
||||
|
||||
if (permissionFilter !== "all") {
|
||||
const groupUserMap = new Map(
|
||||
groupUsers.orderedData
|
||||
.filter((gu) => gu.groupId === group.id)
|
||||
.map((gu) => [gu.userId, gu])
|
||||
);
|
||||
|
||||
result = result.filter((user) => {
|
||||
const groupUser = groupUserMap.get(user.id);
|
||||
return groupUser?.permission === permissionFilter;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [users, group.id, query, permissionFilter, groupUsers.orderedData]);
|
||||
|
||||
const hasActiveFilters = query || permissionFilter !== "all";
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{can.update ? (
|
||||
<>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{can.update && (
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddPeople}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Add people")}…
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
<br />
|
||||
</>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Listing members of the <em>{{groupName}}</em> group."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{(filteredUsers.length || hasActiveFilters) && (
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search members")}
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<InputSelect
|
||||
options={permissionOptions}
|
||||
value={permissionFilter}
|
||||
onChange={handlePermissionFilterChange}
|
||||
label={t("Filter by permissions")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<PaginatedList<User>
|
||||
items={filteredUsers}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={{
|
||||
id: group.id,
|
||||
}}
|
||||
empty={
|
||||
hasActiveFilters ? (
|
||||
<Empty>{t("No members matching your filters")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("This group has no members.")}</Empty>
|
||||
)
|
||||
}
|
||||
renderItem={(user) => (
|
||||
<GroupMemberListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
group={group}
|
||||
groupUser={groupUsers.orderedData.find(
|
||||
(gu) => gu.userId === user.id && gu.groupId === group.id
|
||||
)}
|
||||
onRemove={can.update ? () => handleRemoveUser(user) : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
const AddPeopleToGroupDialog = observer(function ({
|
||||
export const AddPeopleToGroupDialog = observer(function ({
|
||||
group,
|
||||
}: Pick<Props, "group">) {
|
||||
const { dialogs, users, groupUsers } = useStores();
|
||||
@@ -581,7 +397,7 @@ const GroupMemberListItem = observer(function ({
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
)}{" "}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
@@ -619,7 +435,7 @@ const GroupMemberListItem = observer(function ({
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
disabled={!can.update || group.isExternallyManaged}
|
||||
value={groupUser?.permission}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import type Group from "~/models/Group";
|
||||
import type User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Permission } from "~/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import { VStack } from "~/components/primitives/VStack";
|
||||
|
||||
const ROW_HEIGHT = 50;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
group: Group;
|
||||
};
|
||||
|
||||
/**
|
||||
* Table component for displaying group members with permission management.
|
||||
*/
|
||||
export const GroupMembersTable = observer(function GroupMembersTable({
|
||||
group,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { groupUsers } = useStores();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const permissions = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("Group admin"),
|
||||
value: GroupPermission.Admin,
|
||||
},
|
||||
{
|
||||
label: t("Member"),
|
||||
value: GroupPermission.Member,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
label: t("Remove"),
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
] as Permission[],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handlePermissionChange = useCallback(
|
||||
async (
|
||||
user: User,
|
||||
permission: GroupPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
try {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupUsers.delete({
|
||||
userId: user.id,
|
||||
groupId: group.id,
|
||||
});
|
||||
toast.success(
|
||||
t(`{{userName}} was removed from the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await groupUsers.update({
|
||||
userId: user.id,
|
||||
groupId: group.id,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[t, groupUsers, group.id]
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumn<User>[]>(
|
||||
() =>
|
||||
compact<TableColumn<User>>([
|
||||
{
|
||||
type: "data",
|
||||
id: "name",
|
||||
header: t("Name"),
|
||||
accessor: (user) => user.name,
|
||||
component: (user) => (
|
||||
<HStack>
|
||||
<Avatar model={user} size={AvatarSize.Large} />
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<Text selectable>{user.name}</Text>
|
||||
<Text type="tertiary" size="small">
|
||||
{user.email}
|
||||
</Text>
|
||||
</VStack>
|
||||
{user.isAdmin && <Badge primary>{t("Admin")}</Badge>}
|
||||
</HStack>
|
||||
),
|
||||
width: "3fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
header: t("Last active"),
|
||||
accessor: (user) => user.lastActiveAt,
|
||||
component: (user) => (
|
||||
<HStack spacing={4} wrap>
|
||||
{user.lastActiveAt ? (
|
||||
<Time dateTime={user.lastActiveAt} addSuffix />
|
||||
) : (
|
||||
<Text type="tertiary">{t("Never signed in")}</Text>
|
||||
)}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
</HStack>
|
||||
),
|
||||
width: "1fr",
|
||||
},
|
||||
can.update
|
||||
? {
|
||||
type: "data",
|
||||
id: "permission",
|
||||
header: t("Permission"),
|
||||
accessor: (user) => {
|
||||
const gu = groupUsers.orderedData.find(
|
||||
(m) => m.userId === user.id && m.groupId === group.id
|
||||
);
|
||||
return gu?.permission ?? "";
|
||||
},
|
||||
component: (user: User) => (
|
||||
<InputMemberPermissionSelect
|
||||
permissions={permissions}
|
||||
disabled={group.isExternallyManaged}
|
||||
onChange={(permission) =>
|
||||
handlePermissionChange(
|
||||
user,
|
||||
permission as GroupPermission | typeof EmptySelectValue
|
||||
)
|
||||
}
|
||||
value={
|
||||
groupUsers.orderedData.find(
|
||||
(m) => m.userId === user.id && m.groupId === group.id
|
||||
)?.permission
|
||||
}
|
||||
/>
|
||||
),
|
||||
width: "130px",
|
||||
}
|
||||
: undefined,
|
||||
]),
|
||||
[
|
||||
t,
|
||||
can.update,
|
||||
group.id,
|
||||
groupUsers.orderedData,
|
||||
permissions,
|
||||
handlePermissionChange,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import FilterOptions from "~/components/FilterOptions";
|
||||
|
||||
type Props = {
|
||||
activeKey: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter component for group member permissions.
|
||||
*/
|
||||
const GroupPermissionFilter = ({ activeKey, onSelect, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "",
|
||||
label: t("All permissions"),
|
||||
},
|
||||
{
|
||||
key: GroupPermission.Admin,
|
||||
label: t("Group admin"),
|
||||
},
|
||||
{
|
||||
key: GroupPermission.Member,
|
||||
label: t("Member"),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
selectedKeys={[activeKey]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("All permissions")}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupPermissionFilter);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "~/components/FilterOptions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
activeKey: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
};
|
||||
|
||||
const GroupSourceFilter = ({ activeKey, onSelect, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { authenticationProviders } = useStores();
|
||||
|
||||
useEffect(() => {
|
||||
void authenticationProviders.fetchPage({});
|
||||
}, [authenticationProviders]);
|
||||
|
||||
const syncProviders = useMemo(
|
||||
() =>
|
||||
authenticationProviders.orderedData.filter(
|
||||
(p) => p.settings?.groupSyncEnabled
|
||||
),
|
||||
[authenticationProviders.orderedData]
|
||||
);
|
||||
|
||||
if (!syncProviders.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ key: "", label: t("All sources") },
|
||||
{ key: "manual", label: t("Manual") },
|
||||
...syncProviders.map((p) => ({
|
||||
key: p.name,
|
||||
label: p.displayName,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
selectedKeys={[activeKey]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("All sources")}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupSourceFilter);
|
||||
@@ -1,10 +1,11 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import { GroupIcon, HiddenIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import type Group from "~/models/Group";
|
||||
@@ -20,13 +21,13 @@ import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMenu from "~/menus/GroupMenu";
|
||||
import { ViewGroupMembersDialog } from "./GroupDialogs";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
@@ -52,16 +53,14 @@ const GroupRowContextMenu = observer(function GroupRowContextMenu({
|
||||
|
||||
export function GroupsTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
|
||||
const handleViewMembers = useCallback(
|
||||
(group: Group) => {
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
});
|
||||
history.push(settingsPath("groups", group.id, "members"));
|
||||
},
|
||||
[t, dialogs]
|
||||
[history]
|
||||
);
|
||||
|
||||
const applyContextMenu = useCallback(
|
||||
@@ -89,6 +88,14 @@ export function GroupsTable(props: Props) {
|
||||
<Flex column>
|
||||
<Title onClick={() => handleViewMembers(group)}>
|
||||
{group.name}
|
||||
{group.disableMentions && (
|
||||
<>
|
||||
{" "}
|
||||
<Tooltip content={t("This group is hidden")}>
|
||||
<HiddenIcon size={16} color={theme.textSecondary} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Title>
|
||||
<Text type="tertiary" size="small" weight="normal">
|
||||
<Trans
|
||||
@@ -140,6 +147,33 @@ export function GroupsTable(props: Props) {
|
||||
width: "1.5fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "source",
|
||||
header: t("Source"),
|
||||
accessor: (group) => group.externalGroup?.displayName ?? "manual",
|
||||
component: (group) =>
|
||||
group.externalGroup ? (
|
||||
<Flex column>
|
||||
<Text type="secondary" size="small" weight="normal">
|
||||
{group.externalGroup.displayName}
|
||||
</Text>
|
||||
{group.externalGroup.lastSyncedAt && (
|
||||
<Text type="tertiary" size="xsmall" weight="normal">
|
||||
<Trans>
|
||||
Synced{" "}
|
||||
<Time
|
||||
dateTime={group.externalGroup.lastSyncedAt}
|
||||
addSuffix
|
||||
shorten
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
) : null,
|
||||
width: "1fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
@@ -158,7 +192,7 @@ export function GroupsTable(props: Props) {
|
||||
width: "50px",
|
||||
},
|
||||
]),
|
||||
[t, handleViewMembers]
|
||||
[t, handleViewMembers, theme.textSecondary]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,7 +56,7 @@ const TemplateRowContextMenu = observer(function TemplateRowContextMenu({
|
||||
export function TemplatesTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOpen = (template: Template) => () => {
|
||||
const handleOpen = (template: Template) => {
|
||||
history.push(template.path);
|
||||
};
|
||||
|
||||
@@ -122,7 +122,10 @@ export function TemplatesTable(props: Props) {
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (template) => (
|
||||
<TemplateMenu template={template} onEdit={handleOpen(template)} />
|
||||
<TemplateMenu
|
||||
template={template}
|
||||
onEdit={() => handleOpen(template)}
|
||||
/>
|
||||
),
|
||||
width: "50px",
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import styled, { ThemeProvider } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -18,9 +19,11 @@ import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useBuildTheme from "~/hooks/useBuildTheme";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, OfflineError } from "~/utils/errors";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
@@ -151,6 +154,18 @@ function SharedScene() {
|
||||
)
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
useCallback(
|
||||
(ev: KeyboardEvent) => isModKey(ev) && ev.shiftKey && ev.code === "KeyL",
|
||||
[]
|
||||
),
|
||||
useCallback(() => {
|
||||
if (!ui.themeOverride) {
|
||||
ui.setTheme(ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light);
|
||||
}
|
||||
}, [ui])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
void changeLanguage(detectLanguage(), i18n);
|
||||
|
||||
@@ -13,10 +13,10 @@ type DialogDefinition = {
|
||||
};
|
||||
|
||||
export default class DialogsStore {
|
||||
@observable
|
||||
@observable.shallow
|
||||
guide: DialogDefinition;
|
||||
|
||||
@observable
|
||||
@observable.shallow
|
||||
modalStack = new Map<string, DialogDefinition>();
|
||||
|
||||
openGuide = ({
|
||||
|
||||
@@ -53,6 +53,9 @@ export default class DocumentsStore extends Store<Document> {
|
||||
@observable
|
||||
backlinks: Map<string, string[]> = new Map();
|
||||
|
||||
@observable
|
||||
similar: Map<string, string[]> = new Map();
|
||||
|
||||
@observable
|
||||
movingDocumentId: string | null | undefined;
|
||||
|
||||
@@ -254,16 +257,27 @@ export default class DocumentsStore extends Store<Document> {
|
||||
}
|
||||
|
||||
@action
|
||||
fetchBacklinks = async (documentId: string): Promise<void> => {
|
||||
const documents = await this.fetchAll({
|
||||
backlinkDocumentId: documentId,
|
||||
});
|
||||
fetchRelationships = async (documentId: string): Promise<void> => {
|
||||
const res = await client.post("/relationships.list", { documentId });
|
||||
invariant(res?.data, "Relationships not available");
|
||||
|
||||
runInAction("DocumentsStore#fetchBacklinks", () => {
|
||||
this.backlinks.set(
|
||||
documentId,
|
||||
documents.map((doc) => doc.id)
|
||||
);
|
||||
runInAction("DocumentsStore#fetchRelationships", () => {
|
||||
res.data.documents.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
|
||||
const backlinkIds: string[] = [];
|
||||
const similarIds: string[] = [];
|
||||
|
||||
for (const relationship of res.data.relationships) {
|
||||
if (relationship.type === "backlink") {
|
||||
backlinkIds.push(relationship.reverseDocumentId);
|
||||
} else if (relationship.type === "similar") {
|
||||
similarIds.push(relationship.reverseDocumentId);
|
||||
}
|
||||
}
|
||||
|
||||
this.backlinks.set(documentId, backlinkIds);
|
||||
this.similar.set(documentId, similarIds);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -276,6 +290,15 @@ export default class DocumentsStore extends Store<Document> {
|
||||
);
|
||||
}
|
||||
|
||||
getSimilarDocuments(documentId: string): Document[] {
|
||||
const documentIds = this.similar.get(documentId) || [];
|
||||
return orderBy(
|
||||
compact(documentIds.map((id) => this.data.get(id))),
|
||||
"title",
|
||||
"asc"
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
fetchChildDocuments = async (documentId: string): Promise<void> => {
|
||||
const res = await client.post(`/documents.list`, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { flushSync } from "react-dom";
|
||||
import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import Document from "~/models/Document";
|
||||
import type Model from "~/models/base/Model";
|
||||
@@ -92,6 +93,32 @@ class UiStore {
|
||||
@observable
|
||||
debugSafeArea = false;
|
||||
|
||||
/** Data for the currently active presentation, if any. */
|
||||
@observable
|
||||
presentationData: {
|
||||
title: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
data: ProsemirrorData;
|
||||
} | null = null;
|
||||
|
||||
/**
|
||||
* Enter presentation mode for the given document.
|
||||
*
|
||||
* @param document the document to present, or null to exit.
|
||||
*/
|
||||
@action
|
||||
setPresentingDocument = (document: Document | null): void => {
|
||||
this.presentationData = document
|
||||
? {
|
||||
title: document.title,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
data: document.data,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
/** Tracks active export toasts for in-place updates when export completes */
|
||||
exportToasts = observable.map<
|
||||
string,
|
||||
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
declare module "web-haptics" {
|
||||
interface Vibration {
|
||||
duration: number;
|
||||
intensity?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
type HapticPattern = number[] | Vibration[];
|
||||
|
||||
interface HapticPreset {
|
||||
pattern: Vibration[];
|
||||
}
|
||||
|
||||
type HapticInput = number | string | HapticPattern | HapticPreset;
|
||||
|
||||
interface TriggerOptions {
|
||||
intensity?: number;
|
||||
}
|
||||
|
||||
interface WebHapticsOptions {
|
||||
debug?: boolean;
|
||||
showSwitch?: boolean;
|
||||
}
|
||||
|
||||
export {
|
||||
HapticInput,
|
||||
HapticPattern,
|
||||
HapticPreset,
|
||||
TriggerOptions,
|
||||
Vibration,
|
||||
WebHapticsOptions,
|
||||
};
|
||||
}
|
||||
|
||||
declare module "web-haptics/react" {
|
||||
import type {
|
||||
HapticInput,
|
||||
TriggerOptions,
|
||||
WebHapticsOptions,
|
||||
} from "web-haptics";
|
||||
|
||||
function useWebHaptics(options?: WebHapticsOptions): {
|
||||
trigger: (
|
||||
input?: HapticInput,
|
||||
options?: TriggerOptions
|
||||
) => Promise<void> | undefined;
|
||||
cancel: () => void | undefined;
|
||||
isSupported: boolean;
|
||||
};
|
||||
|
||||
export { useWebHaptics };
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import queryString from "query-string";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import type { JSONObject } from "@shared/types";
|
||||
import { Scope } from "@shared/types";
|
||||
import { version } from "../../package.json";
|
||||
import env from "~/env";
|
||||
import stores from "~/stores";
|
||||
import Logger from "./Logger";
|
||||
import download from "./download";
|
||||
@@ -109,6 +111,7 @@ class ApiClient {
|
||||
"cache-control": "no-cache",
|
||||
"x-editor-version": EDITOR_VERSION,
|
||||
"x-api-version": "4",
|
||||
"x-client-version": env.VERSION ? `${version}-${env.VERSION}` : version,
|
||||
pragma: "no-cache",
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ export class PluginManager {
|
||||
}
|
||||
|
||||
if (!this.plugins.has(plugin.type)) {
|
||||
this.plugins.set(plugin.type, observable.array([]));
|
||||
this.plugins.set(plugin.type, observable.array([], { deep: false }));
|
||||
}
|
||||
|
||||
this.plugins
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Calls preventDefault on the event. Useful as a stable callback reference.
|
||||
*
|
||||
* @param event the event to prevent default on.
|
||||
*/
|
||||
export const preventDefault = (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
+51
-10
@@ -2,6 +2,19 @@ import type { IntegrationSettings, IntegrationType } from "@shared/types";
|
||||
import { IntegrationService, MentionType } from "@shared/types";
|
||||
import type Integration from "~/models/Integration";
|
||||
|
||||
const gitlabSystemPaths = new Set([
|
||||
"explore",
|
||||
"help",
|
||||
"admin",
|
||||
"dashboard",
|
||||
"users",
|
||||
"groups",
|
||||
"projects",
|
||||
"snippets",
|
||||
"search",
|
||||
"-",
|
||||
]);
|
||||
|
||||
export const isURLMentionable = ({
|
||||
url,
|
||||
integration,
|
||||
@@ -30,9 +43,15 @@ export const isURLMentionable = ({
|
||||
case IntegrationService.GitLab: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
const gitlabHostname = settings.gitlab?.url
|
||||
? new URL(settings.gitlab?.url).hostname
|
||||
: undefined;
|
||||
let gitlabHostname: string | undefined;
|
||||
try {
|
||||
gitlabHostname = settings.gitlab?.url
|
||||
? new URL(settings.gitlab.url).hostname
|
||||
: undefined;
|
||||
} catch {
|
||||
// Invalid URL stored in settings
|
||||
return false;
|
||||
}
|
||||
|
||||
return hostname === "gitlab.com" || hostname === gitlabHostname;
|
||||
}
|
||||
@@ -59,20 +78,42 @@ export const determineMentionType = ({
|
||||
? MentionType.PullRequest
|
||||
: type === "issues"
|
||||
? MentionType.Issue
|
||||
: undefined;
|
||||
: type === "projects"
|
||||
? MentionType.Project
|
||||
: undefined;
|
||||
}
|
||||
|
||||
case IntegrationService.Linear: {
|
||||
const type = pathParts[2];
|
||||
return type === "issue" ? MentionType.Issue : undefined;
|
||||
return type === "issue"
|
||||
? MentionType.Issue
|
||||
: type === "project"
|
||||
? MentionType.Project
|
||||
: undefined;
|
||||
}
|
||||
|
||||
case IntegrationService.GitLab: {
|
||||
return pathname.includes("merge_requests")
|
||||
? MentionType.PullRequest
|
||||
: pathname.includes("issues")
|
||||
? MentionType.Issue
|
||||
: undefined;
|
||||
const hasShowParam = url.searchParams.has("show");
|
||||
|
||||
if (
|
||||
/\/-\/merge_requests\/\d+/.test(pathname) ||
|
||||
(/\/-\/merge_requests\/?$/.test(pathname) && hasShowParam)
|
||||
) {
|
||||
return MentionType.PullRequest;
|
||||
}
|
||||
if (
|
||||
/\/-\/(issues|work_items)\/\d+/.test(pathname) ||
|
||||
(/\/-\/(issues|work_items)\/?$/.test(pathname) && hasShowParam)
|
||||
) {
|
||||
return MentionType.Issue;
|
||||
}
|
||||
if (!pathname.includes("/-/")) {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
if (parts.length >= 2 && !gitlabSystemPaths.has(parts[0])) {
|
||||
return MentionType.Project;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sharedModelPath } from "./routeHelpers";
|
||||
import { sharedModelPath, desktopify } from "./routeHelpers";
|
||||
|
||||
describe("#sharedDocumentPath", () => {
|
||||
test("should return share path for a document", () => {
|
||||
@@ -12,3 +12,25 @@ describe("#sharedDocumentPath", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#desktopify", () => {
|
||||
test("should replace https protocol with outline://", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { origin: "https://app.getoutline.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(desktopify("/doc/test-DjDlkBi77t")).toBe(
|
||||
"outline://app.getoutline.com/doc/test-DjDlkBi77t"
|
||||
);
|
||||
});
|
||||
|
||||
test("should replace http protocol with outline://", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { origin: "http://localhost:3000" },
|
||||
writable: true,
|
||||
});
|
||||
expect(desktopify("/doc/test-DjDlkBi77t")).toBe(
|
||||
"outline://localhost:3000/doc/test-DjDlkBi77t"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,6 +179,16 @@ export function urlify(path: string): string {
|
||||
return `${window.location.origin}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a path to a desktop app URL using the outline:// protocol.
|
||||
*
|
||||
* @param path The path to convert.
|
||||
* @returns The desktop app URL.
|
||||
*/
|
||||
export function desktopify(path: string): string {
|
||||
return urlify(path).replace(/^https?:\/\//, "outline://");
|
||||
}
|
||||
|
||||
export const matchCollectionSlug =
|
||||
":collectionSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
|
||||
|
||||
|
||||
+5
-3
@@ -106,6 +106,7 @@
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/form-data": "^2.5.2",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/sanitize-filename": "^1.6.3",
|
||||
"@vitejs/plugin-react-oxc": "^0.2.3",
|
||||
"addressparser": "^1.0.1",
|
||||
@@ -121,7 +122,6 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"core-js": "^3.45.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.82.0",
|
||||
"diff": "^5.2.0",
|
||||
@@ -138,6 +138,7 @@
|
||||
"fs-extra": "^11.3.2",
|
||||
"fuzzy-search": "^3.2.1",
|
||||
"glob": "^8.1.0",
|
||||
"hot-shots": "^12.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"i18next": "^22.5.1",
|
||||
@@ -179,7 +180,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^4.1.0",
|
||||
"outline-icons": "^4.3.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"pako": "^2.1.0",
|
||||
"passport": "^0.7.0",
|
||||
@@ -265,6 +266,7 @@
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "npm:rolldown-vite@7.3.0",
|
||||
"vite-plugin-pwa": "1.0.3",
|
||||
"web-haptics": "^0.0.6",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
@@ -390,6 +392,6 @@
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.1",
|
||||
"packageManager": "yarn@4.11.0"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
store: new StateStore(),
|
||||
state: true,
|
||||
callbackURL: `${env.URL}/auth/${config.id}.callback`,
|
||||
authorizationURL: "https://discord.com/api/oauth2/authorize",
|
||||
authorizationURL:
|
||||
"https://discord.com/api/oauth2/authorize?prompt=none",
|
||||
tokenURL: "https://discord.com/api/oauth2/token",
|
||||
pkce: false,
|
||||
},
|
||||
@@ -227,7 +228,6 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
config.id,
|
||||
passport.authenticate(config.id, {
|
||||
scope,
|
||||
prompt: "consent",
|
||||
})
|
||||
);
|
||||
router.get(`${config.id}.callback`, passportMiddleware(config.id));
|
||||
|
||||
@@ -80,6 +80,7 @@ router.post(
|
||||
// send email to users email address with a short-lived token and code
|
||||
await new SigninEmail({
|
||||
to: user.email,
|
||||
language: user.language,
|
||||
token,
|
||||
teamUrl: team.url,
|
||||
client,
|
||||
@@ -171,6 +172,7 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
if (user.isInvited) {
|
||||
await new WelcomeEmail({
|
||||
to: user.email,
|
||||
language: user.language,
|
||||
role: user.role,
|
||||
teamUrl: user.team.url,
|
||||
}).schedule();
|
||||
@@ -179,6 +181,7 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
if (inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)) {
|
||||
await new InviteAcceptedEmail({
|
||||
to: inviter.email,
|
||||
language: inviter.language,
|
||||
inviterId: inviter.id,
|
||||
invitedName: user.name,
|
||||
teamUrl: user.team.url,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import env from "./env";
|
||||
import { FigmaUtils } from "../shared/FigmaUtils";
|
||||
import type { UnfurlSignature } from "@server/types";
|
||||
|
||||
+171
-11
@@ -13,7 +13,11 @@ import { IntegrationService, UnfurlResourceType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import type { User } from "@server/models";
|
||||
import { Integration } from "@server/models";
|
||||
import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import type {
|
||||
UnfurlIssueOrPR,
|
||||
UnfurlProject,
|
||||
UnfurlSignature,
|
||||
} from "@server/types";
|
||||
import { GitHubUtils } from "../shared/GitHubUtils";
|
||||
import env from "./env";
|
||||
|
||||
@@ -24,6 +28,33 @@ type Issue =
|
||||
type Installation =
|
||||
Endpoints["GET /app/installations/{installation_id}"]["response"]["data"];
|
||||
|
||||
type ParsedIssueOrPR = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
type: UnfurlResourceType.Issue | UnfurlResourceType.PR;
|
||||
id: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ParsedProject = {
|
||||
owner: string;
|
||||
ownerType: "orgs" | "users";
|
||||
type: UnfurlResourceType.Project;
|
||||
projectNumber: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type GitHubResource = ParsedIssueOrPR | ParsedProject;
|
||||
|
||||
type GitHubProject = {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
closed: boolean;
|
||||
};
|
||||
|
||||
const requestPlugin = (octokit: Octokit) => ({
|
||||
requestRepos: () =>
|
||||
octokit.paginate.iterator(
|
||||
@@ -36,7 +67,7 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
}
|
||||
),
|
||||
|
||||
requestPR: async (params: NonNullable<ReturnType<typeof GitHub.parseUrl>>) =>
|
||||
requestPR: async (params: ParsedIssueOrPR) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
@@ -47,9 +78,7 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
},
|
||||
}),
|
||||
|
||||
requestIssue: async (
|
||||
params: NonNullable<ReturnType<typeof GitHub.parseUrl>>
|
||||
) =>
|
||||
requestIssue: async (params: ParsedIssueOrPR) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
@@ -60,6 +89,61 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
},
|
||||
}),
|
||||
|
||||
/**
|
||||
* Fetches details of a GitHub ProjectV2 using the GraphQL API.
|
||||
*
|
||||
* @param params Parsed project URL identifiers.
|
||||
* @returns Project data or undefined if not found.
|
||||
*/
|
||||
requestProject: async (
|
||||
params: ParsedProject
|
||||
): Promise<GitHubProject | undefined> => {
|
||||
const ownerField = params.ownerType === "orgs" ? "organization" : "user";
|
||||
|
||||
const query = `query($login: String!, $number: Int!) {
|
||||
${ownerField}(login: $login) {
|
||||
projectV2(number: $number) {
|
||||
number
|
||||
title
|
||||
shortDescription
|
||||
url
|
||||
createdAt
|
||||
closed
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = await octokit.graphql<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
projectV2: {
|
||||
number: number;
|
||||
title: string;
|
||||
shortDescription: string | null;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
closed: boolean;
|
||||
} | null;
|
||||
}
|
||||
>
|
||||
>(query, { login: params.owner, number: params.projectNumber });
|
||||
|
||||
const project = result[ownerField]?.projectV2;
|
||||
if (!project) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
number: project.number,
|
||||
title: project.title,
|
||||
description: project.shortDescription,
|
||||
url: project.url,
|
||||
createdAt: project.createdAt,
|
||||
closed: project.closed,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches app installations accessible to the user
|
||||
*
|
||||
@@ -75,7 +159,7 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
* @returns Response containing resource details
|
||||
*/
|
||||
requestResource: async function requestResource(
|
||||
resource: ReturnType<typeof GitHub.parseUrl>
|
||||
resource: GitHubResource | undefined
|
||||
): Promise<OctokitResponse<Issue | PR> | undefined> {
|
||||
switch (resource?.type) {
|
||||
case UnfurlResourceType.PR:
|
||||
@@ -139,7 +223,7 @@ export class GitHub {
|
||||
* @param url URL to parse
|
||||
* @returns {object} Containing resource identifiers - `owner`, `repo`, `type` and `id`.
|
||||
*/
|
||||
public static parseUrl(url: string) {
|
||||
public static parseUrl(url: string): GitHubResource | undefined {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "github.com") {
|
||||
@@ -147,6 +231,29 @@ export class GitHub {
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
|
||||
// Handle project URLs: /orgs/{org}/projects/{number} or /users/{user}/projects/{number}
|
||||
if (
|
||||
(parts[1] === "orgs" || parts[1] === "users") &&
|
||||
parts[3] === "projects"
|
||||
) {
|
||||
const ownerType = parts[1] as "orgs" | "users";
|
||||
const owner = parts[2];
|
||||
const projectNumber = Number(parts[4]);
|
||||
|
||||
if (!owner || isNaN(projectNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
ownerType,
|
||||
type: UnfurlResourceType.Project,
|
||||
projectNumber,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
const owner = parts[1];
|
||||
const repo = parts[2];
|
||||
const type = parts[3]
|
||||
@@ -154,11 +261,17 @@ export class GitHub {
|
||||
: undefined;
|
||||
const id = Number(parts[4]);
|
||||
|
||||
if (!type || !GitHub.supportedResources.includes(type)) {
|
||||
if (!type || !GitHub.supportedResources.includes(type) || isNaN(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { owner, repo, type, id, url };
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
type: type as UnfurlResourceType.Issue | UnfurlResourceType.PR,
|
||||
id,
|
||||
url,
|
||||
};
|
||||
} catch (_err) {
|
||||
// Invalid URL format
|
||||
return;
|
||||
@@ -261,6 +374,10 @@ export class GitHub {
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
|
||||
if (resource.type === UnfurlResourceType.Project) {
|
||||
return GitHub.unfurlProject(client, resource);
|
||||
}
|
||||
|
||||
const res = await client.requestResource(resource);
|
||||
if (!res) {
|
||||
return { error: "Resource not found" };
|
||||
@@ -273,9 +390,52 @@ export class GitHub {
|
||||
}
|
||||
};
|
||||
|
||||
private static async unfurlProject(
|
||||
client: InstanceType<typeof CustomOctokit>,
|
||||
resource: ParsedProject
|
||||
) {
|
||||
let project: GitHubProject | undefined;
|
||||
try {
|
||||
project = await client.requestProject(resource);
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch project from GitHub", err);
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
const state = project.closed ? "completed" : "open";
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Project,
|
||||
url: project.url,
|
||||
id: `#${project.number}`,
|
||||
name: project.title,
|
||||
color: GitHubUtils.getColorForStatus(state),
|
||||
description: project.description,
|
||||
lead: null,
|
||||
state: {
|
||||
type: state,
|
||||
name: state,
|
||||
color: GitHubUtils.getColorForStatus(state),
|
||||
},
|
||||
labels: [],
|
||||
createdAt: project.createdAt,
|
||||
targetDate: null,
|
||||
} satisfies UnfurlProject;
|
||||
}
|
||||
|
||||
private static transformData(data: Issue | PR, type: UnfurlResourceType) {
|
||||
if (type === UnfurlResourceType.Issue) {
|
||||
const issue = data as Issue;
|
||||
const issueState =
|
||||
issue.state === "closed"
|
||||
? issue.state_reason === "completed"
|
||||
? "completed"
|
||||
: "canceled"
|
||||
: issue.state;
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
url: issue.html_url,
|
||||
@@ -291,8 +451,8 @@ export class GitHub {
|
||||
color: `#${label.color}`,
|
||||
})),
|
||||
state: {
|
||||
name: issue.state,
|
||||
color: GitHubUtils.getColorForStatus(issue.state),
|
||||
name: issueState,
|
||||
color: GitHubUtils.getColorForStatus(issueState),
|
||||
},
|
||||
createdAt: issue.created_at,
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
|
||||
@@ -53,6 +53,7 @@ export class GitHubUtils {
|
||||
return "#a371f7";
|
||||
case "closed":
|
||||
return "#f85149";
|
||||
case "completed":
|
||||
case "merged":
|
||||
return "#8250df";
|
||||
case "canceled":
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Gitlab } from "@gitbeaker/rest";
|
||||
import type {
|
||||
IssueSchemaWithExpandedLabels,
|
||||
MergeRequestSchema,
|
||||
ProjectSchema,
|
||||
StatisticsSchema,
|
||||
} from "@gitbeaker/rest";
|
||||
import z from "zod";
|
||||
import {
|
||||
@@ -12,7 +14,11 @@ import {
|
||||
import Logger from "@server/logging/Logger";
|
||||
import type { User } from "@server/models";
|
||||
import { Integration, IntegrationAuthentication } from "@server/models";
|
||||
import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import type {
|
||||
UnfurlIssueOrPR,
|
||||
UnfurlProject,
|
||||
UnfurlSignature,
|
||||
} from "@server/types";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import { validateUrlNotPrivate } from "@server/utils/url";
|
||||
import { GitLabUtils } from "../shared/GitLabUtils";
|
||||
@@ -208,10 +214,6 @@ export class GitLab {
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
Logger.debug(
|
||||
"plugins",
|
||||
`Could not parse GitLab resource from URL: ${url}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -254,6 +256,13 @@ export class GitLab {
|
||||
customUrl
|
||||
);
|
||||
return this.transformMR(mr);
|
||||
} else if (resource.type === UnfurlResourceType.Project) {
|
||||
const client = await this.createClient(token, customUrl);
|
||||
const [project, issueStats] = await Promise.all([
|
||||
client.Projects.show(projectPath),
|
||||
client.IssuesStatistics.all({ projectId: projectPath }),
|
||||
]);
|
||||
return this.transformProject(project, issueStats);
|
||||
}
|
||||
|
||||
return { error: "Resource not found" };
|
||||
@@ -390,4 +399,45 @@ export class GitLab {
|
||||
createdAt: mr.created_at,
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
}
|
||||
|
||||
private static transformProject(
|
||||
project: ProjectSchema,
|
||||
issueStats: StatisticsSchema
|
||||
) {
|
||||
const visibility = project.visibility ?? "private";
|
||||
const owner = project.owner as
|
||||
| { name: string; avatar_url?: string }
|
||||
| undefined;
|
||||
const { opened, closed } = issueStats.statistics.counts;
|
||||
const total = opened + closed;
|
||||
const progress = total > 0 ? closed / total : 0;
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Project,
|
||||
url: project.web_url,
|
||||
id: String(project.id),
|
||||
name: project.name,
|
||||
color: GitLabUtils.getColorForProject(project.id),
|
||||
avatarUrl: project.avatar_url || undefined,
|
||||
description: project.description ?? null,
|
||||
lead: owner
|
||||
? {
|
||||
name: owner.name,
|
||||
avatarUrl: owner.avatar_url ?? "",
|
||||
}
|
||||
: null,
|
||||
state: {
|
||||
type: visibility,
|
||||
name: visibility.charAt(0).toUpperCase() + visibility.slice(1),
|
||||
color: GitLabUtils.getColorForVisibility(visibility),
|
||||
},
|
||||
labels: (project.topics ?? []).map((topic: string) => ({
|
||||
name: topic,
|
||||
color: "#6B7280",
|
||||
})),
|
||||
progress,
|
||||
createdAt: project.created_at,
|
||||
targetDate: null,
|
||||
} satisfies UnfurlProject;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,32 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a work_items URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/work_items/39"
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "speak",
|
||||
repo: "purser",
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: 39,
|
||||
url: "https://gitlab.com/speak/purser/-/work_items/39",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a nested group work_items URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/group/subgroup/repo/-/work_items/5"
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "group/subgroup",
|
||||
repo: "repo",
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: 5,
|
||||
url: "https://gitlab.com/group/subgroup/repo/-/work_items/5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for unsupported resource type", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/pipelines/100"
|
||||
@@ -49,8 +75,18 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for a URL with too few path segments", () => {
|
||||
it("should parse a project URL", () => {
|
||||
const result = GitLabUtils.parseUrl("https://gitlab.com/speak/purser");
|
||||
expect(result).toEqual({
|
||||
owner: "speak",
|
||||
repo: "purser",
|
||||
type: UnfurlResourceType.Project,
|
||||
url: "https://gitlab.com/speak/purser",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for a URL with too few path segments", () => {
|
||||
const result = GitLabUtils.parseUrl("https://gitlab.com/speak");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -60,6 +96,54 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for an issues list URL without an ID", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/issues"
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should parse a nested group project URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/group/subgroup/repo"
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "group/subgroup",
|
||||
repo: "repo",
|
||||
type: UnfurlResourceType.Project,
|
||||
url: "https://gitlab.com/group/subgroup/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for an invalid custom URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.example.com/team/project/-/issues/10",
|
||||
"not-a-valid-url"
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for system paths", () => {
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/explore/projects")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/help/topics")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/admin/users")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/dashboard/projects")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/users/someone")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/groups/mygroup")
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("base64 show parameter URLs", () => {
|
||||
@@ -124,6 +208,22 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a work_items URL with show parameter", () => {
|
||||
const show = btoa(
|
||||
JSON.stringify({ iid: "39", full_path: "speak/purser", id: 1215135 })
|
||||
);
|
||||
const result = GitLabUtils.parseUrl(
|
||||
`https://gitlab.com/speak/purser/-/work_items?show=${show}`
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "speak",
|
||||
repo: "purser",
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: 39,
|
||||
url: `https://gitlab.com/speak/purser/-/work_items?show=${show}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for invalid base64 in show parameter", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/issues?show=not-valid-base64!!!"
|
||||
@@ -155,6 +255,19 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a project URL with a custom URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://git.example.com/team/project",
|
||||
"https://git.example.com"
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "team",
|
||||
repo: "project",
|
||||
type: UnfurlResourceType.Project,
|
||||
url: "https://git.example.com/team/project",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not match default gitlab.com when custom URL is set", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/issues/1",
|
||||
|
||||
@@ -8,6 +8,7 @@ export class GitLabUtils {
|
||||
private static supportedResources = [
|
||||
UnfurlResourceType.Issue,
|
||||
UnfurlResourceType.PR,
|
||||
UnfurlResourceType.Project,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -103,27 +104,95 @@ export class GitLabUtils {
|
||||
* @param customUrl - Optional custom GitLab URL from integration settings.
|
||||
* @returns An object containing resource identifiers or undefined if the URL is invalid.
|
||||
*/
|
||||
public static parseUrl(url: string, customUrl?: string) {
|
||||
const parsed = new URL(url);
|
||||
const urlHostname = new URL(this.getGitlabUrl(customUrl)).hostname;
|
||||
public static parseUrl(
|
||||
url: string,
|
||||
customUrl?: string
|
||||
):
|
||||
| {
|
||||
owner: string;
|
||||
repo: string | undefined;
|
||||
type: UnfurlResourceType.Issue | UnfurlResourceType.PR;
|
||||
id: number;
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
owner: string;
|
||||
repo: string;
|
||||
type: UnfurlResourceType.Project;
|
||||
url: string;
|
||||
}
|
||||
| undefined {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const urlHostname = new URL(this.getGitlabUrl(customUrl)).hostname;
|
||||
|
||||
if (parsed.hostname !== urlHostname) {
|
||||
return;
|
||||
}
|
||||
if (parsed.hostname !== urlHostname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
|
||||
// Try base64-encoded `show` query parameter first
|
||||
// e.g. /owner/repo/-/issues?show=eyJ...
|
||||
const showParam = parsed.searchParams.get("show");
|
||||
if (showParam && parts.length >= 4) {
|
||||
// Try base64-encoded `show` query parameter first
|
||||
// e.g. /owner/repo/-/issues?show=eyJ...
|
||||
const showParam = parsed.searchParams.get("show");
|
||||
if (showParam && parts.length >= 4) {
|
||||
const resourceType = parts.pop();
|
||||
parts.pop(); // separator ("-")
|
||||
const repo = parts.pop();
|
||||
const owner = parts.join("/");
|
||||
|
||||
const type =
|
||||
resourceType === "issues" || resourceType === "work_items"
|
||||
? UnfurlResourceType.Issue
|
||||
: resourceType === "merge_requests"
|
||||
? UnfurlResourceType.PR
|
||||
: undefined;
|
||||
|
||||
if (!type || !this.supportedResources.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = JSON.parse(atob(decodeURIComponent(showParam)));
|
||||
const iid = Number(decoded.iid);
|
||||
if (!iid) {
|
||||
return;
|
||||
}
|
||||
return { owner, repo, type, id: iid, url };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a project URL (no -/ separator pattern in path)
|
||||
if (!parsed.pathname.includes("/-/")) {
|
||||
if (parts.length >= 2 && !this.isSystemPath(parts[0])) {
|
||||
const repo = parts[parts.length - 1];
|
||||
const owner = parts.slice(0, -1).join("/");
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
type: UnfurlResourceType.Project,
|
||||
url,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct URL: /owner/repo/-/issues/123 or /owner/repo/-/merge_requests/123
|
||||
const resourceId = parts.pop();
|
||||
const resourceType = parts.pop();
|
||||
parts.pop(); // separator ("-")
|
||||
|
||||
const repo = parts.pop();
|
||||
const owner = parts.join("/");
|
||||
|
||||
const type =
|
||||
resourceType === "issues"
|
||||
resourceType === "issues" || resourceType === "work_items"
|
||||
? UnfurlResourceType.Issue
|
||||
: resourceType === "merge_requests"
|
||||
? UnfurlResourceType.PR
|
||||
@@ -133,48 +202,38 @@ export class GitLabUtils {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = JSON.parse(atob(decodeURIComponent(showParam)));
|
||||
const iid = Number(decoded.iid);
|
||||
if (!iid) {
|
||||
return;
|
||||
}
|
||||
return { owner, repo, type, id: iid, url };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length < 5) {
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
type,
|
||||
id: Number(resourceId),
|
||||
url,
|
||||
};
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Direct URL: /owner/repo/-/issues/123 or /owner/repo/-/merge_requests/123
|
||||
const resourceId = parts.pop();
|
||||
const resourceType = parts.pop();
|
||||
parts.pop(); // separator ("-")
|
||||
|
||||
const repo = parts.pop();
|
||||
const owner = parts.join("/");
|
||||
|
||||
const type =
|
||||
resourceType === "issues"
|
||||
? UnfurlResourceType.Issue
|
||||
: resourceType === "merge_requests"
|
||||
? UnfurlResourceType.PR
|
||||
: undefined;
|
||||
|
||||
if (!type || !this.supportedResources.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
type,
|
||||
id: Number(resourceId),
|
||||
url,
|
||||
};
|
||||
/**
|
||||
* Checks if the first path segment is a known GitLab system path.
|
||||
*
|
||||
* @param segment - the first path segment of the URL.
|
||||
* @returns true if the segment is a known system path.
|
||||
*/
|
||||
private static isSystemPath(segment: string): boolean {
|
||||
const systemPaths = new Set([
|
||||
"explore",
|
||||
"help",
|
||||
"admin",
|
||||
"dashboard",
|
||||
"users",
|
||||
"groups",
|
||||
"projects",
|
||||
"snippets",
|
||||
"search",
|
||||
"-",
|
||||
]);
|
||||
return systemPaths.has(segment);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,7 +254,42 @@ export class GitLabUtils {
|
||||
merged: "#8250df",
|
||||
canceled: "#848d97",
|
||||
};
|
||||
|
||||
return statusColors[status] ?? "#848d97";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deterministic color for a GitLab project based on its ID.
|
||||
* Mirrors GitLab's identicon algorithm: (id % 7) mapped to a palette.
|
||||
*
|
||||
* @param projectId - the numeric project ID.
|
||||
* @returns a hex color string.
|
||||
*/
|
||||
public static getColorForProject(projectId: number): string {
|
||||
const palette = [
|
||||
"#e05842", // red
|
||||
"#a972cc", // purple
|
||||
"#5b6abf", // indigo
|
||||
"#3e8fda", // blue
|
||||
"#42a68c", // teal
|
||||
"#e67e3c", // orange
|
||||
"#7e7e7e", // neutral
|
||||
];
|
||||
return palette[projectId % 7];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the color associated with a given visibility level.
|
||||
*
|
||||
* @param visibility - The visibility level of the resource.
|
||||
* @returns The color associated with the visibility level.
|
||||
*/
|
||||
public static getColorForVisibility(visibility: string): string {
|
||||
const visibilityColors: Record<string, string> = {
|
||||
public: "#1f75cb",
|
||||
internal: "#f8ae1a",
|
||||
private: "#848d97",
|
||||
};
|
||||
|
||||
return visibilityColors[visibility] ?? "#848d97";
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user