mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c802a0434 | |||
| 6d76379821 | |||
| cd84eb7228 | |||
| f686edbcf4 | |||
| cc849be25e | |||
| 26f16939ca | |||
| e4917cc4bd | |||
| 0f4c1d7db5 | |||
| 554c7a8111 | |||
| 6cf230963e | |||
| 39f9bfbbcd | |||
| 987dceed28 | |||
| 047239ae16 | |||
| 1704a40045 | |||
| 39c2aca883 | |||
| dc3952212f | |||
| f3b4640c7a | |||
| 4ab2b22f7b | |||
| 9b973c64e9 | |||
| a85fec57cc | |||
| c2069db882 | |||
| 5ace3363ac | |||
| ee285bc4d5 | |||
| b23596600b | |||
| 384186c318 | |||
| 6660cd6746 | |||
| 6a16dc07c1 | |||
| 16038896b4 | |||
| c7f7c43aaf | |||
| b1fd8878f4 | |||
| 44eabf4b8d | |||
| 301a6f1177 | |||
| 277d9fb0d9 | |||
| fde507f34f | |||
| 34bdd59f35 | |||
| 62a388fc3b | |||
| 758d4edbb9 | |||
| 1836d2ef63 | |||
| d73d0523f4 | |||
| bba94faf00 | |||
| 82dc24040c | |||
| 66209c4ee8 | |||
| 665b19d933 | |||
| f6d9d00947 | |||
| 76bd503581 | |||
| e7b7eb7818 | |||
| e51d2f643e | |||
| cd0acc40bb | |||
| 7a5480f12f | |||
| 7c087b125e | |||
| 933dde935d | |||
| 6efcf1beee | |||
| caaff1c3d6 | |||
| 2686e059a0 | |||
| dae1bce48c | |||
| aa8e077649 | |||
| 878f2d2e76 | |||
| d538497fe2 | |||
| 11cff77162 | |||
| f284a27941 | |||
| 022d8fca94 | |||
| ee125e6235 | |||
| 3cc4030221 | |||
| c599b689ab | |||
| c8b121a3bb | |||
| 0198b80b5d | |||
| 6c1df04721 | |||
| e85befb41f | |||
| d0c7409de8 | |||
| d559afe2ce | |||
| c02a33a74c | |||
| 5b7a5d751c |
@@ -27,6 +27,20 @@
|
||||
"eslint-plugin-lodash"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
"importNames": [
|
||||
"useMenuState"
|
||||
],
|
||||
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"eqeqeq": 2,
|
||||
"curly": 2,
|
||||
"no-console": "error",
|
||||
@@ -58,6 +72,7 @@
|
||||
"ignoreTypeValueShadow": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
@@ -71,6 +86,7 @@
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
@@ -171,4 +187,4 @@
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ export const startTyping = createAction({
|
||||
}, 250);
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
intervalId && clearInterval(intervalId);
|
||||
if (event.key === "Escape" && intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -750,7 +750,7 @@ export const importDocument = createAction({
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
@@ -1081,12 +1081,17 @@ export const openDocumentComments = createAction({
|
||||
analyticsName: "Open comments",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CommentIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
const collection = activeCollectionId
|
||||
? stores.collections.get(activeCollectionId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.comment &&
|
||||
!!stores.auth.team?.getPreference(TeamPreference.Commenting)
|
||||
(collection?.canCreateComment ??
|
||||
!!stores.auth.team?.getPreference(TeamPreference.Commenting))
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
@@ -1210,7 +1215,7 @@ export const leaveDocument = createAction({
|
||||
} as UserMembership);
|
||||
|
||||
toast.success(t("You have left the shared document"));
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error(t("Could not leave document"));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,13 +57,15 @@ export const createTeam = createAction({
|
||||
perform: ({ t, event, stores }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
const { user } = stores.auth;
|
||||
user &&
|
||||
if (user) {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create a workspace"),
|
||||
fullscreen: true,
|
||||
content: <TeamNew user={user} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const { ui, auth, collections } = useStores();
|
||||
const location = useLocation();
|
||||
const layoutRef = React.useRef<HTMLDivElement>(null);
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
@@ -108,7 +108,9 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded &&
|
||||
team.getPreference(TeamPreference.Commenting);
|
||||
(ui.activeCollectionId
|
||||
? collections.get(ui.activeCollectionId)?.canCreateComment
|
||||
: !!team.getPreference(TeamPreference.Commenting));
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import filter from "lodash/filter";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import orderBy from "lodash/orderBy";
|
||||
@@ -5,15 +6,16 @@ import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
/** The document to display live collaborators for */
|
||||
@@ -22,6 +24,21 @@ type Props = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
// Styled components to match the original Popover styling
|
||||
const StyledPopoverContent = styled(Popover.Content)`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 12px 24px;
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
z-index: ${depths.modal};
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Displays a list of live collaborators for a document, including their avatars
|
||||
* and presence status.
|
||||
@@ -32,6 +49,7 @@ function Collaborators(props: Props) {
|
||||
const user = useCurrentUser();
|
||||
const currentUserId = user?.id;
|
||||
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const { users, presence, ui } = useStores();
|
||||
const { document } = props;
|
||||
const { observingUserId } = ui;
|
||||
@@ -94,11 +112,6 @@ function Collaborators(props: Props) {
|
||||
}
|
||||
}, [missingUserIds, requestedUserIds, users]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
// Memoize onClick handler to avoid inline function creation
|
||||
const handleAvatarClick = useCallback(
|
||||
(
|
||||
@@ -150,28 +163,33 @@ function Collaborators(props: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(popoverProps) => (
|
||||
<NudeButton
|
||||
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
|
||||
height={AvatarSize.Large}
|
||||
{...popoverProps}
|
||||
>
|
||||
<Facepile
|
||||
size={AvatarSize.Large}
|
||||
limit={limit}
|
||||
overflow={Math.max(0, collaborators.length - limit)}
|
||||
users={collaborators}
|
||||
renderAvatar={renderAvatar}
|
||||
/>
|
||||
</NudeButton>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
{popover.visible && <DocumentViews document={document} />}
|
||||
</Popover>
|
||||
</>
|
||||
<Popover.Root open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<NudeButton
|
||||
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
|
||||
height={AvatarSize.Large}
|
||||
>
|
||||
<Facepile
|
||||
size={AvatarSize.Large}
|
||||
limit={limit}
|
||||
overflow={Math.max(0, collaborators.length - limit)}
|
||||
users={collaborators}
|
||||
renderAvatar={renderAvatar}
|
||||
/>
|
||||
</NudeButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<StyledPopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={0}
|
||||
aria-label={t("Viewers")}
|
||||
style={{ width: 300 }}
|
||||
>
|
||||
<DocumentViews document={document} />
|
||||
</StyledPopoverContent>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { CollectionPermission, TeamPreference } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
@@ -22,6 +22,7 @@ import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { createSwitchRegister } from "~/utils/forms";
|
||||
|
||||
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
|
||||
|
||||
@@ -31,6 +32,7 @@ export interface FormData {
|
||||
color: string | null;
|
||||
sharing: boolean;
|
||||
permission: CollectionPermission | undefined;
|
||||
commenting?: boolean | null;
|
||||
}
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
@@ -83,6 +85,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
icon: collection?.icon,
|
||||
sharing: collection?.sharing ?? true,
|
||||
permission: collection?.permission,
|
||||
commenting: collection?.commenting ?? true,
|
||||
color: iconColor,
|
||||
},
|
||||
});
|
||||
@@ -112,7 +115,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
}, [setFocus]);
|
||||
|
||||
const handleIconChange = useCallback(
|
||||
(icon: string, color: string | null) => {
|
||||
(icon: string, color: string) => {
|
||||
if (icon !== values.icon) {
|
||||
setFocus("name");
|
||||
}
|
||||
@@ -129,7 +132,6 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
<Trans>
|
||||
Collections are used to group documents and choose permissions
|
||||
</Trans>
|
||||
.
|
||||
</Text>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
@@ -186,7 +188,16 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
{...register("sharing")}
|
||||
{...createSwitchRegister(register, "sharing")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t("Allow commenting on documents within this collection.")}
|
||||
{...createSwitchRegister(register, "commenting")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
MenuStateReturn,
|
||||
@@ -13,6 +12,7 @@ import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
|
||||
import Flex from "~/components/Flex";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
@@ -52,7 +52,9 @@ const SubMenu = React.forwardRef(function _Template(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
const menu = useMenuState({
|
||||
parentId: parentMenuState.baseId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -171,7 +171,9 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
scrollElement && !props.isSubMenu && enableBodyScroll(scrollElement);
|
||||
if (scrollElement && !props.isSubMenu) {
|
||||
enableBodyScroll(scrollElement);
|
||||
}
|
||||
};
|
||||
}, [props.isSubMenu, props.visible]);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) {
|
||||
|
||||
const onClick = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLElement>) => {
|
||||
const elem = React.Children.only(children);
|
||||
const childElem = React.Children.only(children);
|
||||
|
||||
copy(text, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
@@ -24,8 +24,12 @@ function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) {
|
||||
|
||||
onCopy?.();
|
||||
|
||||
if (elem && elem.props && typeof elem.props.onClick === "function") {
|
||||
elem.props.onClick(ev);
|
||||
if (
|
||||
childElem &&
|
||||
childElem.props &&
|
||||
typeof childElem.props.onClick === "function"
|
||||
) {
|
||||
childElem.props.onClick(ev);
|
||||
} else {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -46,20 +46,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.isTemplate]);
|
||||
|
||||
const handlePublishChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPublish(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRecursiveChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRecursive(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to copy"));
|
||||
@@ -79,7 +65,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
|
||||
toast.success(t("Document copied"));
|
||||
onSubmit(result);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t copy the document, try again?"));
|
||||
}
|
||||
};
|
||||
@@ -102,7 +88,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
@@ -113,7 +99,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={handleRecursiveChange}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -60,7 +60,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
if (defaultValue) {
|
||||
const node = items.find((item) => item.id === defaultValue);
|
||||
if (node) {
|
||||
return ancestors(node).map((node) => node.id);
|
||||
return ancestors(node).map((ancestorNode) => ancestorNode.id);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
@@ -99,10 +99,10 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
}, [searchTerm]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setItemRefs((itemRefs) =>
|
||||
setItemRefs((existingItemRefs) =>
|
||||
map(
|
||||
fill(Array(items.length), 0),
|
||||
(_, i) => itemRefs[i] || React.createRef()
|
||||
(_, i) => existingItemRefs[i] || React.createRef()
|
||||
)
|
||||
);
|
||||
}, [items.length]);
|
||||
@@ -180,7 +180,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
);
|
||||
|
||||
// remove children
|
||||
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
|
||||
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { FetchPageParams } from "~/stores/base/Store";
|
||||
@@ -9,6 +9,7 @@ import Button, { Inner } from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import Text from "~/components/Text";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import PaginatedList, { PaginatedItem } from "./PaginatedList";
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ const Guide: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(props) => (
|
||||
<Backdrop {...props}>
|
||||
{(backdropProps) => (
|
||||
<Backdrop {...backdropProps}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
@@ -45,8 +45,8 @@ const Guide: React.FC<Props> = ({
|
||||
hideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
<Scene {...props} {...rest}>
|
||||
{(dialogProps) => (
|
||||
<Scene {...dialogProps} {...rest}>
|
||||
<Content>
|
||||
{title && <Header>{title}</Header>}
|
||||
{children}
|
||||
|
||||
@@ -193,7 +193,7 @@ const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
|
||||
`;
|
||||
|
||||
const LargeMobileBuiltinColors = styled(BuiltinColors)`
|
||||
max-width: 380px;
|
||||
max-width: 400px;
|
||||
padding-right: 8px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
|
||||
import { Menu, MenuButton, MenuItem } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import { depths, s, hover } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
@@ -8,6 +8,7 @@ import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
|
||||
+196
-138
@@ -1,27 +1,20 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { SmileyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
PopoverDisclosure,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
usePopoverState,
|
||||
useTabState,
|
||||
} from "reakit";
|
||||
import styled, { css } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s, hover, depths } from "@shared/styles";
|
||||
import theme from "@shared/styles/theme";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
|
||||
import EmojiPanel from "./components/EmojiPanel";
|
||||
import IconPanel from "./components/IconPanel";
|
||||
import { PopoverButton } from "./components/PopoverButton";
|
||||
@@ -31,6 +24,8 @@ const TAB_NAMES = {
|
||||
Emoji: "emoji",
|
||||
} as const;
|
||||
|
||||
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
|
||||
|
||||
const POPOVER_WIDTH = 408;
|
||||
|
||||
type Props = {
|
||||
@@ -67,9 +62,9 @@ const IconPicker = ({
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [chosenColor, setChosenColor] = React.useState(color);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const iconType = determineIconType(icon);
|
||||
const defaultTab = React.useMemo(
|
||||
@@ -78,32 +73,40 @@ const IconPicker = ({
|
||||
[iconType]
|
||||
);
|
||||
|
||||
const popover = usePopoverState({
|
||||
placement: popoverPosition,
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
});
|
||||
const { hide, show, visible } = popover;
|
||||
const tab = useTabState({ selectedId: defaultTab });
|
||||
const previouslyVisible = usePrevious(popover.visible);
|
||||
const [activeTab, setActiveTab] = React.useState<TabName>(defaultTab);
|
||||
|
||||
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
|
||||
// In mobile, popover is absolutely positioned to leave 8px on both sides.
|
||||
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
|
||||
|
||||
const handleTabChange = React.useCallback((value: string) => {
|
||||
setActiveTab(value as TabName);
|
||||
}, []);
|
||||
|
||||
const resetDefaultTab = React.useCallback(() => {
|
||||
tab.select(defaultTab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
setActiveTab(defaultTab);
|
||||
}, [defaultTab]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
setQuery("");
|
||||
resetDefaultTab();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose, resetDefaultTab]
|
||||
);
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
(ic: string) => {
|
||||
hide();
|
||||
setOpen(false);
|
||||
const icType = determineIconType(ic);
|
||||
const finalColor = icType === IconType.SVG ? chosenColor : null;
|
||||
onChange(ic, finalColor);
|
||||
},
|
||||
[hide, onChange, chosenColor]
|
||||
[onChange, chosenColor]
|
||||
);
|
||||
|
||||
const handleIconColorChange = React.useCallback(
|
||||
@@ -111,7 +114,6 @@ const IconPicker = ({
|
||||
setChosenColor(c);
|
||||
|
||||
const icType = determineIconType(icon);
|
||||
// Outline icon set; propagate color change
|
||||
if (icType === IconType.SVG) {
|
||||
onChange(icon, c);
|
||||
}
|
||||
@@ -120,60 +122,40 @@ const IconPicker = ({
|
||||
);
|
||||
|
||||
const handleIconRemove = React.useCallback(() => {
|
||||
hide();
|
||||
setOpen(false);
|
||||
onChange(null, null);
|
||||
}, [hide, onChange]);
|
||||
}, [setOpen, onChange]);
|
||||
|
||||
const handlePopoverButtonClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (visible) {
|
||||
hide();
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
},
|
||||
[hide, show, visible]
|
||||
const PickerContent = (
|
||||
<Content
|
||||
open={open}
|
||||
activeTab={activeTab}
|
||||
iconColor={chosenColor}
|
||||
iconInitial={initial ?? ""}
|
||||
query={query}
|
||||
panelWidth={popoverWidth}
|
||||
allowDelete={!!(allowDelete && icon)}
|
||||
onTabChange={handleTabChange}
|
||||
onQueryChange={setQuery}
|
||||
onIconChange={handleIconChange}
|
||||
onIconColorChange={handleIconColorChange}
|
||||
onIconRemove={handleIconRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
// Popover open effect
|
||||
// Update selected tab when default tab changes
|
||||
React.useEffect(() => {
|
||||
if (visible && !previouslyVisible) {
|
||||
onOpen?.();
|
||||
} else if (!visible && previouslyVisible) {
|
||||
onClose?.();
|
||||
setQuery("");
|
||||
resetDefaultTab();
|
||||
}
|
||||
}, [visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
|
||||
setActiveTab(defaultTab);
|
||||
}, [defaultTab]);
|
||||
|
||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||
// prevent event bubbling.
|
||||
useOnClickOutside(
|
||||
contentRef,
|
||||
(event) => {
|
||||
if (
|
||||
popover.visible &&
|
||||
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
popover.hide();
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<PopoverButton
|
||||
{...props}
|
||||
aria-label={t("Show menu")}
|
||||
className={className}
|
||||
size={size}
|
||||
onClick={handlePopoverButtonClick}
|
||||
$borderOnHover={borderOnHover}
|
||||
>
|
||||
{children ? (
|
||||
@@ -184,71 +166,124 @@ const IconPicker = ({
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
)}
|
||||
</PopoverButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent aria-label={t("Icon Picker")}>
|
||||
{PickerContent}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange} modal={true}>
|
||||
<Popover.Trigger asChild>
|
||||
<PopoverButton
|
||||
aria-label={t("Show menu")}
|
||||
className={className}
|
||||
size={size}
|
||||
$borderOnHover={borderOnHover}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : iconType && icon ? (
|
||||
<Icon value={icon} color={color} size={size} initial={initial} />
|
||||
) : (
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
)}
|
||||
</PopoverButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<StyledPopoverContent
|
||||
side={popoverPosition === "right" ? "right" : "bottom"}
|
||||
align={popoverPosition === "bottom-start" ? "start" : "center"}
|
||||
sideOffset={0}
|
||||
width={popoverWidth}
|
||||
aria-label={t("Icon Picker")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{PickerContent}
|
||||
</StyledPopoverContent>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
open: boolean;
|
||||
activeTab: TabName;
|
||||
query: string;
|
||||
iconColor: string;
|
||||
iconInitial: string;
|
||||
panelWidth: number;
|
||||
allowDelete: boolean;
|
||||
onTabChange: (tab: string) => void;
|
||||
onQueryChange: (query: string) => void;
|
||||
onIconChange: (icon: string) => void;
|
||||
onIconColorChange: (color: string) => void;
|
||||
onIconRemove: () => void;
|
||||
};
|
||||
|
||||
const Content = ({
|
||||
open,
|
||||
activeTab,
|
||||
iconColor,
|
||||
iconInitial,
|
||||
query,
|
||||
panelWidth,
|
||||
allowDelete,
|
||||
onTabChange,
|
||||
onQueryChange,
|
||||
onIconChange,
|
||||
onIconColorChange,
|
||||
onIconRemove,
|
||||
}: ContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={onTabChange}>
|
||||
<TabActionsWrapper justify="space-between" align="center">
|
||||
<Tabs.List>
|
||||
<StyledTab
|
||||
value={TAB_NAMES["Icon"]}
|
||||
aria-label={t("Icons")}
|
||||
$active={activeTab === TAB_NAMES["Icon"]}
|
||||
>
|
||||
{t("Icons")}
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
value={TAB_NAMES["Emoji"]}
|
||||
aria-label={t("Emojis")}
|
||||
$active={activeTab === TAB_NAMES["Emoji"]}
|
||||
>
|
||||
{t("Emojis")}
|
||||
</StyledTab>
|
||||
</Tabs.List>
|
||||
{allowDelete && (
|
||||
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
{...popover}
|
||||
ref={contentRef}
|
||||
width={popoverWidth}
|
||||
shrink
|
||||
aria-label={t("Icon Picker")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
hideOnClickOutside={false}
|
||||
>
|
||||
<>
|
||||
<TabActionsWrapper justify="space-between" align="center">
|
||||
<TabList {...tab}>
|
||||
<StyledTab
|
||||
{...tab}
|
||||
id={TAB_NAMES["Icon"]}
|
||||
aria-label={t("Icons")}
|
||||
$active={tab.selectedId === TAB_NAMES["Icon"]}
|
||||
>
|
||||
{t("Icons")}
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
{...tab}
|
||||
id={TAB_NAMES["Emoji"]}
|
||||
aria-label={t("Emojis")}
|
||||
$active={tab.selectedId === TAB_NAMES["Emoji"]}
|
||||
>
|
||||
{t("Emojis")}
|
||||
</StyledTab>
|
||||
</TabList>
|
||||
{allowDelete && icon && (
|
||||
<RemoveButton onClick={handleIconRemove}>
|
||||
{t("Remove")}
|
||||
</RemoveButton>
|
||||
)}
|
||||
</TabActionsWrapper>
|
||||
<StyledTabPanel {...tab}>
|
||||
<IconPanel
|
||||
panelWidth={panelWidth}
|
||||
initial={initial ?? "?"}
|
||||
color={chosenColor}
|
||||
query={query}
|
||||
panelActive={
|
||||
popover.visible && tab.selectedId === TAB_NAMES["Icon"]
|
||||
}
|
||||
onIconChange={handleIconChange}
|
||||
onColorChange={handleIconColorChange}
|
||||
onQueryChange={setQuery}
|
||||
/>
|
||||
</StyledTabPanel>
|
||||
<StyledTabPanel {...tab}>
|
||||
<EmojiPanel
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
panelActive={
|
||||
popover.visible && tab.selectedId === TAB_NAMES["Emoji"]
|
||||
}
|
||||
onEmojiChange={handleIconChange}
|
||||
onQueryChange={setQuery}
|
||||
/>
|
||||
</StyledTabPanel>
|
||||
</>
|
||||
</Popover>
|
||||
</>
|
||||
</TabActionsWrapper>
|
||||
<StyledTabContent value={TAB_NAMES["Icon"]}>
|
||||
<IconPanel
|
||||
panelWidth={panelWidth}
|
||||
initial={iconInitial}
|
||||
color={iconColor}
|
||||
query={query}
|
||||
panelActive={open && activeTab === TAB_NAMES["Icon"]}
|
||||
onIconChange={onIconChange}
|
||||
onColorChange={onIconColorChange}
|
||||
onQueryChange={onQueryChange}
|
||||
/>
|
||||
</StyledTabContent>
|
||||
<StyledTabContent value={TAB_NAMES["Emoji"]}>
|
||||
<EmojiPanel
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
panelActive={open && activeTab === TAB_NAMES["Emoji"]}
|
||||
onEmojiChange={onIconChange}
|
||||
onQueryChange={onQueryChange}
|
||||
/>
|
||||
</StyledTabContent>
|
||||
</Tabs.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -277,7 +312,7 @@ const TabActionsWrapper = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
const StyledTab = styled(Tabs.Trigger)<{ $active: boolean }>`
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
@@ -308,9 +343,32 @@ const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledTabPanel = styled(TabPanel)`
|
||||
const StyledTabContent = styled(Tabs.Content)`
|
||||
height: 410px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const StyledPopoverContent = styled(Popover.Content)<{ width: number }>`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
z-index: ${depths.modal};
|
||||
width: ${(props) => props.width}px;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
position: fixed;
|
||||
z-index: ${depths.menu};
|
||||
top: 50px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(IconPicker);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
@@ -221,7 +221,7 @@ function Input(
|
||||
<label>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
<VisuallyHidden.Root>{wrappedLabel}</VisuallyHidden.Root>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import {
|
||||
Select,
|
||||
SelectOption,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
} from "@renderlesskit/react";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
|
||||
@@ -213,7 +213,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
||||
<Wrapper short={short}>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
<VisuallyHidden.Root>{wrappedLabel}</VisuallyHidden.Root>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
|
||||
+29
-27
@@ -38,39 +38,41 @@ const Layout = React.forwardRef(function Layout_(
|
||||
});
|
||||
|
||||
return (
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
<MenuProvider>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
|
||||
<SkipNavLink />
|
||||
<SkipNavLink />
|
||||
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
|
||||
<Container auto>
|
||||
<MenuProvider>{sidebar}</MenuProvider>
|
||||
<Container auto>
|
||||
<MenuProvider>{sidebar}</MenuProvider>
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{sidebarRight}
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
</Container>
|
||||
</Container>
|
||||
</MenuProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
|
||||
small?: boolean;
|
||||
/** Whether to enable keyboard navigation */
|
||||
keyboardNavigation?: boolean;
|
||||
ellipsis?: boolean;
|
||||
enableEllipsis?: boolean;
|
||||
};
|
||||
|
||||
const ListItem = (
|
||||
@@ -46,7 +46,7 @@ const ListItem = (
|
||||
border,
|
||||
to,
|
||||
keyboardNavigation,
|
||||
ellipsis,
|
||||
enableEllipsis,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
@@ -85,7 +85,7 @@ const ListItem = (
|
||||
column={!compact}
|
||||
$selected={selected}
|
||||
>
|
||||
<Heading $small={small} $ellipsis={ellipsis}>
|
||||
<Heading $small={small} $ellipsis={enableEllipsis}>
|
||||
{title}
|
||||
</Heading>
|
||||
{subtitle && (
|
||||
|
||||
+80
-75
@@ -1,9 +1,9 @@
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, BackIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
@@ -37,9 +37,6 @@ const Modal: React.FC<Props> = ({
|
||||
style,
|
||||
onRequestClose,
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
const [depth, setDepth] = React.useState(0);
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
const isMobile = useMobile();
|
||||
@@ -48,14 +45,12 @@ const Modal: React.FC<Props> = ({
|
||||
React.useEffect(() => {
|
||||
if (!wasOpen && isOpen) {
|
||||
setDepth(openModals++);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
if (wasOpen && !isOpen) {
|
||||
setDepth(openModals--);
|
||||
dialog.hide();
|
||||
}
|
||||
}, [dialog, wasOpen, isOpen]);
|
||||
}, [wasOpen, isOpen]);
|
||||
|
||||
useUnmount(() => {
|
||||
if (isOpen) {
|
||||
@@ -68,78 +63,75 @@ const Modal: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(props) => (
|
||||
<Backdrop $fullscreen={fullscreen} {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hideOnClickOutside={!fullscreen}
|
||||
hide={onRequestClose}
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onRequestClose()}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay $fullscreen={fullscreen}>
|
||||
<StyledContent
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={fullscreen ? undefined : onRequestClose}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{(props) =>
|
||||
fullscreen || isMobile ? (
|
||||
<Fullscreen
|
||||
$nested={!!depth}
|
||||
style={
|
||||
isMobile
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${depth * 12}px`,
|
||||
}
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && (
|
||||
<Text size="xlarge" weight="bold">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</Content>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} />
|
||||
<Text>{t("Back")} </Text>
|
||||
</Back>
|
||||
</Fullscreen>
|
||||
) : (
|
||||
<Small {...props}>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent style={style} shadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Header>
|
||||
{fullscreen || isMobile ? (
|
||||
<Fullscreen
|
||||
$nested={!!depth}
|
||||
style={
|
||||
isMobile
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${depth * 12}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && (
|
||||
<Text size="xlarge" weight="bold">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</Small>
|
||||
)
|
||||
}
|
||||
</Dialog>
|
||||
</Backdrop>
|
||||
)}
|
||||
</DialogBackdrop>
|
||||
</Content>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} />
|
||||
<Text>{t("Back")} </Text>
|
||||
</Back>
|
||||
</Fullscreen>
|
||||
) : (
|
||||
<Small>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent style={style} shadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Small>
|
||||
)}
|
||||
</StyledContent>
|
||||
</StyledOverlay>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
|
||||
const StyledOverlay = styled(Dialog.Overlay)<{ $fullscreen?: boolean }>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -153,11 +145,24 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-enter] {
|
||||
&[data-state="open"] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
type FullscreenProps = {
|
||||
$nested: boolean;
|
||||
theme: DefaultTheme;
|
||||
|
||||
@@ -23,15 +23,13 @@ import NotificationListItem from "./NotificationListItem";
|
||||
type Props = {
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
/** Whether the panel is open or not. */
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel containing a list of notifications and controls to manage them.
|
||||
*/
|
||||
function Notifications(
|
||||
{ onRequestClose, isOpen }: Props,
|
||||
{ onRequestClose }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const context = useActionContext();
|
||||
@@ -82,7 +80,7 @@ function Notifications(
|
||||
<PaginatedList<Notification>
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={isOpen ? notifications.orderedData : undefined}
|
||||
items={notifications.orderedData}
|
||||
renderItem={(item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import Popover from "~/components/Popover";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { fadeAndSlideUp } from "~/styles/animations";
|
||||
import Notifications from "./Notifications";
|
||||
|
||||
type Props = {
|
||||
@@ -14,44 +15,71 @@ type Props = {
|
||||
const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const scrollableRef = React.useRef<HTMLDivElement>(null);
|
||||
const closeRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "top-start",
|
||||
unstable_fixed: true,
|
||||
});
|
||||
|
||||
// Reset scroll position to the top when popover is opened
|
||||
React.useEffect(() => {
|
||||
if (popover.visible && scrollableRef.current) {
|
||||
scrollableRef.current.scrollTop = 0;
|
||||
const handleRequestClose = React.useCallback(() => {
|
||||
if (closeRef.current) {
|
||||
closeRef.current.click();
|
||||
}
|
||||
}, [popover.visible]);
|
||||
}, []);
|
||||
|
||||
const handleAutoFocus = React.useCallback((event: Event) => {
|
||||
// Prevent focus from moving to the popover content
|
||||
event.preventDefault();
|
||||
|
||||
// Reset scroll position to the top when popover is opened
|
||||
if (scrollableRef.current) {
|
||||
scrollableRef.current.scrollTop = 0;
|
||||
scrollableRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>{children}</PopoverDisclosure>
|
||||
<StyledPopover
|
||||
{...popover}
|
||||
scrollable={false}
|
||||
mobilePosition="bottom"
|
||||
aria-label={t("Notifications")}
|
||||
unstable_initialFocusRef={scrollableRef}
|
||||
shrink
|
||||
flex
|
||||
>
|
||||
<Notifications
|
||||
onRequestClose={popover.hide}
|
||||
isOpen={popover.visible}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</StyledPopover>
|
||||
</>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>{children}</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<StyledContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
avoidCollisions={true}
|
||||
aria-label={t("Notifications")}
|
||||
onOpenAutoFocus={handleAutoFocus}
|
||||
>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
<VisuallyHidden>
|
||||
<Popover.Close ref={closeRef} />
|
||||
</VisuallyHidden>
|
||||
</StyledContent>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
const StyledContent = styled(Popover.Content)`
|
||||
z-index: ${depths.menu};
|
||||
display: flex;
|
||||
animation: ${fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
width: 380px;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
position: fixed;
|
||||
z-index: ${depths.menu};
|
||||
top: 50px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(NotificationsPopover);
|
||||
|
||||
@@ -8,6 +8,7 @@ import ImageInput from "~/scenes/Settings/components/ImageInput";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import { createSwitchRegister } from "~/utils/forms";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Switch from "../Switch";
|
||||
|
||||
@@ -116,7 +117,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
/>
|
||||
{isCloudHosted && (
|
||||
<Switch
|
||||
{...register("published")}
|
||||
{...createSwitchRegister(register, "published")}
|
||||
label={t("Published")}
|
||||
note={t("Allow this app to be installed by other workspaces")}
|
||||
/>
|
||||
|
||||
@@ -107,9 +107,11 @@ const Reaction: React.FC<Props> = ({
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
active
|
||||
? void onRemoveReaction(reaction.emoji)
|
||||
: void onAddReaction(reaction.emoji);
|
||||
if (active) {
|
||||
void onRemoveReaction(reaction.emoji);
|
||||
} else {
|
||||
void onAddReaction(reaction.emoji);
|
||||
}
|
||||
},
|
||||
[reaction, active, onAddReaction, onRemoveReaction]
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ const ReactionList: React.FC<Props> = ({
|
||||
const loadReactedUsersData = async () => {
|
||||
try {
|
||||
await model.loadReactedUsersData();
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
Logger.warn("Could not prefetch reaction data");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<Tooltip content={t("Add reaction")} placement="top" hideOnClick>
|
||||
<Tooltip content={t("Add reaction")} placement="top">
|
||||
<NudeButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
|
||||
@@ -29,7 +29,7 @@ const ViewReactionsDialog: React.FC<Props> = ({ model }) => {
|
||||
const loadReactedUsersData = async () => {
|
||||
try {
|
||||
await model.loadReactedUsersData();
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error(t("Could not load reactions"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,10 +53,10 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
}, [searchResults, query, show]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query, ...options }) => {
|
||||
if (query?.length > 0) {
|
||||
async ({ query: searchQuery, ...options }) => {
|
||||
if (searchQuery?.length > 0) {
|
||||
const response = await documents.search({
|
||||
query,
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
@@ -230,7 +231,9 @@ const AccessTooltip = ({
|
||||
{children}
|
||||
</Text>
|
||||
<Tooltip content={content ?? t("Access inherited from collection")}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error(t("Could not remove user"));
|
||||
}
|
||||
},
|
||||
@@ -62,19 +62,19 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
);
|
||||
|
||||
const handleUpdateUser = React.useCallback(
|
||||
async (user, permission) => {
|
||||
async (userToUpdate, permission) => {
|
||||
try {
|
||||
await userMemberships.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
userId: userToUpdate.id,
|
||||
permission,
|
||||
});
|
||||
toast.success(
|
||||
t(`Permissions for {{ userName }} updated`, {
|
||||
userName: user.name,
|
||||
userName: userToUpdate.name,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error(t("Could not update user"));
|
||||
}
|
||||
},
|
||||
@@ -87,9 +87,9 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
() =>
|
||||
orderBy(
|
||||
document.members,
|
||||
(user) =>
|
||||
(invitedInSession.includes(user.id) ? "_" : "") +
|
||||
user.name.toLocaleLowerCase(),
|
||||
(memberUser) =>
|
||||
(invitedInSession.includes(memberUser.id) ? "_" : "") +
|
||||
memberUser.name.toLocaleLowerCase(),
|
||||
"asc"
|
||||
),
|
||||
[document.members, invitedInSession]
|
||||
|
||||
@@ -52,10 +52,23 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (event) => {
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: event.currentTarget.checked,
|
||||
allowIndexing: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showLastUpdated: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
@@ -65,10 +78,10 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (event) => {
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
published: event.currentTarget.checked,
|
||||
published: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
@@ -177,7 +190,9 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<QuestionMarkIcon size={18} />
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
@@ -193,6 +208,34 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{share?.published && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show last modified")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the last modified timestamp on the shared page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show last modified")}
|
||||
checked={share?.showLastUpdated ?? false}
|
||||
onChange={handleShowLastModifiedChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharedParent?.published ? (
|
||||
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
|
||||
{copyButton}
|
||||
|
||||
@@ -67,9 +67,9 @@ export const Suggestions = observer(
|
||||
});
|
||||
|
||||
const fetchUsersByQuery = useThrottledCallback(
|
||||
(query: string) => {
|
||||
void users.fetchPage({ query });
|
||||
void groups.fetchPage({ query });
|
||||
(searchQuery: string) => {
|
||||
void users.fetchPage({ query: searchQuery });
|
||||
void groups.fetchPage({ query: searchQuery });
|
||||
},
|
||||
250,
|
||||
undefined,
|
||||
|
||||
@@ -61,13 +61,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
const newWidth = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = newWidth < minWidth / 2;
|
||||
|
||||
ui.set({
|
||||
sidebarWidth: isSmallerThanCollapsePoint
|
||||
? theme.sidebarCollapsedWidth
|
||||
: width,
|
||||
: newWidth,
|
||||
});
|
||||
},
|
||||
[ui, theme, offset, minWidth, maxWidth]
|
||||
@@ -246,7 +246,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Notifications />
|
||||
<NotificationsPopover>
|
||||
<SidebarButton
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
/>
|
||||
</NotificationsPopover>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
@@ -261,14 +266,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
);
|
||||
});
|
||||
|
||||
const Notifications = () => (
|
||||
<NotificationsPopover>
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton {...rest} position="bottom" image={<NotificationIcon />} />
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
);
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
|
||||
@@ -120,7 +120,11 @@ function InnerDocumentLink(
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
expanded ? setCollapsed() : setExpanded();
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
setExpanded();
|
||||
}
|
||||
},
|
||||
[setCollapsed, setExpanded, expanded]
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ const NavLink = ({
|
||||
);
|
||||
const { pathname: path } = toLocation;
|
||||
|
||||
const match = path
|
||||
const pathMatch = path
|
||||
? matchPath(currentLocation.pathname, {
|
||||
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
|
||||
path: path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"),
|
||||
@@ -86,7 +86,7 @@ const NavLink = ({
|
||||
|
||||
const isActive =
|
||||
preActive ??
|
||||
!!(isActiveProp ? isActiveProp(match, currentLocation) : match);
|
||||
!!(isActiveProp ? isActiveProp(pathMatch, currentLocation) : pathMatch);
|
||||
const className = isActive
|
||||
? joinClassnames(classNameProp, activeClassName)
|
||||
: classNameProp;
|
||||
|
||||
@@ -86,9 +86,12 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
);
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const node = React.useMemo(() => document?.asNavigationNode, [document]);
|
||||
const reparentableNode = React.useMemo(
|
||||
() => document?.asNavigationNode,
|
||||
[document]
|
||||
);
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, setExpanded, parentRef);
|
||||
useDropToReparentDocument(reparentableNode, setExpanded, parentRef);
|
||||
|
||||
const { icon } = useSidebarLabelAndIcon(membership);
|
||||
const [{ isDragging }, draggableRef] = useDragMembership(membership);
|
||||
@@ -172,10 +175,10 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
</Draggable>
|
||||
</Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
{childDocuments.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
|
||||
@@ -128,11 +128,11 @@ function StarredLink({ star }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const collection = document.collectionId
|
||||
const documentCollection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const childDocuments = collection
|
||||
? collection.getChildrenForDocument(documentId)
|
||||
const childDocuments = documentCollection
|
||||
? documentCollection.getChildrenForDocument(documentId)
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
|
||||
@@ -176,7 +176,7 @@ function StarredLink({ star }: Props) {
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
collection={documentCollection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
|
||||
+59
-54
@@ -1,3 +1,4 @@
|
||||
import * as RadixSwitch from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
@@ -5,7 +6,11 @@ import { LabelText } from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLInputElement> {
|
||||
interface Props
|
||||
extends Omit<
|
||||
React.ComponentProps<typeof RadixSwitch.Root>,
|
||||
"checked" | "onCheckedChange" | "onChange"
|
||||
> {
|
||||
/** Width of the switch. Defaults to 32. */
|
||||
width?: number;
|
||||
/** Height of the switch. Defaults to 18 */
|
||||
@@ -22,6 +27,8 @@ interface Props extends React.HTMLAttributes<HTMLInputElement> {
|
||||
checked?: boolean;
|
||||
/** Whether the switch is disabled */
|
||||
disabled?: boolean;
|
||||
/** Callback when the switch state changes */
|
||||
onChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
function Switch(
|
||||
@@ -33,26 +40,34 @@ function Switch(
|
||||
disabled,
|
||||
className,
|
||||
note,
|
||||
checked,
|
||||
onChange,
|
||||
...props
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLInputElement>
|
||||
ref: React.Ref<React.ElementRef<typeof RadixSwitch.Root>>
|
||||
) {
|
||||
const handleCheckedChange = React.useCallback(
|
||||
(checkedState: boolean) => {
|
||||
if (onChange) {
|
||||
onChange(checkedState);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const component = (
|
||||
<Input
|
||||
<StyledSwitchRoot
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
disabled={disabled}
|
||||
width={width}
|
||||
height={height}
|
||||
className={label ? undefined : className}
|
||||
{...props}
|
||||
>
|
||||
<HiddenInput
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
width={width}
|
||||
height={height}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
<Slider width={width} height={height} />
|
||||
</Input>
|
||||
<StyledSwitchThumb width={width} height={height} />
|
||||
</StyledSwitchRoot>
|
||||
);
|
||||
|
||||
if (label) {
|
||||
@@ -110,60 +125,50 @@ const Label = styled.label<{
|
||||
${(props) => (props.disabled ? `opacity: 0.75;` : "")}
|
||||
`;
|
||||
|
||||
const Input = styled.label<{ width: number; height: number }>`
|
||||
const StyledSwitchRoot = styled(RadixSwitch.Root)<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: ${(props) => props.width}px;
|
||||
height: ${(props) => props.height}px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Slider = styled.span<{ width: number; height: number }>`
|
||||
position: absolute;
|
||||
cursor: var(--pointer);
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) => props.theme.slate};
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
border-radius: ${(props) => props.height}px;
|
||||
border: none;
|
||||
cursor: var(--pointer);
|
||||
transition: background-color 0.4s;
|
||||
padding: 0 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: ${(props) => props.height - 8}px;
|
||||
width: ${(props) => props.height - 8}px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
}
|
||||
`;
|
||||
|
||||
const HiddenInput = styled.input<{ width: number; height: number }>`
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
|
||||
&:disabled + ${Slider} {
|
||||
opacity: 0.75;
|
||||
cursor: default;
|
||||
&:focus {
|
||||
box-shadow: 0 0 1px ${s("accent")};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:checked + ${Slider} {
|
||||
&[data-state="checked"] {
|
||||
background-color: ${s("accent")};
|
||||
}
|
||||
|
||||
&:focus + ${Slider} {
|
||||
box-shadow: 0 0 1px ${s("accent")};
|
||||
&:disabled {
|
||||
opacity: 0.75;
|
||||
cursor: default;
|
||||
}
|
||||
`;
|
||||
|
||||
&:checked + ${Slider}:before {
|
||||
const StyledSwitchThumb = styled(RadixSwitch.Thumb)<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>`
|
||||
display: block;
|
||||
width: ${(props) => props.height - 8}px;
|
||||
height: ${(props) => props.height - 8}px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.4s;
|
||||
transform: translateX(0);
|
||||
will-change: transform;
|
||||
|
||||
&[data-state="checked"] {
|
||||
transform: translateX(${(props) => props.width - props.height}px);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -27,13 +27,6 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
document.collectionId ?? null
|
||||
);
|
||||
|
||||
const handlePublishChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPublish(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize({
|
||||
collectionId,
|
||||
@@ -72,7 +65,7 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
label={t("Published")}
|
||||
note={t("Enable other members to use the template immediately")}
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Flex>
|
||||
</ConfirmationDialog>
|
||||
|
||||
+214
-150
@@ -1,42 +1,138 @@
|
||||
import Tippy, { TippyProps } from "@tippyjs/react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import { roundArrow } from "tippy.js";
|
||||
import styled, { createGlobalStyle, keyframes } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useTooltipContext } from "./TooltipContext";
|
||||
|
||||
export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
export type Props = {
|
||||
/** The content to display in the tooltip. */
|
||||
content?: React.ReactChild | React.ReactChild[];
|
||||
/** A keyboard shortcut to display next to the content */
|
||||
shortcut?: React.ReactNode;
|
||||
/** Whether to show the shortcut on a new line */
|
||||
shortcutOnNewline?: boolean;
|
||||
/** The preferred side of the trigger to render against when open */
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
/** The distance in pixels from the trigger */
|
||||
sideOffset?: number;
|
||||
/** The preferred alignment against the trigger */
|
||||
align?: "start" | "center" | "end";
|
||||
/** An offset in pixels from the "start" or "end" alignment options */
|
||||
alignOffset?: number;
|
||||
/** When true, overrides the side and align preferences to prevent collisions with boundary edges */
|
||||
avoidCollisions?: boolean;
|
||||
/** The element used as the collision boundary */
|
||||
collisionBoundary?: Element | null | Array<Element | null>;
|
||||
/** The distance in pixels from the boundary edges where collision detection should occur */
|
||||
collisionPadding?:
|
||||
| number
|
||||
| Partial<Record<"top" | "right" | "bottom" | "left", number>>;
|
||||
/** Whether the tooltip should be open by default */
|
||||
defaultOpen?: boolean;
|
||||
/** The controlled open state of the tooltip */
|
||||
open?: boolean;
|
||||
/** Event handler called when the open state of the tooltip changes */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** The duration from when the mouse enters the trigger until the tooltip gets opened */
|
||||
delayDuration?: number;
|
||||
/** How much time a user has to enter another trigger without incurring a delay again */
|
||||
skipDelayDuration?: number;
|
||||
/** Prevents the tooltip from opening */
|
||||
disableHoverableContent?: boolean;
|
||||
/** The children that will trigger the tooltip */
|
||||
children?: React.ReactNode;
|
||||
/** Whether to disable the tooltip entirely */
|
||||
disabled?: boolean;
|
||||
/** Custom offset for the tooltip */
|
||||
offset?: [number, number];
|
||||
/** Placement prop for backward compatibility with Tippy */
|
||||
placement?:
|
||||
| "top"
|
||||
| "right"
|
||||
| "bottom"
|
||||
| "left"
|
||||
| "top-start"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right-end"
|
||||
| "bottom-start"
|
||||
| "bottom-end"
|
||||
| "left-start"
|
||||
| "left-end";
|
||||
/** Delay prop for backward compatibility with Tippy */
|
||||
delay?: number | [number, number];
|
||||
};
|
||||
|
||||
/**
|
||||
* A tooltip component that wraps Tippy and provides a consistent look and feel. Optionally
|
||||
* displays a keyboard shortcut next to the content.
|
||||
* Tooltip component using Radix UI primitives.
|
||||
* Displays a tooltip with optional keyboard shortcut.
|
||||
* Optionally displays a keyboard shortcut next to the content.
|
||||
*
|
||||
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
|
||||
* singleton instance (delay, animation, etc).
|
||||
* provider instance (delay, animation, etc).
|
||||
*/
|
||||
function Tooltip({
|
||||
shortcut,
|
||||
shortcutOnNewline,
|
||||
content: tooltip,
|
||||
delay = 500,
|
||||
side = "top",
|
||||
sideOffset = 8,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
avoidCollisions = true,
|
||||
collisionBoundary,
|
||||
collisionPadding = 8,
|
||||
defaultOpen,
|
||||
open,
|
||||
onOpenChange,
|
||||
delayDuration = 500,
|
||||
skipDelayDuration = 300,
|
||||
disableHoverableContent = false,
|
||||
children,
|
||||
disabled = false,
|
||||
offset,
|
||||
placement,
|
||||
delay,
|
||||
...rest
|
||||
}: Props) {
|
||||
}: Props): React.ReactElement | null {
|
||||
const isMobile = useMobile();
|
||||
const singleton = useTooltipContext();
|
||||
const isInProvider = useTooltipContext();
|
||||
|
||||
// Handle backward compatibility with Tippy props
|
||||
let finalSide = side;
|
||||
let finalAlign = align;
|
||||
let finalDelayDuration = delayDuration;
|
||||
let finalSideOffset = sideOffset;
|
||||
|
||||
// Convert placement prop to side/align for backward compatibility
|
||||
if (placement) {
|
||||
const [placementSide, placementAlign] = placement.split("-");
|
||||
finalSide = placementSide as "top" | "right" | "bottom" | "left";
|
||||
if (placementAlign) {
|
||||
finalAlign = placementAlign as "start" | "center" | "end";
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delay prop for backward compatibility
|
||||
if (delay !== undefined) {
|
||||
if (typeof delay === "number") {
|
||||
finalDelayDuration = delay;
|
||||
} else if (Array.isArray(delay)) {
|
||||
finalDelayDuration = delay[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle offset prop for backward compatibility
|
||||
if (offset) {
|
||||
finalSideOffset = offset[1] || sideOffset;
|
||||
}
|
||||
|
||||
let content = <>{tooltip}</>;
|
||||
|
||||
if (!tooltip || isMobile) {
|
||||
return rest.children ?? null;
|
||||
if (!tooltip || isMobile || disabled) {
|
||||
return (children as React.ReactElement) ?? null;
|
||||
}
|
||||
|
||||
if (shortcut) {
|
||||
@@ -59,20 +155,92 @@ function Tooltip({
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipContent = (
|
||||
<TooltipPrimitive.Root
|
||||
defaultOpen={defaultOpen}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
delayDuration={isInProvider ? undefined : finalDelayDuration}
|
||||
disableHoverableContent={disableHoverableContent}
|
||||
>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<StyledContent
|
||||
side={finalSide}
|
||||
sideOffset={finalSideOffset}
|
||||
align={finalAlign}
|
||||
alignOffset={alignOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</StyledContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
);
|
||||
|
||||
// If we're already in a provider, don't wrap with another one
|
||||
if (isInProvider) {
|
||||
return tooltipContent;
|
||||
}
|
||||
|
||||
// Otherwise, wrap with a provider for standalone usage
|
||||
return (
|
||||
<Tippy
|
||||
arrow={roundArrow}
|
||||
content={content}
|
||||
delay={delay}
|
||||
animation="shift-away"
|
||||
singleton={singleton}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...rest}
|
||||
/>
|
||||
<TooltipPrimitive.Provider
|
||||
delayDuration={finalDelayDuration}
|
||||
skipDelayDuration={skipDelayDuration}
|
||||
>
|
||||
{tooltipContent}
|
||||
</TooltipPrimitive.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const slideUpAndFade = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
const slideRightAndFade = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
`;
|
||||
|
||||
const slideDownAndFade = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
const slideLeftAndFade = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
`;
|
||||
|
||||
const Shortcut = styled.kbd`
|
||||
position: relative;
|
||||
top: -1px;
|
||||
@@ -89,140 +257,36 @@ const Shortcut = styled.kbd`
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
export const TooltipStyles = createGlobalStyle`
|
||||
.tippy-box[data-animation=fade][data-state=hidden]{
|
||||
opacity:0
|
||||
}
|
||||
[data-tippy-root]{
|
||||
max-width:calc(100vw - 10px)
|
||||
}
|
||||
.tippy-box{
|
||||
position:relative;
|
||||
background-color: ${s("tooltipBackground")};
|
||||
color: ${s("tooltipText")};
|
||||
border-radius:4px;
|
||||
font-size:13px;
|
||||
line-height:1.4;
|
||||
white-space:normal;
|
||||
outline:0;
|
||||
transition-property:transform,visibility,opacity
|
||||
}
|
||||
.tippy-box[data-placement^=top]>.tippy-arrow{
|
||||
bottom:0
|
||||
}
|
||||
.tippy-box[data-placement^=top]>.tippy-arrow:before{
|
||||
bottom:-7px;
|
||||
left:0;
|
||||
border-width:8px 8px 0;
|
||||
border-top-color:initial;
|
||||
transform-origin:center top
|
||||
}
|
||||
.tippy-box[data-placement^=bottom]>.tippy-arrow{
|
||||
top:0
|
||||
}
|
||||
.tippy-box[data-placement^=bottom]>.tippy-arrow:before{
|
||||
top:-7px;
|
||||
left:0;
|
||||
border-width:0 8px 8px;
|
||||
border-bottom-color:initial;
|
||||
transform-origin:center bottom
|
||||
}
|
||||
.tippy-box[data-placement^=left]>.tippy-arrow{
|
||||
right:0
|
||||
}
|
||||
.tippy-box[data-placement^=left]>.tippy-arrow:before{
|
||||
border-width:8px 0 8px 8px;
|
||||
border-left-color:initial;
|
||||
right:-7px;
|
||||
transform-origin:center left
|
||||
}
|
||||
.tippy-box[data-placement^=right]>.tippy-arrow{
|
||||
left:0
|
||||
}
|
||||
.tippy-box[data-placement^=right]>.tippy-arrow:before{
|
||||
left:-7px;
|
||||
border-width:8px 8px 8px 0;
|
||||
border-right-color:initial;
|
||||
transform-origin:center right
|
||||
}
|
||||
.tippy-box[data-inertia][data-state=visible]{
|
||||
transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)
|
||||
}
|
||||
.tippy-arrow{
|
||||
width:16px;
|
||||
height:16px;
|
||||
color: ${s("tooltipBackground")};
|
||||
}
|
||||
.tippy-arrow:before{
|
||||
content:"";
|
||||
position:absolute;
|
||||
border-color:transparent;
|
||||
border-style:solid
|
||||
}
|
||||
.tippy-content{
|
||||
position:relative;
|
||||
padding:5px 9px;
|
||||
z-index:1
|
||||
}
|
||||
|
||||
/* Arrow Styles */
|
||||
.tippy-box[data-placement^=top]>.tippy-svg-arrow{
|
||||
bottom:0
|
||||
}
|
||||
.tippy-box[data-placement^=top]>.tippy-svg-arrow:after,.tippy-box[data-placement^=top]>.tippy-svg-arrow>svg{
|
||||
top:16px;
|
||||
transform:rotate(180deg)
|
||||
}
|
||||
.tippy-box[data-placement^=bottom]>.tippy-svg-arrow{
|
||||
top:0
|
||||
}
|
||||
.tippy-box[data-placement^=bottom]>.tippy-svg-arrow>svg{
|
||||
bottom:16px
|
||||
}
|
||||
.tippy-box[data-placement^=left]>.tippy-svg-arrow{
|
||||
right:0
|
||||
}
|
||||
.tippy-box[data-placement^=left]>.tippy-svg-arrow:after,.tippy-box[data-placement^=left]>.tippy-svg-arrow>svg{
|
||||
transform:rotate(90deg);
|
||||
top:calc(50% - 3px);
|
||||
left:11px
|
||||
}
|
||||
.tippy-box[data-placement^=right]>.tippy-svg-arrow{
|
||||
left:0
|
||||
}
|
||||
.tippy-box[data-placement^=right]>.tippy-svg-arrow:after,.tippy-box[data-placement^=right]>.tippy-svg-arrow>svg{
|
||||
transform:rotate(-90deg);
|
||||
top:calc(50% - 3px);
|
||||
right:11px
|
||||
}
|
||||
.tippy-svg-arrow{
|
||||
width:16px;
|
||||
height:16px;
|
||||
fill: ${s("tooltipBackground")};
|
||||
text-align:initial
|
||||
}
|
||||
.tippy-svg-arrow,.tippy-svg-arrow>svg{
|
||||
position:absolute
|
||||
}
|
||||
const StyledContent = styled(TooltipPrimitive.Content)`
|
||||
position: relative;
|
||||
background-color: ${s("tooltipBackground")};
|
||||
color: ${s("tooltipText")};
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
outline: 0;
|
||||
padding: 5px 9px;
|
||||
z-index: 9999;
|
||||
max-width: calc(100vw - 10px);
|
||||
|
||||
/* Animation */
|
||||
.tippy-box[data-animation=shift-away][data-state=hidden]{opacity:0}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=top]{transform:translateY(10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=bottom]{transform:translateY(-10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=left]{transform:translateX(10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=right]{transform:translateX(-10px)}
|
||||
|
||||
.tippy-box[data-animation=shift-away][data-state=hidden]{
|
||||
opacity:0
|
||||
&[data-state="delayed-open"][data-side="top"] {
|
||||
animation: ${slideUpAndFade} 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=top]{
|
||||
transform:translateY(10px)
|
||||
&[data-state="delayed-open"][data-side="right"] {
|
||||
animation: ${slideLeftAndFade} 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=bottom]{
|
||||
transform:translateY(-10px)
|
||||
&[data-state="delayed-open"][data-side="bottom"] {
|
||||
animation: ${slideDownAndFade} 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=left]{
|
||||
transform:translateX(10px)
|
||||
}
|
||||
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=right]{
|
||||
transform:translateX(-10px)
|
||||
&[data-state="delayed-open"][data-side="left"] {
|
||||
animation: ${slideRightAndFade} 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
export const TooltipStyles = createGlobalStyle`
|
||||
/* Legacy styles for backward compatibility - can be removed after migration */
|
||||
`;
|
||||
|
||||
export default Tooltip;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import Tippy, { useSingleton, TippyProps } from "@tippyjs/react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
import { roundArrow } from "tippy.js";
|
||||
|
||||
export const TooltipContext =
|
||||
React.createContext<TippyProps["singleton"]>(undefined);
|
||||
export const TooltipContext = React.createContext<boolean>(false);
|
||||
|
||||
export function useTooltipContext() {
|
||||
return React.useContext(TooltipContext);
|
||||
@@ -11,30 +9,39 @@ export function useTooltipContext() {
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
/** Props to pass to the Tippy component */
|
||||
tippyProps?: TippyProps;
|
||||
/** The duration from when the mouse enters the trigger until the tooltip gets opened */
|
||||
delayDuration?: number;
|
||||
/** How much time a user has to enter another trigger without incurring a delay again */
|
||||
skipDelayDuration?: number;
|
||||
/** Prevents the tooltip from opening */
|
||||
disableHoverableContent?: boolean;
|
||||
/** Props to pass to the Tippy component - kept for backward compatibility */
|
||||
tippyProps?: {
|
||||
delay?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a collection of tooltips in a provider to allow them to share the same singleton instance.
|
||||
* Wrap a collection of tooltips in a provider to allow them to share the same provider instance.
|
||||
*/
|
||||
export function TooltipProvider({ children, tippyProps }: Props) {
|
||||
const [source, target] = useSingleton();
|
||||
export function TooltipProvider({
|
||||
children,
|
||||
delayDuration = 500,
|
||||
skipDelayDuration = 300,
|
||||
disableHoverableContent = false,
|
||||
tippyProps,
|
||||
}: Props) {
|
||||
// Handle backward compatibility with tippyProps
|
||||
const finalDelayDuration = tippyProps?.delay ?? delayDuration;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tippy
|
||||
delay={500}
|
||||
arrow={roundArrow}
|
||||
animation="shift-away"
|
||||
singleton={source}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...tippyProps}
|
||||
/>
|
||||
<TooltipContext.Provider value={target}>
|
||||
{children}
|
||||
</TooltipContext.Provider>
|
||||
</>
|
||||
<TooltipPrimitive.Provider
|
||||
delayDuration={finalDelayDuration}
|
||||
skipDelayDuration={skipDelayDuration}
|
||||
disableHoverableContent={disableHoverableContent}
|
||||
>
|
||||
<TooltipContext.Provider value={true}>{children}</TooltipContext.Provider>
|
||||
</TooltipPrimitive.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,10 +159,16 @@ class WebsocketProvider extends Component<Props> {
|
||||
if (document?.updatedAt === documentDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
if (!document && !event.fetchIfMissing) {
|
||||
if (!document) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.invalidatedPolicies) {
|
||||
event.invalidatedPolicies.forEach((policyId) => {
|
||||
policies.remove(policyId);
|
||||
});
|
||||
}
|
||||
|
||||
// otherwise, grab the latest version of the document
|
||||
try {
|
||||
document = await documents.fetch(documentId, {
|
||||
@@ -207,10 +213,16 @@ class WebsocketProvider extends Component<Props> {
|
||||
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
if (!collection?.documents && !event.fetchIfMissing) {
|
||||
if (!collection?.documents) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.invalidatedPolicies) {
|
||||
event.invalidatedPolicies.forEach((policyId) => {
|
||||
policies.remove(policyId);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await collection?.fetchDocuments({
|
||||
force: true,
|
||||
|
||||
@@ -190,7 +190,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
|
||||
try {
|
||||
onClickLink(getHref(), event);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error(dictionary.openLinkError);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Text from "@shared/components/Text";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { extraArea } from "@shared/styles";
|
||||
import Input, { NativeInput, Outline } from "~/components/Input";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Dimension = {
|
||||
width: string;
|
||||
height: string;
|
||||
changed: "width" | "height" | "none";
|
||||
};
|
||||
|
||||
export function MediaDimension() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const boundsRef = useRef<{
|
||||
width: { min: number; max: number };
|
||||
height: { min: number; max: number };
|
||||
}>();
|
||||
const { view, commands } = useEditor();
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
// This component will be rendered only when the selection is image or video (NodeSelection types).
|
||||
const node = (selection as NodeSelection).node;
|
||||
const nodeType = node.type.name,
|
||||
width = node.attrs.width as number,
|
||||
height = node.attrs.height as number;
|
||||
|
||||
const [localDimension, setLocalDimension] = useState<Dimension>(() => ({
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
changed: "none",
|
||||
}));
|
||||
const [error, setError] = useState<{ width: boolean; height: boolean }>({
|
||||
width: false,
|
||||
height: false,
|
||||
});
|
||||
|
||||
if (!boundsRef.current && ref.current) {
|
||||
const docWidth = parseInt(
|
||||
getComputedStyle(ref.current).getPropertyValue("--document-width")
|
||||
);
|
||||
const maxWidth = docWidth - EditorStyleHelper.padding * 2;
|
||||
const constrainedWidth = Math.min(width, maxWidth); // Ensure media width does not exceed the max width of the editor.
|
||||
const aspectRatio = height / constrainedWidth;
|
||||
|
||||
const maxHeight = Math.round(maxWidth * aspectRatio);
|
||||
boundsRef.current = {
|
||||
width: { min: 50, max: maxWidth },
|
||||
height: { min: 50, max: maxHeight },
|
||||
};
|
||||
}
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setLocalDimension({
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
changed: "none",
|
||||
});
|
||||
setError({ width: false, height: false });
|
||||
}, [width, height]);
|
||||
|
||||
const isOutsideBounds = useCallback(
|
||||
(type: "width" | "height", value: number) => {
|
||||
const bounds = boundsRef.current!;
|
||||
return value < bounds[type].min || value > bounds[type].max;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(type: "width" | "height") => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target;
|
||||
const isNumber = /^\d+$/.test(value);
|
||||
|
||||
if (value && (!isNumber || value === "0")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError((prev) => {
|
||||
if (!prev.width && !prev.height) {
|
||||
return prev;
|
||||
}
|
||||
return { width: false, height: false };
|
||||
});
|
||||
|
||||
setLocalDimension((prev) => {
|
||||
if (type === "width") {
|
||||
return {
|
||||
...prev,
|
||||
width: value,
|
||||
changed: "width",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
height: value,
|
||||
changed: "height",
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
const localWidthAsNumber = localDimension.width
|
||||
? parseInt(localDimension.width, 10)
|
||||
: undefined,
|
||||
localHeightAsNumber = localDimension.height
|
||||
? parseInt(localDimension.height, 10)
|
||||
: undefined;
|
||||
|
||||
const isUnchanged =
|
||||
!localWidthAsNumber ||
|
||||
!localHeightAsNumber ||
|
||||
(localWidthAsNumber === width && localHeightAsNumber === height);
|
||||
|
||||
const isError =
|
||||
error.width ||
|
||||
error.height ||
|
||||
(localDimension.changed === "width" &&
|
||||
localWidthAsNumber &&
|
||||
isOutsideBounds("width", localWidthAsNumber)); // check width bounds here since 'onChange' error checker is debounced.
|
||||
|
||||
if (isUnchanged || isError) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const maxWidth = boundsRef.current!.width.max;
|
||||
// For images resized to the full width of the editor, natural width will be shown in the toolbar.
|
||||
// So, we constrain it here for computing aspect ratio.
|
||||
const constrainedWidth = Math.min(width, maxWidth);
|
||||
|
||||
const aspectRatio =
|
||||
localDimension.changed === "width"
|
||||
? height / constrainedWidth
|
||||
: constrainedWidth / height;
|
||||
|
||||
const finalWidth =
|
||||
localDimension.changed === "width"
|
||||
? localWidthAsNumber
|
||||
: Math.round(aspectRatio * localHeightAsNumber);
|
||||
const finalHeight =
|
||||
localDimension.changed === "height"
|
||||
? localHeightAsNumber
|
||||
: Math.round(aspectRatio * localWidthAsNumber);
|
||||
|
||||
if (nodeType === "image") {
|
||||
commands["resizeImage"]({
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
});
|
||||
}
|
||||
}, [commands, width, height, localDimension, nodeType, error, reset]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleBlur();
|
||||
} else if (e.key === "Escape") {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
[handleBlur, reset]
|
||||
);
|
||||
|
||||
// Sync dimension changes from outside.
|
||||
useEffect(() => {
|
||||
if (
|
||||
width !== Number(localDimension.width) ||
|
||||
height !== Number(localDimension.height)
|
||||
) {
|
||||
reset();
|
||||
}
|
||||
}, [width, height, reset]);
|
||||
|
||||
// hacky debounce for checking error.
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
const isWidthError = localDimension.width
|
||||
? Number(localDimension.width) !== width &&
|
||||
isOutsideBounds("width", Number(localDimension.width))
|
||||
: false;
|
||||
const isHeightError = localDimension.height
|
||||
? Number(localDimension.height) !== height &&
|
||||
isOutsideBounds("height", Number(localDimension.height))
|
||||
: false;
|
||||
|
||||
if (isWidthError || isHeightError) {
|
||||
setError({
|
||||
width: isWidthError,
|
||||
height: isHeightError,
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [width, height, localDimension, isOutsideBounds]);
|
||||
|
||||
return (
|
||||
<StyledFlex ref={ref} align="center">
|
||||
<StyledInput
|
||||
value={localDimension.width}
|
||||
onChange={handleChange("width")}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
$error={error.width}
|
||||
/>
|
||||
<Text size="xsmall" type="tertiary">
|
||||
x
|
||||
</Text>
|
||||
<StyledInput
|
||||
value={localDimension.height}
|
||||
onChange={handleChange("height")}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
$error={error.height}
|
||||
/>
|
||||
</StyledFlex>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledFlex = styled(Flex)`
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
|
||||
${extraArea(4)}
|
||||
`;
|
||||
|
||||
const StyledInput = styled(Input)<{ $error?: boolean }>`
|
||||
width: 50px;
|
||||
z-index: 1;
|
||||
|
||||
${Outline} {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
${NativeInput} {
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
|
||||
${(props) => props.$error && `color: ${props.theme.danger}`};
|
||||
}
|
||||
`;
|
||||
@@ -1,5 +1,6 @@
|
||||
import some from "lodash/some";
|
||||
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import * as React from "react";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
|
||||
@@ -22,6 +23,7 @@ import getImageMenuItems from "../menus/image";
|
||||
import getNoticeMenuItems from "../menus/notice";
|
||||
import getReadOnlyMenuItems from "../menus/readOnly";
|
||||
import getTableMenuItems from "../menus/table";
|
||||
import getTableCellMenuItems from "../menus/tableCell";
|
||||
import getTableColMenuItems from "../menus/tableCol";
|
||||
import getTableRowMenuItems from "../menus/tableRow";
|
||||
import { useEditor } from "./EditorContext";
|
||||
@@ -183,6 +185,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
const isCellSelection = selection instanceof CellSelection;
|
||||
const link = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "image";
|
||||
@@ -202,6 +205,8 @@ export default function SelectionToolbar(props: Props) {
|
||||
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
|
||||
} else if (rowIndex !== undefined) {
|
||||
items = getTableRowMenuItems(state, rowIndex, dictionary);
|
||||
} else if (isCellSelection) {
|
||||
items = getTableCellMenuItems(state, dictionary);
|
||||
} else if (isImageSelection) {
|
||||
items = readOnly ? [] : getImageMenuItems(state, dictionary);
|
||||
} else if (isAttachmentSelection) {
|
||||
@@ -221,6 +226,9 @@ export default function SelectionToolbar(props: Props) {
|
||||
if (item.name === "separator") {
|
||||
return true;
|
||||
}
|
||||
if (item.name === "dimensions") {
|
||||
return item.visible ?? false;
|
||||
}
|
||||
if (item.name && !commands[item.name]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import commandScore from "command-score";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
@@ -672,7 +672,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
</List>
|
||||
)}
|
||||
{uploadFile && (
|
||||
<VisuallyHidden>
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
<Trans>Import document</Trans>
|
||||
<input
|
||||
@@ -682,7 +682,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
</VisuallyHidden.Root>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TippyProps } from "@tippyjs/react";
|
||||
import { useMemo } from "react";
|
||||
import { useMenuState } from "reakit";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
@@ -11,6 +10,7 @@ import Template from "~/components/ContextMenu/Template";
|
||||
import { TooltipProvider } from "~/components/TooltipContext";
|
||||
import { MenuItem as TMenuItem } from "~/types";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { MediaDimension } from "./MediaDimension";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import ToolbarSeparator from "./ToolbarSeparator";
|
||||
import Tooltip from "./Tooltip";
|
||||
@@ -77,8 +77,6 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
const tippyProps = { placement: "top" } as TippyProps;
|
||||
|
||||
function ToolbarMenu(props: Props) {
|
||||
const { commands, view } = useEditor();
|
||||
const { items } = props;
|
||||
@@ -95,13 +93,13 @@ function ToolbarMenu(props: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider tippyProps={tippyProps}>
|
||||
<TooltipProvider>
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator" && item.visible !== false) {
|
||||
return <ToolbarSeparator key={index} />;
|
||||
}
|
||||
if (item.visible === false || !item.icon) {
|
||||
if (item.visible === false || (!item.skipIcon && !item.icon)) {
|
||||
return null;
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
@@ -112,7 +110,9 @@ function ToolbarMenu(props: Props) {
|
||||
shortcut={item.shortcut}
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
>
|
||||
{item.children ? (
|
||||
{item.name === "dimensions" ? (
|
||||
<MediaDimension key={index} />
|
||||
) : item.children ? (
|
||||
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
|
||||
@@ -8,10 +8,10 @@ const WrappedTooltip: React.FC<Props> = ({
|
||||
...rest
|
||||
}: Props) => (
|
||||
<Tooltip
|
||||
offset={[0, 16]}
|
||||
delay={150}
|
||||
sideOffset={16}
|
||||
delayDuration={150}
|
||||
content={content}
|
||||
placement="top"
|
||||
side="top"
|
||||
shortcutOnNewline
|
||||
{...rest}
|
||||
>
|
||||
|
||||
@@ -299,7 +299,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
this.results.push({ from, to, type });
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_err) {
|
||||
// Invalid RegExp
|
||||
}
|
||||
});
|
||||
|
||||
@@ -293,9 +293,11 @@ export default class PasteHandler extends Extension {
|
||||
currentPos += node.nodeSize;
|
||||
});
|
||||
} else {
|
||||
singleNode
|
||||
? tr.replaceSelectionWith(singleNode, this.shiftKey)
|
||||
: tr.replaceSelection(slice);
|
||||
if (singleNode) {
|
||||
tr.replaceSelectionWith(singleNode, this.shiftKey);
|
||||
} else {
|
||||
tr.replaceSelection(slice);
|
||||
}
|
||||
}
|
||||
|
||||
view.dispatch(
|
||||
@@ -550,7 +552,7 @@ function parseSingleIframeSrc(html: string) {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_err) {
|
||||
// Ignore the million ways parsing could fail.
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -513,7 +513,7 @@ export class Editor extends React.PureComponent<
|
||||
},
|
||||
this.elementRef.current || undefined
|
||||
);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// querySelector will throw an error if the hash begins with a number
|
||||
// or contains a period. This is protected against now by safeSlugify
|
||||
// however previous links may be in the wild.
|
||||
|
||||
@@ -59,6 +59,15 @@ export default function imageMenuItems(
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "dimensions",
|
||||
tooltip: dictionary.dimensions,
|
||||
visible: !isFullWidthAligned(state),
|
||||
skipIcon: true,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "downloadImage",
|
||||
tooltip: dictionary.downloadImage,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TableSplitCellsIcon, TableMergeCellsIcon } from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function tableCellMenuItems(
|
||||
state: EditorState,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { selection } = state;
|
||||
|
||||
// Only show menu items if we have a CellSelection
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: dictionary.mergeCells,
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: dictionary.splitCell,
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -8,10 +8,17 @@ import {
|
||||
ArrowIcon,
|
||||
MoreIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
TableSplitCellsIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import styled from "styled-components";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -21,7 +28,11 @@ export default function tableColMenuItems(
|
||||
rtl: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { schema } = state;
|
||||
const { schema, selection } = state;
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -96,6 +107,21 @@ export default function tableColMenuItems(
|
||||
icon: <InsertRightIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: dictionary.mergeCells,
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: dictionary.splitCell,
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteColumn",
|
||||
dangerous: true,
|
||||
|
||||
@@ -4,8 +4,15 @@ import {
|
||||
InsertBelowIcon,
|
||||
MoreIcon,
|
||||
TableHeaderRowIcon,
|
||||
TableSplitCellsIcon,
|
||||
TableMergeCellsIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -14,6 +21,11 @@ export default function tableRowMenuItems(
|
||||
index: number,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { selection } = state;
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
@@ -36,6 +48,21 @@ export default function tableRowMenuItems(
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: dictionary.mergeCells,
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: dictionary.splitCell,
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteRow",
|
||||
label: dictionary.deleteRow,
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function useDictionary() {
|
||||
deleteRow: t("Delete"),
|
||||
deleteTable: t("Delete table"),
|
||||
deleteAttachment: t("Delete file"),
|
||||
dimensions: t("Width x Height"),
|
||||
download: t("Download"),
|
||||
downloadAttachment: t("Download file"),
|
||||
replaceAttachment: t("Replace file"),
|
||||
@@ -86,6 +87,8 @@ export default function useDictionary() {
|
||||
toggleHeader: t("Toggle header"),
|
||||
mathInline: t("Math inline (LaTeX)"),
|
||||
mathBlock: t("Math block (LaTeX)"),
|
||||
mergeCells: t("Merge cells"),
|
||||
splitCell: t("Split cell"),
|
||||
tip: t("Tip"),
|
||||
tipNotice: t("Tip notice"),
|
||||
warning: t("Warning"),
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,7 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
embeds.map((e) => {
|
||||
// Find any integrations that match this embed and inject the settings
|
||||
const integration: Integration<IntegrationType.Embed> | undefined =
|
||||
find(
|
||||
integrations.orderedData,
|
||||
(integration) => integration.service === e.name
|
||||
);
|
||||
find(integrations.orderedData, (i) => i.service === e.name);
|
||||
|
||||
if (integration?.settings) {
|
||||
e.settings = integration.settings;
|
||||
|
||||
@@ -43,7 +43,7 @@ export function setPostLoginPath(path: string) {
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(key, path);
|
||||
} catch (e) {
|
||||
} catch (_err) {
|
||||
// If the session storage is full or inaccessible, we can't do anything about it.
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function usePostLoginPath() {
|
||||
let path;
|
||||
try {
|
||||
path = sessionStorage.getItem(key) || getCookie(key);
|
||||
} catch (e) {
|
||||
} catch (_err) {
|
||||
// Expected error if the session storage is full or inaccessible.
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function usePostLoginPath() {
|
||||
const cleanup = history.listen(() => {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch (e) {
|
||||
} catch (_err) {
|
||||
// Expected error if the session storage is full or inaccessible.
|
||||
}
|
||||
removeCookie(key);
|
||||
|
||||
@@ -4,22 +4,54 @@ import * as React from "react";
|
||||
type MenuContextType = {
|
||||
isMenuOpen: boolean;
|
||||
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
registerMenu: (menuId: string, hideFunction: () => void) => void;
|
||||
unregisterMenu: (menuId: string) => void;
|
||||
closeOtherMenus: (...menuIds: (string | undefined)[]) => void;
|
||||
};
|
||||
|
||||
const MenuContext = React.createContext<MenuContextType | null>(null);
|
||||
|
||||
// Registry to track all active menu instances
|
||||
const menuRegistry = new Map();
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MenuProvider: React.FC = ({ children }: Props) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||
|
||||
const registerMenu = React.useCallback(
|
||||
(menuId: string, hideFunction: () => void) => {
|
||||
menuRegistry.set(menuId, hideFunction);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const unregisterMenu = React.useCallback((menuId: string) => {
|
||||
menuRegistry.delete(menuId);
|
||||
}, []);
|
||||
|
||||
const closeOtherMenus = React.useCallback(
|
||||
(...menuIds: (string | undefined)[]) => {
|
||||
menuRegistry.forEach((hideFunction, menuId) => {
|
||||
if (!menuIds.includes(menuId)) {
|
||||
hideFunction();
|
||||
}
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const memoized = React.useMemo(
|
||||
() => ({
|
||||
isMenuOpen,
|
||||
setIsMenuOpen,
|
||||
registerMenu,
|
||||
unregisterMenu,
|
||||
closeOtherMenus,
|
||||
}),
|
||||
[isMenuOpen, setIsMenuOpen]
|
||||
[isMenuOpen, setIsMenuOpen, registerMenu, unregisterMenu, closeOtherMenus]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -29,7 +61,15 @@ export const MenuProvider: React.FC = ({ children }: Props) => {
|
||||
|
||||
const useMenuContext: () => MenuContextType = () => {
|
||||
const value = React.useContext(MenuContext);
|
||||
return value ? value : { isMenuOpen: false, setIsMenuOpen: noop };
|
||||
return value
|
||||
? value
|
||||
: {
|
||||
isMenuOpen: false,
|
||||
setIsMenuOpen: noop,
|
||||
registerMenu: noop,
|
||||
unregisterMenu: noop,
|
||||
closeOtherMenus: noop,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMenuContext;
|
||||
|
||||
@@ -23,11 +23,11 @@ const useMenuHeight = ({
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (visible && !isMobile) {
|
||||
const maxHeight = (windowHeight / 100) * maxViewportHeight;
|
||||
const calculatedMaxHeight = (windowHeight / 100) * maxViewportHeight;
|
||||
|
||||
setMaxHeight(
|
||||
Math.min(
|
||||
maxHeight,
|
||||
calculatedMaxHeight,
|
||||
elementRef?.current
|
||||
? windowHeight -
|
||||
elementRef.current.getBoundingClientRect().bottom -
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
useMenuState as reakitUseMenuState,
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import useMenuContext from "./useMenuContext";
|
||||
|
||||
type Props = Parameters<typeof reakitUseMenuState>[0] & {
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that wraps Reakit's useMenuState with coordination logic to ensure
|
||||
* only one context menu can be open at a time across the application.
|
||||
*/
|
||||
export function useMenuState(options?: Props): MenuStateReturn {
|
||||
const menuState = reakitUseMenuState(options);
|
||||
const { registerMenu, unregisterMenu, closeOtherMenus } = useMenuContext();
|
||||
const menuId = menuState.baseId;
|
||||
const parentId = options?.parentId;
|
||||
|
||||
// Register this menu instance on mount and unregister on unmount
|
||||
React.useEffect(() => {
|
||||
registerMenu(menuId, menuState.hide);
|
||||
return () => unregisterMenu(menuId);
|
||||
}, [menuId, menuState.hide, registerMenu, unregisterMenu]);
|
||||
|
||||
const coordinatedShow = React.useCallback(() => {
|
||||
closeOtherMenus(menuId, parentId);
|
||||
menuState.show();
|
||||
}, [closeOtherMenus, menuId, menuState, parentId]);
|
||||
|
||||
const coordinatedToggle = React.useCallback(() => {
|
||||
closeOtherMenus(menuId, parentId);
|
||||
menuState.toggle();
|
||||
}, [menuId, menuState, closeOtherMenus, parentId]);
|
||||
|
||||
// Return the menu state with the coordinated show method
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
...menuState,
|
||||
toggle: coordinatedToggle,
|
||||
show: coordinatedShow,
|
||||
}),
|
||||
[menuState, coordinatedToggle, coordinatedShow]
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
logout,
|
||||
} from "~/actions/definitions/navigation";
|
||||
import { changeTheme } from "~/actions/definitions/settings";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import separator from "~/menus/separator";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
|
||||
type Props = {
|
||||
onMembers: () => void;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
NewDocumentIcon,
|
||||
@@ -11,8 +12,7 @@ import {
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { toast } from "sonner";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -281,7 +282,7 @@ function CollectionMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden>
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
{t("Import document")}
|
||||
<input
|
||||
@@ -293,7 +294,7 @@ function CollectionMenu({
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
</VisuallyHidden.Root>
|
||||
{label ? (
|
||||
<MenuButton {...menu} onPointerEnter={handlePointerEnter}>
|
||||
{label}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { observer } from "mobx-react";
|
||||
import { CopyIcon, EditIcon } from "outline-icons";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import { toast } from "sonner";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Comment from "~/models/Comment";
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
viewCommentReactionsFactory,
|
||||
} from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { commentPath, urlify } from "~/utils/routeHelpers";
|
||||
|
||||
+28
-19
@@ -1,3 +1,4 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import noop from "lodash/noop";
|
||||
@@ -12,8 +13,7 @@ import {
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
@@ -233,6 +234,27 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const handleEmbedsToggle = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
document.enableEmbeds();
|
||||
} else {
|
||||
document.disableEmbeds();
|
||||
}
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
const handleFullWidthToggle = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
user.setPreference(UserPreference.FullWidthDocuments, checked);
|
||||
void user.save();
|
||||
document.fullWidth = checked;
|
||||
void document.save({ fullWidth: checked });
|
||||
},
|
||||
[user, document]
|
||||
);
|
||||
|
||||
return !isEmpty(can) ? (
|
||||
<ContextMenu
|
||||
{...menuState}
|
||||
@@ -363,11 +385,7 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
label={t("Enable embeds")}
|
||||
labelPosition="left"
|
||||
checked={!document.embedsDisabled}
|
||||
onChange={
|
||||
document.embedsDisabled
|
||||
? document.enableEmbeds
|
||||
: document.disableEmbeds
|
||||
}
|
||||
onChange={handleEmbedsToggle}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
@@ -379,16 +397,7 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
label={t("Full width")}
|
||||
labelPosition="left"
|
||||
checked={document.fullWidth}
|
||||
onChange={(ev) => {
|
||||
const fullWidth = ev.currentTarget.checked;
|
||||
user.setPreference(
|
||||
UserPreference.FullWidthDocuments,
|
||||
fullWidth
|
||||
);
|
||||
void user.save();
|
||||
document.fullWidth = fullWidth;
|
||||
void document.save();
|
||||
}}
|
||||
onChange={handleFullWidthToggle}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
@@ -468,7 +477,7 @@ function DocumentMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden>
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
{t("Import document")}
|
||||
<input
|
||||
@@ -480,7 +489,7 @@ function DocumentMenu({
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
</VisuallyHidden.Root>
|
||||
<MenuContext.Provider value={{ model: document, menuState }}>
|
||||
<MenuTrigger label={label} onTrigger={showMenu} />
|
||||
{isMenuVisible ? (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { DownloadIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
|
||||
type Props = {
|
||||
onRemove: () => void;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Group from "~/models/Group";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import { observer } from "mobx-react";
|
||||
import { CrossIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Import from "~/models/Import";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "i18next";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
@@ -10,6 +10,7 @@ import NudeButton from "~/components/NudeButton";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { toggleViewerInsights } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
const InsightsMenu: React.FC = () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import User from "~/models/User";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import Document from "~/models/Document";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
@@ -2,13 +2,14 @@ import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "i18next";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
@@ -11,6 +11,7 @@ import { actionToMenuItem, performAction } from "~/actions";
|
||||
import { navigateToNotificationSettings } from "~/actions/definitions/navigation";
|
||||
import { markNotificationsAsArchived } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Document from "~/models/Document";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
restoreRevision,
|
||||
} from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import separator from "./separator";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -3,13 +3,13 @@ import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import { toast } from "sonner";
|
||||
import Share from "~/models/Share";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import { observer } from "mobx-react";
|
||||
import { TableOfContentsIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
function TableOfContentsMenu() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
desktopLoginTeam,
|
||||
} from "~/actions/definitions/teams";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import separator from "~/menus/separator";
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ShapesIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
updateUserRoleActionFactory,
|
||||
} from "~/actions/definitions/users";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type NavigationNode,
|
||||
NavigationNodeType,
|
||||
type ProsemirrorData,
|
||||
TeamPreference,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
@@ -68,6 +69,13 @@ export default class Collection extends ParanoidModel {
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether commenting is enabled for the collection.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
commenting?: boolean | null;
|
||||
|
||||
/** The child documents of the collection. */
|
||||
@observable
|
||||
documents?: NavigationNode[];
|
||||
@@ -121,6 +129,21 @@ export default class Collection extends ParanoidModel {
|
||||
return !this.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether comments should be enabled for this collection,
|
||||
*
|
||||
* @returns boolean
|
||||
*/
|
||||
@computed
|
||||
get canCreateComment(): boolean {
|
||||
const teamCommentingEnabled =
|
||||
!!this.store.rootStore.auth.team?.getPreference(
|
||||
TeamPreference.Commenting
|
||||
);
|
||||
|
||||
return teamCommentingEnabled && this.commenting !== false;
|
||||
}
|
||||
|
||||
/** Returns whether the collection description is not empty. */
|
||||
@computed
|
||||
get hasDescription(): boolean {
|
||||
|
||||
@@ -60,6 +60,10 @@ class Share extends Model implements Searchable {
|
||||
@observable
|
||||
allowIndexing: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
showLastUpdated: boolean;
|
||||
|
||||
@observable
|
||||
views: number;
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ class Team extends Model {
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
description: string | null;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
avatarUrl: string;
|
||||
|
||||
@@ -195,6 +195,11 @@ function SharedDocumentScene(props: Props) {
|
||||
rel="canonical"
|
||||
href={canonicalOrigin + location.pathname.replace(/\/$/, "")}
|
||||
/>
|
||||
<link
|
||||
rel="sitemap"
|
||||
type="application/xml"
|
||||
href={`${env.URL}/api/documents.sitemap?shareId=${shareId}`}
|
||||
/>
|
||||
</Helmet>
|
||||
<TeamContext.Provider value={response.team}>
|
||||
<ThemeProvider theme={theme}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { m } from "framer-motion";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { ImageIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VisuallyHidden } from "reakit";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -266,7 +266,7 @@ function CommentForm({
|
||||
{...presence}
|
||||
{...rest}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<VisuallyHidden.Root>
|
||||
<input
|
||||
ref={file}
|
||||
type="file"
|
||||
@@ -274,7 +274,7 @@ function CommentForm({
|
||||
accept={AttachmentValidation.imageContentTypes.join(", ")}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
</VisuallyHidden.Root>
|
||||
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
|
||||
<Avatar model={user} size={24} style={{ marginTop: 8 }} />
|
||||
<Bubble
|
||||
|
||||
@@ -165,7 +165,7 @@ function CommentThreadItem({
|
||||
setReadOnly();
|
||||
comment.data = data;
|
||||
await comment.save();
|
||||
} catch (error) {
|
||||
} catch (_err) {
|
||||
setEditing();
|
||||
toast.error(t("Error updating comment"));
|
||||
}
|
||||
@@ -294,7 +294,7 @@ const ResolveButton = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Mark as resolved")} placement="top" hideOnClick>
|
||||
<Tooltip content={t("Mark as resolved")} placement="top">
|
||||
<Action
|
||||
as={NudeButton}
|
||||
context={context}
|
||||
|
||||
@@ -25,7 +25,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const { views, comments, ui } = useStores();
|
||||
const { collections, views, comments, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
@@ -41,9 +41,16 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const insightsPath = documentInsightsPath(document);
|
||||
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const collectionCommentingEnabled =
|
||||
collection?.canCreateComment ??
|
||||
!!team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
return (
|
||||
<Meta document={document} revision={revision} to={to} replace {...rest}>
|
||||
{team.getPreference(TeamPreference.Commenting) && can.comment && (
|
||||
{collectionCommentingEnabled && can.comment && (
|
||||
<>
|
||||
•
|
||||
<CommentLink
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Text from "@shared/components/Text";
|
||||
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -13,6 +15,7 @@ import { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import Time from "~/components/Time";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -57,7 +60,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const focusedComment = useFocusedComment();
|
||||
const { ui, comments } = useStores();
|
||||
const { ui, comments, collections } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const history = useHistory();
|
||||
@@ -75,6 +78,15 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
...rest
|
||||
} = props;
|
||||
const can = usePolicy(document);
|
||||
|
||||
// Check collection-level commenting setting
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const collectionCommentingEnabled =
|
||||
collection?.canCreateComment ??
|
||||
!!team?.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const iconColor = document.color ?? (last(colorPalette) as string);
|
||||
const childRef = React.useRef<HTMLDivElement>(null);
|
||||
const focusAtStart = React.useCallback(() => {
|
||||
@@ -220,16 +232,26 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
onBlur={handleBlur}
|
||||
placeholder={t("Untitled")}
|
||||
/>
|
||||
{!shareId && (
|
||||
{shareId ? (
|
||||
document.updatedAt ? (
|
||||
<SharedMeta type="tertiary">
|
||||
{t("Last updated")} <Time dateTime={document.updatedAt} addSuffix />
|
||||
</SharedMeta>
|
||||
) : null
|
||||
) : (
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
to={{
|
||||
pathname:
|
||||
match.path === matchDocumentHistory
|
||||
? documentPath(document)
|
||||
: documentHistoryPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
to={
|
||||
shareId
|
||||
? undefined
|
||||
: {
|
||||
pathname:
|
||||
match.path === matchDocumentHistory
|
||||
? documentPath(document)
|
||||
: documentHistoryPath(document),
|
||||
state: { sidebarContext },
|
||||
}
|
||||
}
|
||||
rtl={direction === "rtl"}
|
||||
/>
|
||||
)}
|
||||
@@ -244,12 +266,12 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
focusedCommentId={focusedComment?.id}
|
||||
onClickCommentMark={handleClickComment}
|
||||
onCreateCommentMark={
|
||||
team?.getPreference(TeamPreference.Commenting) && can.comment
|
||||
collectionCommentingEnabled && can.comment
|
||||
? handleDraftComment
|
||||
: undefined
|
||||
}
|
||||
onDeleteCommentMark={
|
||||
team?.getPreference(TeamPreference.Commenting) && can.comment
|
||||
collectionCommentingEnabled && can.comment
|
||||
? handleRemoveComment
|
||||
: undefined
|
||||
}
|
||||
@@ -265,4 +287,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
);
|
||||
}
|
||||
|
||||
const SharedMeta = styled(Text)`
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentEditor));
|
||||
|
||||
@@ -21,7 +21,9 @@ function MarkAsViewed(props: Props) {
|
||||
}, MARK_AS_VIEWED_AFTER);
|
||||
|
||||
return () => {
|
||||
viewTimeout.current && clearTimeout(viewTimeout.current);
|
||||
if (viewTimeout.current) {
|
||||
clearTimeout(viewTimeout.current);
|
||||
}
|
||||
};
|
||||
}, [document]);
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ function RevisionViewer(props: Props) {
|
||||
dangerouslySetInnerHTML={{ __html: revision.html }}
|
||||
dir={revision.dir}
|
||||
rtl={revision.rtl}
|
||||
readOnly
|
||||
/>
|
||||
{children}
|
||||
</Flex>
|
||||
|
||||
@@ -86,7 +86,7 @@ function DocumentMove({ document }: Props) {
|
||||
toast.success(t("Document moved"));
|
||||
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t move the document, try again?"));
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user