mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 878860215a | |||
| cb5ca55d9e | |||
| 8b2ccc6caf | |||
| 2db7690e27 | |||
| 06b89635be | |||
| 1ff23756ac | |||
| a00b677076 | |||
| 6c1e4a5b40 | |||
| 59078704c8 | |||
| f1a20b27fd | |||
| 313b046e4e | |||
| 1154432924 | |||
| e8bddbe104 | |||
| dddb12027c | |||
| 5cb3da82bc | |||
| 7a6f75c34f | |||
| 5d09be4add | |||
| 48cae96a56 | |||
| e8ab7a4885 | |||
| 183d02d5c6 | |||
| 4b833b3e2e | |||
| d1b75d44f6 |
@@ -53,9 +53,13 @@ export const resolveCommentFactory = ({
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onResolve();
|
||||
@@ -81,9 +85,13 @@ export const unresolveCommentFactory = ({
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
|
||||
@@ -8,18 +8,16 @@ import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
|
||||
type Props = {
|
||||
type Props = React.PropsWithChildren<{
|
||||
items: MenuInternalLink[];
|
||||
max?: number;
|
||||
highlightFirstItem?: boolean;
|
||||
};
|
||||
}>;
|
||||
|
||||
function Breadcrumb({
|
||||
items,
|
||||
highlightFirstItem,
|
||||
children,
|
||||
max = 2,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
function Breadcrumb(
|
||||
{ items, highlightFirstItem, children, max = 2 }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const totalItems = items.length;
|
||||
const topLevelItems: MenuInternalLink[] = [...items];
|
||||
let overflowItems;
|
||||
@@ -37,7 +35,7 @@ function Breadcrumb({
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" align="center">
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
{item.icon}
|
||||
@@ -67,6 +65,8 @@ const Slash = styled(GoToIcon)`
|
||||
|
||||
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
@@ -76,7 +76,6 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
height: 24px;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
@@ -87,4 +86,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default Breadcrumb;
|
||||
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
|
||||
|
||||
@@ -18,6 +18,8 @@ import useStores from "~/hooks/useStores";
|
||||
type Props = {
|
||||
/** The document to display live collaborators for */
|
||||
document: Document;
|
||||
/** The maximum number of collaborators to display, defaults to 6 */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,7 @@ type Props = {
|
||||
* and presence status.
|
||||
*/
|
||||
function Collaborators(props: Props) {
|
||||
const { limit = 6 } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const currentUserId = user?.id;
|
||||
@@ -75,8 +78,6 @@ function Collaborators(props: Props) {
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const limit = 8;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
@@ -88,6 +89,7 @@ function Collaborators(props: Props) {
|
||||
>
|
||||
<Facepile
|
||||
limit={limit}
|
||||
overflow={collaborators.length - limit}
|
||||
users={collaborators}
|
||||
renderAvatar={(collaborator) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
|
||||
@@ -8,15 +8,11 @@ import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import {
|
||||
archivePath,
|
||||
collectionPath,
|
||||
settingsPath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -57,14 +53,14 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
document,
|
||||
children,
|
||||
onlyText,
|
||||
}: Props) => {
|
||||
function DocumentBreadcrumb(
|
||||
{ document, children, onlyText }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -81,7 +77,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
to: {
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
};
|
||||
} else if (document.isCollectionDeleted) {
|
||||
collectionNode = {
|
||||
@@ -115,11 +114,14 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
) : (
|
||||
node.title
|
||||
),
|
||||
to: node.url,
|
||||
to: {
|
||||
pathname: node.url,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [path, category, collectionNode]);
|
||||
}, [path, category, sidebarContext, collectionNode]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
@@ -140,11 +142,11 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb items={items} highlightFirstItem>
|
||||
<Breadcrumb items={items} ref={ref} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const StyledIcon = styled(Icon)`
|
||||
margin-right: 2px;
|
||||
@@ -160,4 +162,4 @@ const SmallSlash = styled(GoToIcon)`
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
export default observer(DocumentBreadcrumb);
|
||||
export default observer(React.forwardRef(DocumentBreadcrumb));
|
||||
|
||||
@@ -144,12 +144,7 @@ function DocumentCard(props: Props) {
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
<Clock size={18} />
|
||||
<Time
|
||||
dateTime={document.updatedAt}
|
||||
tooltipDelay={500}
|
||||
addSuffix
|
||||
shorten
|
||||
/>
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
@@ -15,6 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -25,7 +26,6 @@ import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { ancestors, descendants } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -21,9 +21,11 @@ import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -50,6 +52,7 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
@@ -78,6 +81,12 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const sidebarContext = determineSidebarContext({
|
||||
document,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
@@ -89,6 +98,7 @@ function DocumentListItem(
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
@@ -111,11 +121,7 @@ function DocumentListItem(
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
content={t("Only visible to you")}
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import Event from "~/models/Event";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { hover } from "~/styles";
|
||||
@@ -35,6 +36,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { revisions } = useStores();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const opts = {
|
||||
userName: event.actor.name,
|
||||
};
|
||||
@@ -66,7 +68,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||
state: { retainScrollPosition: true },
|
||||
state: {
|
||||
sidebarContext,
|
||||
retainScrollPosition: true,
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -140,7 +145,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
title={
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
tooltipDelay={500}
|
||||
format={{
|
||||
en_US: "MMM do, h:mm a",
|
||||
fr_FR: "'Le 'd MMMM 'à' H:mm",
|
||||
|
||||
+56
-36
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
@@ -10,27 +11,35 @@ import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TooltipProvider } from "./TooltipContext";
|
||||
|
||||
export const HEADER_HEIGHT = 64;
|
||||
|
||||
type Props = {
|
||||
left?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
actions?:
|
||||
| ((props: { isCompact: boolean }) => React.ReactNode)
|
||||
| React.ReactNode;
|
||||
hasSidebar?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
function Header(
|
||||
{ left, title, actions, hasSidebar, className }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMobile();
|
||||
const hasMobileSidebar = hasSidebar && isMobile;
|
||||
|
||||
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const breadcrumbsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const passThrough = !actions && !left && !title;
|
||||
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
@@ -53,38 +62,50 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
{left}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
breadcrumbsRef.current = node?.firstElementChild as HTMLDivElement;
|
||||
}, []);
|
||||
|
||||
{isScrolled ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
const size = useComponentSize(internalRef);
|
||||
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
|
||||
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
|
||||
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
{left}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
|
||||
{isScrolled && !isCompact ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{typeof actions === "function" ? actions({ isCompact }) : actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,7 +172,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 16px;
|
||||
justify-content: center;
|
||||
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
|
||||
`};
|
||||
`;
|
||||
@@ -190,4 +210,4 @@ const MobileMenuButton = styled(Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Header);
|
||||
export default observer(React.forwardRef(Header));
|
||||
|
||||
@@ -4,9 +4,9 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input, { Outline } from "./Input";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Helmet } from "react-helmet-async";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
@@ -13,7 +14,6 @@ import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
|
||||
@@ -23,7 +23,6 @@ function eachMinute(fn: () => void) {
|
||||
export type Props = {
|
||||
children?: React.ReactNode;
|
||||
dateTime: string;
|
||||
tooltipDelay?: number;
|
||||
addSuffix?: boolean;
|
||||
shorten?: boolean;
|
||||
relative?: boolean;
|
||||
@@ -37,7 +36,6 @@ const LocaleTime: React.FC<Props> = ({
|
||||
shorten,
|
||||
format,
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}: Props) => {
|
||||
const userLocale = useUserLocale();
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
@@ -82,7 +80,7 @@ const LocaleTime: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} delay={tooltipDelay} placement="bottom">
|
||||
<Tooltip content={tooltipContent} placement="bottom">
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -52,11 +52,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
<Text weight="bold">{notification.subject}</Text>
|
||||
</Text>
|
||||
<Text type="tertiary" size="xsmall">
|
||||
<Time
|
||||
dateTime={notification.createdAt}
|
||||
tooltipDelay={1000}
|
||||
addSuffix
|
||||
/>{" "}
|
||||
<Time dateTime={notification.createdAt} addSuffix />{" "}
|
||||
{collection && <>· {collection.name}</>}
|
||||
</Text>
|
||||
{notification.comment && (
|
||||
|
||||
@@ -60,7 +60,7 @@ function Notifications(
|
||||
</Text>
|
||||
<Flex gap={8}>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
<Tooltip delay={500} content={t("Mark all as read")}>
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button action={markNotificationsAsRead} context={context}>
|
||||
<MarkAsReadIcon />
|
||||
</Button>
|
||||
|
||||
@@ -10,13 +10,23 @@ import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = PopoverProps & {
|
||||
children: React.ReactNode;
|
||||
/** The width of the popover, defaults to 380px. */
|
||||
width?: number;
|
||||
/** The minimum width of the popover, use instead of width if contents adjusts size. */
|
||||
minWidth?: number;
|
||||
/** Shrink the padding of the popover */
|
||||
shrink?: boolean;
|
||||
/** Make the popover flex */
|
||||
flex?: boolean;
|
||||
/** The tab index of the popover */
|
||||
tabIndex?: number;
|
||||
/** Whether the popover should be scrollable, defaults to true. */
|
||||
scrollable?: boolean;
|
||||
/** The position of the popover on mobile, defaults to "top". */
|
||||
mobilePosition?: "top" | "bottom";
|
||||
/** Function to show the popover */
|
||||
show: () => void;
|
||||
/** Function to hide the popover */
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
@@ -25,6 +35,7 @@ const Popover = (
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
minWidth,
|
||||
scrollable = true,
|
||||
flex,
|
||||
mobilePosition,
|
||||
@@ -71,6 +82,7 @@ const Popover = (
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
$minWidth={minWidth}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
>
|
||||
@@ -83,6 +95,7 @@ const Popover = (
|
||||
type ContentsProps = {
|
||||
$shrink?: boolean;
|
||||
$width?: number;
|
||||
$minWidth?: number;
|
||||
$flex?: boolean;
|
||||
$scrollable: boolean;
|
||||
$mobilePosition?: "top" | "bottom";
|
||||
@@ -101,7 +114,8 @@ const Contents = styled.div<ContentsProps>`
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
width: ${(props) => props.$width}px;
|
||||
${(props) => props.$width && `width: ${props.$width}px`};
|
||||
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
|
||||
|
||||
${(props) =>
|
||||
props.$scrollable
|
||||
|
||||
@@ -128,7 +128,7 @@ const Reaction: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
return tooltipContent ? (
|
||||
<Tooltip content={tooltipContent} delay={250} placement="bottom">
|
||||
<Tooltip content={tooltipContent} placement="bottom">
|
||||
{DisplayedEmoji}
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -98,12 +98,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
content={t("Add reaction")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<Tooltip content={t("Add reaction")} placement="top" hideOnClick>
|
||||
<NudeButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
|
||||
@@ -119,7 +119,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
: share?.url ?? "";
|
||||
|
||||
const copyButton = (
|
||||
<Tooltip content={t("Copy public link")} delay={500} placement="top">
|
||||
<Tooltip content={t("Copy public link")} placement="top">
|
||||
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
|
||||
<NudeButton type="button" disabled={!share} style={{ marginRight: 3 }}>
|
||||
<CopyIcon color={theme.placeholder} size={18} />
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CopyLinkButton({
|
||||
}, [onCopy, t]);
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Copy link")} delay={500} placement="top">
|
||||
<Tooltip content={t("Copy link")} placement="top">
|
||||
<CopyToClipboard text={url} onCopy={handleCopied}>
|
||||
<NudeButton type="button">
|
||||
<LinkIcon size={20} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
@@ -14,7 +15,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
@@ -80,7 +80,6 @@ function AppSidebar() {
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
|
||||
@@ -128,7 +128,7 @@ const Sidebar = styled(m.div)<{
|
||||
max-width: 80%;
|
||||
border-left: 1px solid ${s("divider")};
|
||||
transition: border-left 100ms ease-in-out;
|
||||
z-index: 1;
|
||||
z-index: ${depths.sidebar};
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: flex;
|
||||
|
||||
@@ -5,12 +5,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
@@ -42,11 +42,7 @@ function SettingsSidebar() {
|
||||
image={<StyledBackIcon />}
|
||||
onClick={returnToApp}
|
||||
>
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
@@ -12,7 +13,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import history from "~/utils/history";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
@@ -81,11 +81,7 @@ const ToggleSidebar = () => {
|
||||
const { ui } = useStores();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import { TooltipProvider } from "../TooltipContext";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
@@ -194,7 +195,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
@@ -242,7 +243,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
/>
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
</>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
@@ -49,38 +50,40 @@ function Collections() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
<SidebarContext.Provider value="collections">
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ function InnerDocumentLink(
|
||||
!isDraggingAnyDocument ? (
|
||||
<Fade>
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")} delay={500}>
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
|
||||
@@ -7,8 +7,8 @@ import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
@@ -29,7 +29,7 @@ function DraggableCollectionLink({
|
||||
prefetchDocument,
|
||||
belowCollection,
|
||||
}: Props) {
|
||||
const locationSidebarContext = useLocationState();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const { ui, policies, collections } = useStores();
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
|
||||
@@ -2,10 +2,11 @@ import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Group from "~/models/Group";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -14,13 +15,23 @@ type Props = {
|
||||
};
|
||||
|
||||
const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = groupSidebarContext(group.id);
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (locationSidebarContext === sidebarContext) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [sidebarContext, locationSidebarContext, setExpanded]);
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
<SidebarLink
|
||||
@@ -30,7 +41,7 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
onClick={handleDisclosureClick}
|
||||
depth={0}
|
||||
/>
|
||||
<SidebarContext.Provider value={group.id}>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
|
||||
@@ -43,12 +43,12 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
|
||||
return (
|
||||
<Navigation gap={4} {...props}>
|
||||
<Tooltip content={t("Go back")} delay={500}>
|
||||
<Tooltip content={t("Go back")}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goBack()}>
|
||||
<Back $active={back} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Go forward")} delay={500}>
|
||||
<Tooltip content={t("Go forward")}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goForward()}>
|
||||
<Forward $active={forward} />
|
||||
</NudeButton>
|
||||
|
||||
@@ -9,6 +9,7 @@ import GroupMembership from "~/models/GroupMembership";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
@@ -36,7 +36,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId } = membership;
|
||||
const isActiveDocument = documentId === ui.activeDocumentId;
|
||||
const locationSidebarContext = useLocationState();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
import * as React from "react";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
|
||||
export type SidebarContextType = "collections" | "starred" | string | undefined;
|
||||
export type SidebarContextType =
|
||||
| "collections"
|
||||
| "shared"
|
||||
| `group-${string}`
|
||||
| `starred-${string}`
|
||||
| undefined;
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType>(undefined);
|
||||
|
||||
export const useSidebarContext = () => React.useContext(SidebarContext);
|
||||
|
||||
export const groupSidebarContext = (groupId: string): SidebarContextType =>
|
||||
`group-${groupId}`;
|
||||
|
||||
export const starredSidebarContext = (modelId: string): SidebarContextType =>
|
||||
`starred-${modelId}`;
|
||||
|
||||
export const determineSidebarContext = ({
|
||||
document,
|
||||
user,
|
||||
currentContext,
|
||||
}: {
|
||||
document: Document;
|
||||
user: User;
|
||||
currentContext?: SidebarContextType;
|
||||
}): SidebarContextType => {
|
||||
const isStarred = document.isStarred || !!document.collection?.isStarred;
|
||||
const preferStarred = !currentContext || currentContext.startsWith("starred");
|
||||
|
||||
if (isStarred && preferStarred) {
|
||||
const currentlyInStarredCollection =
|
||||
currentContext === starredSidebarContext(document.collectionId ?? "");
|
||||
|
||||
return document.isStarred && !currentlyInStarredCollection
|
||||
? starredSidebarContext(document.id)
|
||||
: starredSidebarContext(document.collectionId!);
|
||||
}
|
||||
|
||||
if (document.collection) {
|
||||
return "collections";
|
||||
} else if (
|
||||
user.documentMemberships.find((m) => m.documentId === document.id)
|
||||
) {
|
||||
return "shared";
|
||||
} else {
|
||||
const group = user.groupsWithDocumentMemberships.find(
|
||||
(g) => !!g.documentMemberships.find((m) => m.documentId === document.id)
|
||||
);
|
||||
return groupSidebarContext(group?.id ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
export default SidebarContext;
|
||||
|
||||
@@ -15,7 +15,6 @@ import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredLink from "./StarredLink";
|
||||
|
||||
@@ -42,48 +41,46 @@ function Starred() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value="starred">
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import styled, { useTheme } from "styled-components";
|
||||
import Star from "~/models/Star";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
@@ -25,7 +25,7 @@ import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext, {
|
||||
SidebarContextType,
|
||||
useSidebarContext,
|
||||
starredSidebarContext,
|
||||
} from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -39,10 +39,14 @@ function StarredLink({ star }: Props) {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId, collectionId } = star;
|
||||
const collection = collections.get(collectionId);
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = starredSidebarContext(
|
||||
star.documentId ?? star.collectionId
|
||||
);
|
||||
const [expanded, setExpanded] = useState(
|
||||
star.collectionId === ui.activeCollectionId &&
|
||||
(star.documentId
|
||||
? star.documentId === ui.activeDocumentId
|
||||
: star.collectionId === ui.activeCollectionId) &&
|
||||
sidebarContext === locationSidebarContext
|
||||
);
|
||||
|
||||
@@ -159,7 +163,7 @@ function StarredLink({ star }: Props) {
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={document.id}>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
@@ -183,7 +187,7 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<CollectionLink
|
||||
collection={collection}
|
||||
@@ -193,16 +197,14 @@ function StarredLink({ star }: Props) {
|
||||
isDraggingAnyCollection={reorderStarProps.isDragging}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={collection.id}>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
</>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function Time({ onClick, ...props }: Props) {
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<LocaleTime tooltipDelay={250} {...props} />
|
||||
<LocaleTime {...props} />
|
||||
</React.Suspense>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Tippy, { TippyProps } from "@tippyjs/react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import { roundArrow } from "tippy.js";
|
||||
import { s } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useTooltipContext } from "./TooltipContext";
|
||||
|
||||
export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
/** The content to display in the tooltip. */
|
||||
@@ -12,8 +14,16 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
shortcut?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
/**
|
||||
* A tooltip component that wraps Tippy and provides a consistent look and feel. 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).
|
||||
*/
|
||||
function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const singleton = useTooltipContext();
|
||||
|
||||
let content = <>{tooltip}</>;
|
||||
|
||||
@@ -24,7 +34,18 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
if (shortcut) {
|
||||
content = (
|
||||
<>
|
||||
{tooltip} · <Shortcut>{shortcut}</Shortcut>
|
||||
{tooltip}{" "}
|
||||
{typeof shortcut === "string" ? (
|
||||
shortcut
|
||||
.split("+")
|
||||
.map((key) => (
|
||||
<Shortcut key={key}>
|
||||
{key.length === 1 ? key.toUpperCase() : key}
|
||||
</Shortcut>
|
||||
))
|
||||
) : (
|
||||
<Shortcut>{shortcut}</Shortcut>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -32,9 +53,10 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
return (
|
||||
<Tippy
|
||||
arrow={roundArrow}
|
||||
animation="shift-away"
|
||||
content={content}
|
||||
delay={delay}
|
||||
animation="shift-away"
|
||||
singleton={singleton}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...rest}
|
||||
@@ -44,16 +66,17 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
|
||||
const Shortcut = styled.kbd`
|
||||
position: relative;
|
||||
top: -2px;
|
||||
top: -1px;
|
||||
|
||||
margin-left: 2px;
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
font: 10px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
font-family: ${s("fontFamilyMono")};
|
||||
line-height: 10px;
|
||||
color: ${s("tooltipBackground")};
|
||||
color: ${s("tooltipText")};
|
||||
border: 1px solid ${(props) => transparentize(0.75, props.theme.tooltipText)};
|
||||
vertical-align: middle;
|
||||
background-color: ${s("tooltipText")};
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
@@ -132,7 +155,7 @@ export const TooltipStyles = createGlobalStyle`
|
||||
padding:5px 9px;
|
||||
z-index:1
|
||||
}
|
||||
|
||||
|
||||
/* Arrow Styles */
|
||||
.tippy-box[data-placement^=top]>.tippy-svg-arrow{
|
||||
bottom:0
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Tippy, { useSingleton, TippyProps } from "@tippyjs/react";
|
||||
import * as React from "react";
|
||||
import { roundArrow } from "tippy.js";
|
||||
|
||||
export const TooltipContext =
|
||||
React.createContext<TippyProps["singleton"]>(undefined);
|
||||
|
||||
export function useTooltipContext() {
|
||||
return React.useContext(TooltipContext);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
/** Props to pass to the Tippy component */
|
||||
tippyProps?: TippyProps;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a collection of tooltips in a provider to allow them to share the same singleton instance.
|
||||
*/
|
||||
export function TooltipProvider({ children, tippyProps }: Props) {
|
||||
const [source, target] = useSingleton();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tippy
|
||||
delay={500}
|
||||
arrow={roundArrow}
|
||||
animation="shift-away"
|
||||
singleton={source}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...tippyProps}
|
||||
/>
|
||||
<TooltipContext.Provider value={target}>
|
||||
{children}
|
||||
</TooltipContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState } from "reakit/Popover";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { altDisplay, isModKey, metaDisplay } from "@shared/utils/keyboard";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
@@ -21,14 +22,21 @@ import Tooltip from "~/components/Tooltip";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { altDisplay, isModKey, metaDisplay } from "~/utils/keyboard";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Props = {
|
||||
/** Whether the find and replace popover is open */
|
||||
open: boolean;
|
||||
/** Callback when the find and replace popover is opened */
|
||||
onOpen: () => void;
|
||||
/** Callback when the find and replace popover is closed */
|
||||
onClose: () => void;
|
||||
/** Whether the editor is in read-only mode */
|
||||
readOnly?: boolean;
|
||||
/** The current highlighted index in the search results */
|
||||
currentIndex: number;
|
||||
/** The total number of search results */
|
||||
totalResults: number;
|
||||
};
|
||||
|
||||
export default function FindAndReplace({
|
||||
@@ -36,6 +44,8 @@ export default function FindAndReplace({
|
||||
open,
|
||||
onOpen,
|
||||
onClose,
|
||||
currentIndex,
|
||||
totalResults,
|
||||
}: Props) {
|
||||
const editor = useEditor();
|
||||
const finalFocusRef = React.useRef<HTMLElement>(
|
||||
@@ -270,25 +280,26 @@ export default function FindAndReplace({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [popover.visible]);
|
||||
|
||||
const disabled = totalResults === 0;
|
||||
const navigation = (
|
||||
<>
|
||||
<Tooltip
|
||||
content={t("Previous match")}
|
||||
shortcut="shift+enter"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={() => editor.commands.prevSearchMatch()}>
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.prevSearchMatch()}
|
||||
>
|
||||
<CaretUpIcon />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={t("Next match")}
|
||||
shortcut="enter"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={() => editor.commands.nextSearchMatch()}>
|
||||
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
>
|
||||
<CaretDownIcon />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
@@ -303,10 +314,11 @@ export default function FindAndReplace({
|
||||
style={style}
|
||||
aria-label={t("Find and replace")}
|
||||
scrollable={false}
|
||||
width={420}
|
||||
minWidth={420}
|
||||
width={0}
|
||||
>
|
||||
<Content column>
|
||||
<Flex gap={8}>
|
||||
<Flex gap={4}>
|
||||
<StyledInput
|
||||
ref={inputRef}
|
||||
maxLength={255}
|
||||
@@ -319,7 +331,6 @@ export default function FindAndReplace({
|
||||
<Tooltip
|
||||
content={t("Match case")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+c`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleCaseSensitive}>
|
||||
@@ -331,7 +342,6 @@ export default function FindAndReplace({
|
||||
<Tooltip
|
||||
content={t("Enable regex")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+r`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleRegex}>
|
||||
@@ -344,16 +354,15 @@ export default function FindAndReplace({
|
||||
</StyledInput>
|
||||
{navigation}
|
||||
{!readOnly && (
|
||||
<Tooltip
|
||||
content={t("Replace options")}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Tooltip content={t("Replace options")} placement="bottom">
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Results>
|
||||
{totalResults > 0 ? currentIndex + 1 : 0} / {totalResults}
|
||||
</Results>
|
||||
</Flex>
|
||||
<ResizingHeightContainer>
|
||||
{showReplace && !readOnly && (
|
||||
@@ -367,10 +376,10 @@ export default function FindAndReplace({
|
||||
onRequestSubmit={handleReplaceAll}
|
||||
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
|
||||
/>
|
||||
<Button onClick={handleReplace} neutral>
|
||||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
<Button onClick={handleReplaceAll} neutral>
|
||||
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Flex>
|
||||
@@ -396,6 +405,12 @@ const ButtonSmall = styled(NudeButton)`
|
||||
&[aria-expanded="true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${s("textTertiary")};
|
||||
background: none;
|
||||
cursor: default;
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonLarge = styled(ButtonSmall)`
|
||||
@@ -408,3 +423,15 @@ const Content = styled(Flex)`
|
||||
margin-bottom: -16px;
|
||||
position: static;
|
||||
`;
|
||||
|
||||
const Results = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 32px;
|
||||
min-width: 32px;
|
||||
letter-spacing: -0.5px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TippyProps } from "@tippyjs/react";
|
||||
import * as React from "react";
|
||||
import { useMenuState } from "reakit";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
@@ -7,6 +8,7 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { s } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { TooltipProvider } from "~/components/TooltipContext";
|
||||
import { MenuItem as TMenuItem } from "~/types";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
@@ -75,6 +77,8 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
const tippyProps = { placement: "top" } as TippyProps;
|
||||
|
||||
function ToolbarMenu(props: Props) {
|
||||
const { commands, view } = useEditor();
|
||||
const { items } = props;
|
||||
@@ -91,36 +95,39 @@ function ToolbarMenu(props: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator" && item.visible !== false) {
|
||||
return <ToolbarSeparator key={index} />;
|
||||
}
|
||||
if (item.visible === false || !item.icon) {
|
||||
return null;
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
<TooltipProvider tippyProps={tippyProps}>
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator" && item.visible !== false) {
|
||||
return <ToolbarSeparator key={index} />;
|
||||
}
|
||||
if (item.visible === false || !item.icon) {
|
||||
return null;
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
key={index}
|
||||
>
|
||||
{item.children ? (
|
||||
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</FlexibleWrapper>
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
shortcut={item.shortcut}
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
>
|
||||
{item.children ? (
|
||||
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</FlexibleWrapper>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Tooltip, { Props } from "~/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
/** The content to display in the tooltip. */
|
||||
content?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const WrappedTooltip: React.FC<Props> = ({ children, content }: Props) => (
|
||||
<Tooltip offset={[0, 16]} delay={150} content={content} placement="top">
|
||||
const WrappedTooltip: React.FC<Props> = ({
|
||||
children,
|
||||
content,
|
||||
...rest
|
||||
}: Props) => (
|
||||
<Tooltip
|
||||
offset={[0, 16]}
|
||||
delay={150}
|
||||
content={content}
|
||||
placement="top"
|
||||
{...rest}
|
||||
>
|
||||
<TooltipContent>{children}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -332,6 +332,8 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
public widget = ({ readOnly }: WidgetProps) => (
|
||||
<FindAndReplace
|
||||
currentIndex={this.currentResultIndex}
|
||||
totalResults={this.results.length}
|
||||
readOnly={readOnly}
|
||||
open={this.open}
|
||||
onOpen={() => {
|
||||
@@ -346,7 +348,11 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
@observable
|
||||
private open = false;
|
||||
|
||||
@observable
|
||||
private results: { from: number; to: number }[] = [];
|
||||
|
||||
@observable
|
||||
private currentResultIndex = 0;
|
||||
|
||||
private searchTerm = "";
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Image from "@shared/editor/components/Img";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
|
||||
const Img = styled(Image)`
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -63,6 +64,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "strong",
|
||||
tooltip: dictionary.strong,
|
||||
shortcut: `${metaDisplay}+B`,
|
||||
icon: <BoldIcon />,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
@@ -70,6 +72,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "em",
|
||||
tooltip: dictionary.em,
|
||||
shortcut: `${metaDisplay}+I`,
|
||||
icon: <ItalicIcon />,
|
||||
active: isMarkActive(schema.marks.em),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
@@ -77,12 +80,14 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "strikethrough",
|
||||
tooltip: dictionary.strikethrough,
|
||||
shortcut: `${metaDisplay}+D`,
|
||||
icon: <StrikethroughIcon />,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
shortcut: `${metaDisplay}+Ctrl+H`,
|
||||
icon: highlight ? (
|
||||
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
|
||||
) : (
|
||||
@@ -114,6 +119,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "code_inline",
|
||||
tooltip: dictionary.codeInline,
|
||||
shortcut: `${metaDisplay}+E`,
|
||||
icon: <CodeIcon />,
|
||||
active: isMarkActive(schema.marks.code_inline),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
@@ -125,6 +131,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "heading",
|
||||
tooltip: dictionary.heading,
|
||||
shortcut: `⇧+Ctrl+1`,
|
||||
icon: <Heading1Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 1 }),
|
||||
attrs: { level: 1 },
|
||||
@@ -133,6 +140,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "heading",
|
||||
tooltip: dictionary.subheading,
|
||||
shortcut: `⇧+Ctrl+2`,
|
||||
icon: <Heading2Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 2 }),
|
||||
attrs: { level: 2 },
|
||||
@@ -141,6 +149,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "heading",
|
||||
tooltip: dictionary.subheading,
|
||||
shortcut: `⇧+Ctrl+3`,
|
||||
icon: <Heading3Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 3 }),
|
||||
attrs: { level: 3 },
|
||||
@@ -149,6 +158,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "blockquote",
|
||||
tooltip: dictionary.quote,
|
||||
shortcut: `${metaDisplay}+]`,
|
||||
icon: <BlockQuoteIcon />,
|
||||
active: isNodeActive(schema.nodes.blockquote),
|
||||
attrs: { level: 2 },
|
||||
@@ -161,6 +171,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "checkbox_list",
|
||||
tooltip: dictionary.checkboxList,
|
||||
shortcut: `⇧+Ctrl+7`,
|
||||
icon: <TodoListIcon />,
|
||||
keywords: "checklist checkbox task",
|
||||
active: isNodeActive(schema.nodes.checkbox_list),
|
||||
@@ -169,6 +180,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "bullet_list",
|
||||
tooltip: dictionary.bulletList,
|
||||
shortcut: `⇧+Ctrl+8`,
|
||||
icon: <BulletedListIcon />,
|
||||
active: isNodeActive(schema.nodes.bullet_list),
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
@@ -176,6 +188,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "ordered_list",
|
||||
tooltip: dictionary.orderedList,
|
||||
shortcut: `⇧+Ctrl+9`,
|
||||
icon: <OrderedListIcon />,
|
||||
active: isNodeActive(schema.nodes.ordered_list),
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
@@ -183,6 +196,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "outdentList",
|
||||
tooltip: dictionary.outdent,
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
@@ -190,6 +204,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "indentList",
|
||||
tooltip: dictionary.indent,
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
@@ -197,12 +212,14 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "outdentCheckboxList",
|
||||
tooltip: dictionary.outdent,
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentCheckboxList",
|
||||
tooltip: dictionary.indent,
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
@@ -213,6 +230,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "link",
|
||||
tooltip: dictionary.createLink,
|
||||
shortcut: `${metaDisplay}+K`,
|
||||
icon: <LinkIcon />,
|
||||
attrs: { href: "" },
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
@@ -220,6 +238,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "comment",
|
||||
tooltip: dictionary.comment,
|
||||
shortcut: `${metaDisplay}+⌥+M`,
|
||||
icon: <CommentIcon />,
|
||||
label: isCodeBlock ? dictionary.comment : undefined,
|
||||
active: isMarkActive(schema.marks.comment, { resolved: false }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
}
|
||||
|
||||
if (!isModKey(event) && !event.shiftKey) {
|
||||
history.push(navigateTo);
|
||||
history.push(navigateTo, { sidebarContext: "collections" }); // optimistic preference of "collections"
|
||||
} else {
|
||||
window.open(navigateTo, "_blank");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import isTextInput from "~/utils/isTextInput";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Callback = (event: KeyboardEvent) => void;
|
||||
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SidebarContextType } from "../components/SidebarContext";
|
||||
import { SidebarContextType } from "../components/Sidebar/components/SidebarContext";
|
||||
|
||||
/**
|
||||
* Hook to retrieve the sidebar context from the current location state.
|
||||
*/
|
||||
export function useLocationState() {
|
||||
export function useLocationSidebarContext() {
|
||||
const location = useLocation<{
|
||||
sidebarContext?: SidebarContextType;
|
||||
}>();
|
||||
@@ -29,6 +29,7 @@ import useCurrentUser from "./useCurrentUser";
|
||||
import usePolicy from "./usePolicy";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
const Features = lazy(() => import("~/scenes/Settings/Features"));
|
||||
@@ -87,10 +88,10 @@ const useSettingsConfig = () => {
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API"),
|
||||
path: settingsPath("tokens"),
|
||||
component: ApiKeys,
|
||||
enabled: can.createApiKey,
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("personal-api-keys"),
|
||||
component: PersonalApiKeys,
|
||||
enabled: can.createApiKey && !can.listApiKeys,
|
||||
group: t("Account"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
@@ -143,6 +144,14 @@ const useSettingsConfig = () => {
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("api-keys"),
|
||||
component: ApiKeys,
|
||||
enabled: can.listApiKeys,
|
||||
group: t("Workspace"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: t("Shared Links"),
|
||||
path: settingsPath("shares"),
|
||||
|
||||
@@ -136,14 +136,14 @@ type MenuContentProps = {
|
||||
showToggleEmbeds?: boolean;
|
||||
};
|
||||
|
||||
const MenuContent: React.FC<MenuContentProps> = ({
|
||||
const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
onOpen,
|
||||
onClose,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
showDisplayOptions,
|
||||
showToggleEmbeds,
|
||||
}) => {
|
||||
}) {
|
||||
const user = useCurrentUser();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
const can = usePolicy(document);
|
||||
@@ -348,7 +348,7 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
)}
|
||||
</ContextMenu>
|
||||
) : null;
|
||||
};
|
||||
});
|
||||
|
||||
function DocumentMenu({
|
||||
document,
|
||||
|
||||
@@ -19,12 +19,7 @@ function NewDocumentMenu() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Tooltip content={t("New document")} shortcut="n" placement="bottom">
|
||||
<Button as={Link} to={newDocumentPath()} icon={<PlusIcon />}>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import { DocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
@@ -14,11 +14,15 @@ import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
document: Document;
|
||||
/** Whether to render the button as a compact icon */
|
||||
isCompact?: boolean;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate: (template: Document) => void;
|
||||
};
|
||||
|
||||
function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
@@ -79,8 +83,13 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button {...props} disclosure neutral>
|
||||
{t("Templates")}
|
||||
<Button
|
||||
{...props}
|
||||
icon={isCompact ? <ShapesIcon /> : undefined}
|
||||
disclosure={!isCompact}
|
||||
neutral
|
||||
>
|
||||
{isCompact ? undefined : t("Templates")}
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
|
||||
@@ -119,6 +119,8 @@ class Notification extends Model {
|
||||
return t("mentioned you in");
|
||||
case NotificationEventType.CreateComment:
|
||||
return t("left a comment on");
|
||||
case NotificationEventType.ResolveComment:
|
||||
return t("resolved a comment on");
|
||||
case NotificationEventType.AddUserToDocument:
|
||||
return t("shared");
|
||||
case NotificationEventType.AddUserToCollection:
|
||||
@@ -170,6 +172,7 @@ class Notification extends Model {
|
||||
return this.document?.path;
|
||||
}
|
||||
case NotificationEventType.MentionedInComment:
|
||||
case NotificationEventType.ResolveComment:
|
||||
case NotificationEventType.CreateComment: {
|
||||
return this.document && this.comment
|
||||
? commentPath(this.document, this.comment)
|
||||
|
||||
@@ -27,7 +27,6 @@ function Actions({ collection }: Props) {
|
||||
<Tooltip
|
||||
content={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -316,7 +316,7 @@ function CommentForm({
|
||||
{t("Cancel")}
|
||||
</ButtonSmall>
|
||||
</Flex>
|
||||
<Tooltip delay={500} content={t("Upload image")} placement="top">
|
||||
<Tooltip content={t("Upload image")} placement="top">
|
||||
<NudeButton onClick={handleImageUpload}>
|
||||
<ImageIcon color={theme.textTertiary} />
|
||||
</NudeButton>
|
||||
|
||||
@@ -7,12 +7,14 @@ import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import { CommentSortType } from "~/types";
|
||||
|
||||
const CommentSortMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const history = useHistory();
|
||||
const user = useCurrentUser();
|
||||
const params = useQuery();
|
||||
@@ -39,6 +41,7 @@ const CommentSortMenu = () => {
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -49,6 +52,7 @@ const CommentSortMenu = () => {
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -61,6 +62,7 @@ function CommentThread({
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
|
||||
const can = usePolicy(document);
|
||||
@@ -101,7 +103,10 @@ function CommentThread({
|
||||
history.replace({
|
||||
search: location.search,
|
||||
pathname: location.pathname,
|
||||
state: { commentId: undefined },
|
||||
state: {
|
||||
commentId: undefined,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -115,7 +120,10 @@ function CommentThread({
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
search: thread.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId: thread.id },
|
||||
state: {
|
||||
commentId: thread.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -197,21 +197,12 @@ function CommentThreadItem({
|
||||
{showAuthor && <em>{comment.createdBy.name}</em>}
|
||||
{showAuthor && showTime && <> · </>}
|
||||
{showTime && (
|
||||
<Time
|
||||
dateTime={comment.createdAt}
|
||||
tooltipDelay={500}
|
||||
addSuffix
|
||||
shorten
|
||||
/>
|
||||
<Time dateTime={comment.createdAt} addSuffix shorten />
|
||||
)}
|
||||
{showEdited && (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<Time dateTime={comment.updatedAt} tooltipDelay={500}>
|
||||
{t("edited")}
|
||||
</Time>
|
||||
)
|
||||
(<Time dateTime={comment.updatedAt}>{t("edited")}</Time>)
|
||||
</>
|
||||
)}
|
||||
</Meta>
|
||||
@@ -304,12 +295,7 @@ const ResolveButton = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Mark as resolved")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<Tooltip content={t("Mark as resolved")} placement="top" hideOnClick>
|
||||
<Action
|
||||
as={NudeButton}
|
||||
context={context}
|
||||
|
||||
@@ -94,14 +94,18 @@ function Comments() {
|
||||
React.useEffect(() => {
|
||||
// Handles: 1. on refresh 2. when switching sort setting
|
||||
const readyToDisplay = Boolean(document && isEditorInitialized);
|
||||
if (readyToDisplay && sortOption.type === CommentSortType.MostRecent) {
|
||||
if (
|
||||
readyToDisplay &&
|
||||
sortOption.type === CommentSortType.MostRecent &&
|
||||
!viewingResolved
|
||||
) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [sortOption.type, document, isEditorInitialized]);
|
||||
}, [sortOption.type, document, isEditorInitialized, viewingResolved]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowJumpToRecentBtn(false);
|
||||
if (sortOption.type === CommentSortType.MostRecent) {
|
||||
if (sortOption.type === CommentSortType.MostRecent && !viewingResolved) {
|
||||
const commentsAdded = threads.length > prevThreadCount.current;
|
||||
if (commentsAdded) {
|
||||
if (isAtBottom.current) {
|
||||
@@ -112,7 +116,7 @@ function Comments() {
|
||||
}
|
||||
}
|
||||
prevThreadCount.current = threads.length;
|
||||
}, [sortOption.type, threads.length]);
|
||||
}, [sortOption.type, threads.length, viewingResolved]);
|
||||
|
||||
if (!document || !isEditorInitialized) {
|
||||
return null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import history from "~/utils/history";
|
||||
import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers";
|
||||
import Loading from "./Loading";
|
||||
import MarkAsViewed from "./MarkAsViewed";
|
||||
|
||||
type Params = {
|
||||
/** The document urlId + slugified title */
|
||||
@@ -222,16 +223,19 @@ function DataLoader({ match, children }: Props) {
|
||||
const readOnly = !isEditing || !canEdit;
|
||||
|
||||
return (
|
||||
<React.Fragment key={canEdit ? "edit" : "read"}>
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
abilities: can,
|
||||
readOnly,
|
||||
onCreateLink,
|
||||
sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
<>
|
||||
{!shareId && !revision && <MarkAsViewed document={document} />}
|
||||
<React.Fragment key={canEdit ? "edit" : "read"}>
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
abilities: can,
|
||||
readOnly,
|
||||
onCreateLink,
|
||||
sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
@@ -41,12 +42,12 @@ import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import withStores from "~/components/withStores";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import { SearchResult } from "~/editor/components/LinkEditor";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { emojiToUrl } from "~/utils/emoji";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
import {
|
||||
documentHistoryPath,
|
||||
@@ -58,7 +59,6 @@ import Contents from "./Contents";
|
||||
import Editor from "./Editor";
|
||||
import Header from "./Header";
|
||||
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||
import MarkAsViewed from "./MarkAsViewed";
|
||||
import { MeasuredContainer } from "./MeasuredContainer";
|
||||
import Notices from "./Notices";
|
||||
import PublicReferences from "./PublicReferences";
|
||||
@@ -77,6 +77,7 @@ type LocationState = {
|
||||
title?: string;
|
||||
restore?: boolean;
|
||||
revisionId?: string;
|
||||
sidebarContext?: SidebarContextType;
|
||||
};
|
||||
|
||||
type Props = WithTranslation &
|
||||
@@ -252,7 +253,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
const { document, abilities } = this.props;
|
||||
|
||||
if (abilities.update) {
|
||||
this.props.history.push(documentEditPath(document));
|
||||
this.props.history.push({
|
||||
pathname: documentEditPath(document),
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
}
|
||||
} else if (this.editor.current?.isBlurred) {
|
||||
ev.preventDefault();
|
||||
@@ -271,9 +275,15 @@ class DocumentScene extends React.Component<Props> {
|
||||
const { document, location } = this.props;
|
||||
|
||||
if (location.pathname.endsWith("history")) {
|
||||
this.props.history.push(document.url);
|
||||
this.props.history.push({
|
||||
pathname: document.url,
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
} else {
|
||||
this.props.history.push(documentHistoryPath(document));
|
||||
this.props.history.push({
|
||||
pathname: documentHistoryPath(document),
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,10 +349,16 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.isEditorDirty = false;
|
||||
|
||||
if (options.done) {
|
||||
this.props.history.push(savedDocument.url);
|
||||
this.props.history.push({
|
||||
pathname: savedDocument.url,
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
this.props.ui.setActiveDocument(savedDocument);
|
||||
} else if (document.isNew) {
|
||||
this.props.history.push(documentEditPath(savedDocument));
|
||||
this.props.history.push({
|
||||
pathname: documentEditPath(savedDocument),
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
this.props.ui.setActiveDocument(savedDocument);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -396,7 +412,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
goBack = () => {
|
||||
if (!this.props.readOnly) {
|
||||
this.props.history.push(this.props.document.url);
|
||||
this.props.history.push({
|
||||
pathname: this.props.document.url,
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -563,7 +582,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
canUpdate={abilities.update}
|
||||
canComment={abilities.comment}
|
||||
>
|
||||
{shareId && (
|
||||
{shareId ? (
|
||||
<ReferencesWrapper>
|
||||
<PublicReferences
|
||||
shareId={shareId}
|
||||
@@ -571,15 +590,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
sharedTree={this.props.sharedTree}
|
||||
/>
|
||||
</ReferencesWrapper>
|
||||
)}
|
||||
{!isShare && !revision && (
|
||||
<>
|
||||
<MarkAsViewed document={document} />
|
||||
<ReferencesWrapper>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
</>
|
||||
)}
|
||||
) : !revision ? (
|
||||
<ReferencesWrapper>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
) : null}
|
||||
</Editor>
|
||||
</MeasuredContainer>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ import Revision from "~/models/Revision";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Fade from "~/components/Fade";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
|
||||
@@ -27,6 +28,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const { views, comments, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const team = useCurrentTeam();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
@@ -45,7 +47,10 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
<>
|
||||
•
|
||||
<CommentLink
|
||||
to={documentPath(document)}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
onClick={() => ui.toggleComments()}
|
||||
>
|
||||
<CommentIcon size={18} />
|
||||
@@ -62,9 +67,13 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
<Wrapper>
|
||||
•
|
||||
<Link
|
||||
to={
|
||||
match.url === insightsPath ? documentPath(document) : insightsPath
|
||||
}
|
||||
to={{
|
||||
pathname:
|
||||
match.url === insightsPath
|
||||
? documentPath(document)
|
||||
: insightsPath,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
} from "@shared/utils/date";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
@@ -22,7 +23,6 @@ import Icon, { IconTitleWrapper } from "~/components/Icon";
|
||||
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import SmartText from "~/editor/extensions/SmartText";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -82,6 +83,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const params = useQuery();
|
||||
const {
|
||||
document,
|
||||
@@ -113,12 +115,15 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
history.replace({
|
||||
search: focusedComment.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname,
|
||||
state: { commentId: focusedComment.id },
|
||||
state: {
|
||||
commentId: focusedComment.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
}
|
||||
ui.set({ commentsExpanded: true });
|
||||
}
|
||||
}, [focusedComment, ui, document.id, history, params]);
|
||||
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
@@ -143,10 +148,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
(commentId: string) => {
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId },
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
},
|
||||
[history]
|
||||
[history, sidebarContext]
|
||||
);
|
||||
|
||||
// Create a Comment model in local store when a comment mark is created, this
|
||||
@@ -171,10 +176,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId },
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
},
|
||||
[comments, user?.id, props.id, history]
|
||||
[comments, user?.id, props.id, history, sidebarContext]
|
||||
);
|
||||
|
||||
// Soft delete the Comment model when associated mark is totally removed.
|
||||
@@ -238,11 +243,13 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
{!shareId && (
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
to={
|
||||
match.path === matchDocumentHistory
|
||||
? documentPath(document)
|
||||
: documentHistoryPath(document)
|
||||
}
|
||||
to={{
|
||||
pathname:
|
||||
match.path === matchDocumentHistory
|
||||
? documentPath(document)
|
||||
: documentHistoryPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
rtl={
|
||||
titleRef.current?.getComputedDirection() === "rtl" ? true : false
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
@@ -30,10 +31,12 @@ import { publishDocument } from "~/actions/definitions/documents";
|
||||
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
|
||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -41,7 +44,6 @@ import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
|
||||
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
|
||||
import TemplatesMenu from "~/menus/TemplatesMenu";
|
||||
import { altDisplay, metaDisplay } from "~/utils/keyboard";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import ObservingBanner from "./ObservingBanner";
|
||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||
@@ -86,11 +88,14 @@ function DocumentHeader({
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { resolvedTheme } = ui;
|
||||
const isMobile = useMobile();
|
||||
const isMobileMedia = useMobile();
|
||||
const isRevision = !!revision;
|
||||
const isEditingFocus = useEditingFocus();
|
||||
const { editor } = useDocumentContext();
|
||||
const { hasHeadings } = useDocumentContext();
|
||||
const { hasHeadings, editor } = useDocumentContext();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
const size = useComponentSize(ref);
|
||||
const isMobile = isMobileMedia || size.width < 700;
|
||||
|
||||
// We cache this value for as long as the component is mounted so that if you
|
||||
// apply a template there is still the option to replace it until the user
|
||||
@@ -129,8 +134,7 @@ function DocumentHeader({
|
||||
? t("Show contents")
|
||||
: `${t("Show contents")} (${t("available when headings are added")})`
|
||||
}
|
||||
shortcut={`ctrl+${altDisplay}+h`}
|
||||
delay={250}
|
||||
shortcut={`Ctrl+${altDisplay}+h`}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -148,13 +152,15 @@ function DocumentHeader({
|
||||
noun: document.noun,
|
||||
})}
|
||||
shortcut="e"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
icon={<EditIcon />}
|
||||
to={documentEditPath(document)}
|
||||
to={{
|
||||
pathname: documentEditPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{isMobile ? null : t("Edit")}
|
||||
@@ -168,7 +174,6 @@ function DocumentHeader({
|
||||
content={
|
||||
resolvedTheme === "light" ? t("Switch to dark") : t("Switch to light")
|
||||
}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -230,6 +235,7 @@ function DocumentHeader({
|
||||
return (
|
||||
<>
|
||||
<StyledHeader
|
||||
ref={ref}
|
||||
$hidden={isEditingFocus}
|
||||
hasSidebar
|
||||
left={
|
||||
@@ -254,7 +260,7 @@ function DocumentHeader({
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
</Flex>
|
||||
}
|
||||
actions={
|
||||
actions={({ isCompact }) => (
|
||||
<>
|
||||
<ObservingBanner />
|
||||
|
||||
@@ -262,11 +268,15 @@ function DocumentHeader({
|
||||
<Status>{t("Saving")}…</Status>
|
||||
)}
|
||||
{!isDeleted && !isRevision && can.listViews && (
|
||||
<Collaborators document={document} />
|
||||
<Collaborators
|
||||
document={document}
|
||||
limit={isCompact ? 3 : undefined}
|
||||
/>
|
||||
)}
|
||||
{(isEditing || !user?.separateEditMode) && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
isCompact={isCompact}
|
||||
document={document}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
@@ -282,7 +292,6 @@ function DocumentHeader({
|
||||
<Tooltip
|
||||
content={t("Save")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -306,6 +315,7 @@ function DocumentHeader({
|
||||
{can.update &&
|
||||
can.createChildDocument &&
|
||||
!isRevision &&
|
||||
!isCompact &&
|
||||
!isMobile && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
@@ -314,7 +324,6 @@ function DocumentHeader({
|
||||
<Tooltip
|
||||
content={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
@@ -327,11 +336,7 @@ function DocumentHeader({
|
||||
)}
|
||||
{revision && revision.createdAt !== document.updatedAt && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={t("Restore version")}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Tooltip content={t("Restore version")} placement="bottom">
|
||||
<Button
|
||||
action={restoreRevision}
|
||||
context={context}
|
||||
@@ -377,7 +382,7 @@ function DocumentHeader({
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import Event from "~/models/Event";
|
||||
import Empty from "~/components/Empty";
|
||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
@@ -20,6 +21,7 @@ function History() {
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
|
||||
const eventsInDocument = document
|
||||
@@ -28,7 +30,10 @@ function History() {
|
||||
|
||||
const onCloseHistory = () => {
|
||||
if (document) {
|
||||
history.push(documentPath(document));
|
||||
history.push({
|
||||
pathname: documentPath(document),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useTextSelection from "~/hooks/useTextSelection";
|
||||
@@ -28,6 +29,7 @@ function Insights() {
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const selectedText = useTextSelection();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const { editor } = useDocumentContext();
|
||||
@@ -38,7 +40,10 @@ function Insights() {
|
||||
|
||||
const onCloseInsights = () => {
|
||||
if (document) {
|
||||
history.push(documentPath(document));
|
||||
history.push({
|
||||
pathname: documentPath(document),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function KeyboardShortcutsButton() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Keyboard shortcuts")} shortcut="?" delay={500}>
|
||||
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
|
||||
<Button onClick={handleOpenKeyboardShortcuts} $hidden={isEditingFocus}>
|
||||
<KeyboardIcon />
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { determineIconType } from "@shared/utils/icon";
|
||||
import Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import { hover } from "~/styles";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -17,6 +18,7 @@ type Props = {
|
||||
document: Document | NavigationNode;
|
||||
anchor?: string;
|
||||
showCollection?: boolean;
|
||||
sidebarContext?: SidebarContextType;
|
||||
};
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
@@ -57,6 +59,7 @@ function ReferenceListItem({
|
||||
showCollection,
|
||||
anchor,
|
||||
shareId,
|
||||
sidebarContext,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { icon, color } = document;
|
||||
@@ -73,6 +76,7 @@ function ReferenceListItem({
|
||||
hash: anchor ? `d-${anchor}` : undefined,
|
||||
state: {
|
||||
title: document.title,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
|
||||
@@ -5,8 +5,11 @@ import { useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
import { determineSidebarContext } from "~/components/Sidebar/components/SidebarContext";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ReferenceListItem from "./ReferenceListItem";
|
||||
|
||||
@@ -16,7 +19,9 @@ type Props = {
|
||||
|
||||
function References({ document }: Props) {
|
||||
const { collections, documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
void documents.fetchBacklinks(document.id);
|
||||
@@ -40,12 +45,24 @@ function References({ document }: Props) {
|
||||
<Component>
|
||||
<Tabs>
|
||||
{showChildDocuments && (
|
||||
<Tab to="#children" isActive={() => !isBacklinksTab}>
|
||||
<Tab
|
||||
to={{
|
||||
hash: "#children",
|
||||
state: { sidebarContext: locationSidebarContext },
|
||||
}}
|
||||
isActive={() => !isBacklinksTab}
|
||||
>
|
||||
<Trans>Documents</Trans>
|
||||
</Tab>
|
||||
)}
|
||||
{showBacklinks && (
|
||||
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
|
||||
<Tab
|
||||
to={{
|
||||
hash: "#backlinks",
|
||||
state: { sidebarContext: locationSidebarContext },
|
||||
}}
|
||||
isActive={() => isBacklinksTab}
|
||||
>
|
||||
<Trans>Backlinks</Trans>
|
||||
</Tab>
|
||||
)}
|
||||
@@ -61,6 +78,11 @@ function References({ document }: Props) {
|
||||
showCollection={
|
||||
backlinkedDocument.collectionId !== document.collectionId
|
||||
}
|
||||
sidebarContext={determineSidebarContext({
|
||||
document: backlinkedDocument,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
@@ -76,6 +98,7 @@ function References({ document }: Props) {
|
||||
key={node.id}
|
||||
document={document || node}
|
||||
showCollection={false}
|
||||
sidebarContext={locationSidebarContext}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -32,7 +32,7 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
<>
|
||||
<Header>
|
||||
<Title>{title}</Title>
|
||||
<Tooltip content={t("Close")} shortcut="Esc" delay={500}>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
<Button
|
||||
icon={<ForwardIcon />}
|
||||
onClick={onClose}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { StaticContext } from "react-router";
|
||||
import { StaticContext, useHistory } from "react-router";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
@@ -16,12 +17,14 @@ type LocationState = {
|
||||
title?: string;
|
||||
restore?: boolean;
|
||||
revisionId?: string;
|
||||
sidebarContext?: SidebarContextType;
|
||||
};
|
||||
|
||||
type Props = RouteComponentProps<Params, StaticContext, LocationState>;
|
||||
|
||||
export default function DocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const history = useHistory();
|
||||
const { documentSlug, revisionId } = props.match.params;
|
||||
const currentPath = props.location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
@@ -32,6 +35,16 @@ export default function DocumentScene(props: Props) {
|
||||
|
||||
React.useEffect(() => () => ui.clearActiveDocument(), [ui]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// When opening a document directly on app load, sidebarContext will not be set.
|
||||
if (!props.location.state?.sidebarContext) {
|
||||
history.replace({
|
||||
...props.location,
|
||||
state: { ...props.location.state, sidebarContext: "collections" }, // optimistic preference of "collections"
|
||||
});
|
||||
}
|
||||
}, [props.location, history]);
|
||||
|
||||
// the urlId portion of the url does not include the slugified title
|
||||
// we only want to force a re-mount of the document component when the
|
||||
// document changes, not when the title does so only this portion is used
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import { metaDisplay, altDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Key from "~/components/Key";
|
||||
import { metaDisplay, altDisplay } from "~/utils/keyboard";
|
||||
|
||||
function KeyboardShortcuts() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DocumentFilter(props: Props) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tooltip content={t("Remove document filter")} delay={350}>
|
||||
<Tooltip content={t("Remove document filter")}>
|
||||
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
|
||||
{props.document.title}
|
||||
</StyledButton>
|
||||
|
||||
@@ -33,7 +33,7 @@ function RecentSearchListItem({ searchQuery }: Props) {
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
{searchQuery.query}
|
||||
<Tooltip content={t("Remove search")} delay={150}>
|
||||
<Tooltip content={t("Remove search")}>
|
||||
<RemoveButton
|
||||
aria-label={t("Remove search")}
|
||||
onClick={async (ev) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
@@ -13,36 +12,17 @@ import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
|
||||
function ApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
const [copiedKeyId, setCopiedKeyId] = React.useState<string | null>();
|
||||
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopy = React.useCallback(
|
||||
(keyId: string) => {
|
||||
if (copyTimeoutIdRef.current) {
|
||||
clearTimeout(copyTimeoutIdRef.current);
|
||||
}
|
||||
setCopiedKeyId(keyId);
|
||||
copyTimeoutIdRef.current = setTimeout(() => {
|
||||
setCopiedKeyId(null);
|
||||
}, 3000);
|
||||
toast.message(t("API key copied to clipboard"));
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
@@ -62,12 +42,11 @@ function ApiKeys() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Heading>{t("API Keys")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
defaults="API keys can be used to authenticate with the API and programatically control
|
||||
your workspace's data. For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
@@ -81,16 +60,10 @@ function ApiKeys() {
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<h2>{t("All")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
isCopied={apiKey.id === copiedKeyId}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CollectionIcon,
|
||||
CommentIcon,
|
||||
DocumentIcon,
|
||||
DoneIcon,
|
||||
EditIcon,
|
||||
EmailIcon,
|
||||
PublishIcon,
|
||||
@@ -65,6 +66,14 @@ function Notifications() {
|
||||
"Receive a notification when someone mentions you in a document or comment"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.ResolveComment,
|
||||
icon: <DoneIcon />,
|
||||
title: t("Resolved"),
|
||||
description: t(
|
||||
"Receive a notification when a comment thread you were involved in is resolved"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.CreateCollection,
|
||||
icon: <CollectionIcon />,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
|
||||
function PersonalApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
icon={<CodeIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
href="https://www.getoutline.com/developers"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(PersonalApiKeys);
|
||||
@@ -1,6 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import Button from "~/components/Button";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
@@ -8,24 +10,29 @@ import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import ApiKeyMenu from "~/menus/ApiKeyMenu";
|
||||
import { dateToExpiry } from "~/utils/date";
|
||||
|
||||
type Props = {
|
||||
/** The API key to display */
|
||||
apiKey: ApiKey;
|
||||
isCopied: boolean;
|
||||
onCopy: (keyId: string) => void;
|
||||
};
|
||||
|
||||
const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const userLocale = useUserLocale();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const subtitle = (
|
||||
<>
|
||||
<Text type="tertiary">
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix /> ·{" "}
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
|
||||
{apiKey.userId === user.id
|
||||
? ""
|
||||
: t(`by {{ name }}`, { name: user.name })}{" "}
|
||||
·{" "}
|
||||
</Text>
|
||||
{apiKey.lastActiveAt && (
|
||||
<Text type={"tertiary"}>
|
||||
@@ -41,9 +48,19 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
</>
|
||||
);
|
||||
|
||||
const [copied, setCopied] = React.useState<boolean>(false);
|
||||
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
onCopy(apiKey.id);
|
||||
}, [apiKey.id, onCopy]);
|
||||
if (copyTimeoutIdRef.current) {
|
||||
clearTimeout(copyTimeoutIdRef.current);
|
||||
}
|
||||
setCopied(true);
|
||||
copyTimeoutIdRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
toast.message(t("API key copied to clipboard"));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -52,10 +69,10 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{apiKey.value && (
|
||||
{apiKey.value && handleCopy && (
|
||||
<CopyToClipboard text={apiKey.value} onCopy={handleCopy}>
|
||||
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
|
||||
{isCopied ? t("Copied") : t("Copy")}
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
@@ -74,4 +91,4 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyListItem;
|
||||
export default observer(ApiKeyListItem);
|
||||
|
||||
+2
-2
@@ -223,7 +223,7 @@
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"slug": "^5.3.0",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.7.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"sonner": "^1.0.3",
|
||||
@@ -351,7 +351,7 @@
|
||||
"nodemon": "^3.1.7",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.0",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^0.4.1",
|
||||
"terser": "^5.36.0",
|
||||
|
||||
@@ -32,8 +32,7 @@ type BeforeSend = {
|
||||
type Props = InputProps & BeforeSend;
|
||||
|
||||
/**
|
||||
* Email sent to a user when a new comment is created in a document they are
|
||||
* subscribed to.
|
||||
* Email sent to a user when they are mentioned in a comment.
|
||||
*/
|
||||
export default class CommentMentionedEmail extends BaseEmail<
|
||||
InputProps,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import * as React from "react";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { Collection, Comment, Document } from "@server/models";
|
||||
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { can } from "@server/policies";
|
||||
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import Diff from "./components/Diff";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
type InputProps = EmailProps & {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
actorName: string;
|
||||
commentId: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
type BeforeSend = {
|
||||
document: Document;
|
||||
collection: Collection;
|
||||
body: string | undefined;
|
||||
unsubscribeUrl: string;
|
||||
};
|
||||
|
||||
type Props = InputProps & BeforeSend;
|
||||
|
||||
/**
|
||||
* Email sent to a user when a comment they are involved in was resolved.
|
||||
*/
|
||||
export default class CommentResolvedEmail extends BaseEmail<
|
||||
InputProps,
|
||||
BeforeSend
|
||||
> {
|
||||
protected get category() {
|
||||
return EmailMessageCategory.Notification;
|
||||
}
|
||||
|
||||
protected async beforeSend(props: InputProps) {
|
||||
const { documentId, commentId } = props;
|
||||
const document = await Document.unscoped().findByPk(documentId);
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = await document.$get("collection");
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [comment, team] = await Promise.all([
|
||||
Comment.findByPk(commentId),
|
||||
document.$get("team"),
|
||||
]);
|
||||
if (!comment || !team) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = await this.htmlForData(
|
||||
team,
|
||||
ProsemirrorHelper.toProsemirror(comment.data)
|
||||
);
|
||||
|
||||
return {
|
||||
document,
|
||||
collection,
|
||||
body,
|
||||
unsubscribeUrl: this.unsubscribeUrl(props),
|
||||
};
|
||||
}
|
||||
|
||||
protected unsubscribeUrl({ userId }: InputProps) {
|
||||
return NotificationSettingsHelper.unsubscribeUrl(
|
||||
userId,
|
||||
NotificationEventType.ResolveComment
|
||||
);
|
||||
}
|
||||
|
||||
protected replyTo({ notification }: Props) {
|
||||
if (notification?.user && notification.actor?.email) {
|
||||
if (can(notification.user, "readEmail", notification.actor)) {
|
||||
return notification.actor.email;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
protected subject({ document }: Props) {
|
||||
return `Resolved a comment thread in “${document.title}”`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
return `${actorName} resolved a comment thread`;
|
||||
}
|
||||
|
||||
protected fromName({ actorName }: Props): string {
|
||||
return actorName;
|
||||
}
|
||||
|
||||
protected renderAsText({
|
||||
actorName,
|
||||
teamUrl,
|
||||
document,
|
||||
commentId,
|
||||
collection,
|
||||
}: Props): string {
|
||||
const t1 = `${actorName} resolved a comment thread on "${document.title}"`;
|
||||
const t2 = collection.name ? ` in the ${collection.name} collection` : "";
|
||||
const t3 = `Open Thread: ${teamUrl}${document.url}?commentId=${commentId}`;
|
||||
return `${t1}${t2}.\n\n${t3}`;
|
||||
}
|
||||
|
||||
protected render(props: Props) {
|
||||
const {
|
||||
document,
|
||||
collection,
|
||||
actorName,
|
||||
teamUrl,
|
||||
commentId,
|
||||
unsubscribeUrl,
|
||||
body,
|
||||
} = props;
|
||||
const threadLink = `${teamUrl}${document.url}?commentId=${commentId}&ref=notification-email`;
|
||||
|
||||
return (
|
||||
<EmailTemplate
|
||||
previewText={this.preview(props)}
|
||||
goToAction={{ url: threadLink, name: "View Thread" }}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.title}</Heading>
|
||||
<p>
|
||||
{actorName} resolved a comment on{" "}
|
||||
<a href={threadLink}>{document.title}</a>{" "}
|
||||
{collection.name ? `in the ${collection.name} collection` : ""}.
|
||||
</p>
|
||||
{body && (
|
||||
<>
|
||||
<EmptySpace height={20} />
|
||||
<Diff>
|
||||
<div dangerouslySetInnerHTML={{ __html: body }} />
|
||||
</Diff>
|
||||
<EmptySpace height={20} />
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Button href={threadLink}>Open Thread</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer unsubscribeUrl={unsubscribeUrl} />
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { ApiKey, User, Team } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isOwner, isTeamModel, isTeamMutable } from "./utils";
|
||||
import {
|
||||
and,
|
||||
isCloudHosted,
|
||||
isOwner,
|
||||
isTeamModel,
|
||||
isTeamMutable,
|
||||
} from "./utils";
|
||||
|
||||
allow(User, "createApiKey", Team, (actor, team) =>
|
||||
and(
|
||||
@@ -18,6 +24,7 @@ allow(User, "createApiKey", Team, (actor, team) =>
|
||||
allow(User, "listApiKeys", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
isCloudHosted(),
|
||||
isTeamModel(actor, team),
|
||||
actor.isAdmin
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import CollectionCreatedEmail from "@server/emails/templates/CollectionCreatedEm
|
||||
import CollectionSharedEmail from "@server/emails/templates/CollectionSharedEmail";
|
||||
import CommentCreatedEmail from "@server/emails/templates/CommentCreatedEmail";
|
||||
import CommentMentionedEmail from "@server/emails/templates/CommentMentionedEmail";
|
||||
import CommentResolvedEmail from "@server/emails/templates/CommentResolvedEmail";
|
||||
import DocumentMentionedEmail from "@server/emails/templates/DocumentMentionedEmail";
|
||||
import DocumentPublishedOrUpdatedEmail from "@server/emails/templates/DocumentPublishedOrUpdatedEmail";
|
||||
import DocumentSharedEmail from "@server/emails/templates/DocumentSharedEmail";
|
||||
@@ -143,6 +144,23 @@ export default class EmailsProcessor extends BaseProcessor {
|
||||
).schedule({
|
||||
delay: Minute.ms,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case NotificationEventType.ResolveComment: {
|
||||
await new CommentResolvedEmail(
|
||||
{
|
||||
to: notification.user.email,
|
||||
userId: notification.userId,
|
||||
documentId: notification.documentId,
|
||||
teamUrl: notification.team.url,
|
||||
actorName: notification.actor.name,
|
||||
commentId: notification.commentId,
|
||||
},
|
||||
{ notificationId: notification.id }
|
||||
).schedule({
|
||||
delay: Minute.ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import invariant from "invariant";
|
||||
import { Op } from "sequelize";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { Comment, Document, Notification, User } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
@@ -7,6 +9,16 @@ import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEvent> {
|
||||
public async perform(event: CommentUpdateEvent) {
|
||||
const isResolving =
|
||||
event.changes?.previous?.resolvedAt === null &&
|
||||
event.changes?.attributes?.resolvedAt !== null;
|
||||
|
||||
return isResolving
|
||||
? await this.handleResolvedComment(event)
|
||||
: await this.handleMentionedComment(event);
|
||||
}
|
||||
|
||||
private async handleMentionedComment(event: CommentUpdateEvent) {
|
||||
const newMentionIds = event.data?.newMentionIds;
|
||||
if (!newMentionIds) {
|
||||
return;
|
||||
@@ -53,6 +65,69 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
|
||||
documentId: document.id,
|
||||
commentId: comment.id,
|
||||
});
|
||||
userIdsMentioned.push(mention.modelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResolvedComment(event: CommentUpdateEvent) {
|
||||
invariant(
|
||||
!event.data?.newMentionIds,
|
||||
"newMentionIds should not be present in resolved comment"
|
||||
);
|
||||
|
||||
const [document, commentsAndReplies] = await Promise.all([
|
||||
Document.scope("withCollection").findOne({
|
||||
where: { id: event.documentId },
|
||||
}),
|
||||
Comment.findAll({
|
||||
where: {
|
||||
[Op.or]: [{ id: event.modelId }, { parentCommentId: event.modelId }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!document || !commentsAndReplies) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userIdsNotified: string[] = [];
|
||||
|
||||
// Don't notify resolver
|
||||
userIdsNotified.push(event.actorId);
|
||||
|
||||
for (const item of commentsAndReplies) {
|
||||
// Mentions:
|
||||
const proseCommentData = ProsemirrorHelper.toProsemirror(item.data);
|
||||
const mentions = ProsemirrorHelper.parseMentions(proseCommentData);
|
||||
const userIds = mentions.map((mention) => mention.modelId);
|
||||
|
||||
// Comment author:
|
||||
userIds.push(item.createdById);
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (userIdsNotified.includes(userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
|
||||
if (
|
||||
user &&
|
||||
user.subscribedToEventType(NotificationEventType.ResolveComment) &&
|
||||
(await canUserAccessDocument(user, document.id))
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.ResolveComment,
|
||||
userId: user.id,
|
||||
actorId: event.actorId,
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
commentId: event.modelId,
|
||||
});
|
||||
|
||||
userIdsNotified.push(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@ this is code
|
||||
});
|
||||
|
||||
test("returns true for latex fence", () => {
|
||||
expect(isMarkdown(`\$\$i\$\$`)).toBe(true);
|
||||
expect(isMarkdown(`\$i\$`)).toBe(true);
|
||||
expect(
|
||||
isMarkdown(`\$0.00
|
||||
random content
|
||||
\$1.00`)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-closed fence", () => {
|
||||
|
||||
@@ -10,8 +10,8 @@ export default function isMarkdown(text: string): boolean {
|
||||
}
|
||||
|
||||
// latex-ish
|
||||
const latex = text.match(/\$\$/gm);
|
||||
if (latex && latex.length > 1) {
|
||||
const latex = text.match(/\$(.+)\$/g);
|
||||
if (latex && latex.length > 0) {
|
||||
signals += latex.length;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { MarkType } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { getMarksBetween } from "../queries/getMarksBetween";
|
||||
|
||||
/**
|
||||
* A factory function for creating Prosemirror plugins that automatically apply a mark to text
|
||||
* A factory function for creating a Prosemirror InputRule that automatically apply a mark to text
|
||||
* that matches a given regular expression.
|
||||
*
|
||||
* @param regexp The regular expression to match
|
||||
* @param markType The mark type to apply
|
||||
* @param getAttrs A function that returns the attributes to apply to the mark
|
||||
* Assumes the mark is not already applied, and that the regex includes two named capture groups:
|
||||
* `remove` and `text`. The `remove` group is used to determine what text should be removed from
|
||||
* the document before applying the mark, and the `text` group is used to determine what text
|
||||
* should be marked.
|
||||
*
|
||||
* @param regexp The regular expression to match.
|
||||
* @param markType The mark type to apply.
|
||||
* @param getAttrs An optional function that returns the attributes to apply to the new mark.
|
||||
* @returns The input rule
|
||||
*/
|
||||
export default function markInputRule(
|
||||
@@ -19,15 +25,20 @@ export default function markInputRule(
|
||||
): InputRule {
|
||||
return new InputRule(
|
||||
regexp,
|
||||
(state: EditorState, match: string[], start: number, end: number) => {
|
||||
(
|
||||
state: EditorState,
|
||||
match: RegExpMatchArray,
|
||||
start: number,
|
||||
end: number
|
||||
) => {
|
||||
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
|
||||
const { tr } = state;
|
||||
const captureGroup = match[match.length - 1];
|
||||
const captureGroup = match.groups?.text ?? match[match.length - 1];
|
||||
const removalGroup = match.groups?.remove ?? match[match.length - 2];
|
||||
const fullMatch = match[0];
|
||||
const startSpaces = fullMatch.search(/\S/);
|
||||
|
||||
if (captureGroup) {
|
||||
const matchStart = start + fullMatch.indexOf(captureGroup);
|
||||
const matchStart = start + fullMatch.lastIndexOf(removalGroup);
|
||||
const textStart = start + fullMatch.lastIndexOf(captureGroup);
|
||||
const textEnd = textStart + captureGroup.length;
|
||||
|
||||
@@ -43,14 +54,41 @@ export default function markInputRule(
|
||||
tr.delete(textEnd, end);
|
||||
}
|
||||
if (textStart > start) {
|
||||
tr.delete(start + startSpaces, textStart);
|
||||
tr.delete(matchStart, textStart);
|
||||
}
|
||||
end = start + startSpaces + captureGroup.length;
|
||||
|
||||
start = matchStart;
|
||||
end = start + captureGroup.length;
|
||||
}
|
||||
|
||||
tr.addMark(start + startSpaces, end, markType.create(attrs));
|
||||
tr.addMark(start, end, markType.create(attrs));
|
||||
tr.removeStoredMark(markType);
|
||||
return tr;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A factory function for creating a Prosemirror InputRule that automatically applies a mark to
|
||||
* text that is surrounded by a given pattern.
|
||||
*
|
||||
* @param pattern The pattern to match.
|
||||
* @param markType The mark type to apply.
|
||||
* @param getAttrs An optional function that returns the attributes to apply to the new mark.
|
||||
* @returns The input rule
|
||||
*/
|
||||
export function markInputRuleForPattern(
|
||||
pattern: string,
|
||||
markType: MarkType,
|
||||
getAttrs?: (match: string[]) => Record<string, unknown>
|
||||
): InputRule {
|
||||
const escapedPattern = escapeRegExp(pattern);
|
||||
|
||||
return markInputRule(
|
||||
new RegExp(
|
||||
`(?:^|[\\s\\[\\{\\(])(?<remove>${escapedPattern}(?<text>[^${escapedPattern}]+)${escapedPattern})$`
|
||||
),
|
||||
markType,
|
||||
getAttrs
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { MarkSpec, MarkType } from "prosemirror-model";
|
||||
import markInputRule from "../lib/markInputRule";
|
||||
import { markInputRuleForPattern } from "../lib/markInputRule";
|
||||
import Mark from "./Mark";
|
||||
|
||||
const heavyWeightRegex = /^(bold(er)?|[5-9]\d{2,})$/;
|
||||
@@ -33,7 +33,7 @@ export default class Bold extends Mark {
|
||||
}
|
||||
|
||||
inputRules({ type }: { type: MarkType }): InputRule[] {
|
||||
return [markInputRule(/(?:\*\*)([^*]+)(?:\*\*)$/, type)];
|
||||
return [markInputRuleForPattern("**", type)];
|
||||
}
|
||||
|
||||
keys({ type }: { type: MarkType }) {
|
||||
|
||||
+47
-24
@@ -7,33 +7,13 @@ import {
|
||||
Mark as ProsemirrorMark,
|
||||
Slice,
|
||||
} from "prosemirror-model";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { Plugin, TextSelection } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import markInputRule from "../lib/markInputRule";
|
||||
import { markInputRuleForPattern } from "../lib/markInputRule";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { isInCode } from "../queries/isInCode";
|
||||
import Mark from "./Mark";
|
||||
|
||||
function backticksFor(node: ProsemirrorNode, side: -1 | 1) {
|
||||
const ticks = /`+/g;
|
||||
let match: RegExpMatchArray | null;
|
||||
let len = 0;
|
||||
|
||||
if (node.isText) {
|
||||
while ((match = ticks.exec(node.text || ""))) {
|
||||
len = Math.max(len, match[0].length);
|
||||
}
|
||||
}
|
||||
|
||||
let result = len > 0 && side > 0 ? " `" : "`";
|
||||
for (let i = 0; i < len; i++) {
|
||||
result += "`";
|
||||
}
|
||||
if (len > 0 && side < 0) {
|
||||
result += " ";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default class Code extends Mark {
|
||||
get name() {
|
||||
return "code_inline";
|
||||
@@ -48,7 +28,7 @@ export default class Code extends Mark {
|
||||
}
|
||||
|
||||
inputRules({ type }: { type: MarkType }) {
|
||||
return [markInputRule(/(?:^|\s)((?:`)((?:[^`]+))(?:`))$/, type)];
|
||||
return [markInputRuleForPattern("`", type)];
|
||||
}
|
||||
|
||||
keys({ type }: { type: MarkType }) {
|
||||
@@ -140,12 +120,55 @@ export default class Code extends Mark {
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
// Triple clicking inside of an inline code mark will select the entire
|
||||
// code mark.
|
||||
handleTripleClickOn: (view: EditorView, pos: number) => {
|
||||
const { state } = view;
|
||||
const inCodeMark = isInCode(state, { onlyMark: true });
|
||||
|
||||
if (inCodeMark) {
|
||||
const $pos = state.doc.resolve(pos);
|
||||
const before = $pos.nodeBefore?.nodeSize ?? 0;
|
||||
const after = $pos.nodeAfter?.nodeSize ?? 0;
|
||||
const $from = state.doc.resolve(pos - before);
|
||||
const $to = state.doc.resolve(pos + after);
|
||||
|
||||
view.dispatch(
|
||||
state.tr.setSelection(TextSelection.between($from, $to))
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
toMarkdown() {
|
||||
function backticksFor(node: ProsemirrorNode, side: -1 | 1) {
|
||||
const ticks = /`+/g;
|
||||
let match: RegExpMatchArray | null;
|
||||
let len = 0;
|
||||
|
||||
if (node.isText) {
|
||||
while ((match = ticks.exec(node.text || ""))) {
|
||||
len = Math.max(len, match[0].length);
|
||||
}
|
||||
}
|
||||
|
||||
let result = len > 0 && side > 0 ? " `" : "`";
|
||||
for (let i = 0; i < len; i++) {
|
||||
result += "`";
|
||||
}
|
||||
if (len > 0 && side < 0) {
|
||||
result += " ";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
open(
|
||||
_state: MarkdownSerializerState,
|
||||
|
||||
@@ -21,9 +21,11 @@ export default class Comment extends Mark {
|
||||
userId: {},
|
||||
resolved: {
|
||||
default: false,
|
||||
validate: "boolean",
|
||||
},
|
||||
draft: {
|
||||
default: false,
|
||||
validate: "boolean",
|
||||
},
|
||||
},
|
||||
inclusive: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { rgba } from "polished";
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { MarkSpec, MarkType } from "prosemirror-model";
|
||||
import markInputRule from "../lib/markInputRule";
|
||||
import { markInputRuleForPattern } from "../lib/markInputRule";
|
||||
import markRule from "../rules/mark";
|
||||
import Mark from "./Mark";
|
||||
|
||||
@@ -24,6 +24,7 @@ export default class Highlight extends Mark {
|
||||
attrs: {
|
||||
color: {
|
||||
default: null,
|
||||
validate: "string|null",
|
||||
},
|
||||
},
|
||||
parseDOM: [
|
||||
@@ -52,7 +53,7 @@ export default class Highlight extends Mark {
|
||||
}
|
||||
|
||||
inputRules({ type }: { type: MarkType }) {
|
||||
return [markInputRule(/(?:==)([^=]+)(?:==)$/, type)];
|
||||
return [markInputRuleForPattern("==", type)];
|
||||
}
|
||||
|
||||
keys({ type }: { type: MarkType }) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { toggleMark } from "prosemirror-commands";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { MarkSpec, MarkType } from "prosemirror-model";
|
||||
import { Command } from "prosemirror-state";
|
||||
import markInputRule from "../lib/markInputRule";
|
||||
import { markInputRuleForPattern } from "../lib/markInputRule";
|
||||
import Mark from "./Mark";
|
||||
|
||||
export default class Italic extends Mark {
|
||||
@@ -25,29 +25,9 @@ export default class Italic extends Mark {
|
||||
}
|
||||
|
||||
inputRules({ type }: { type: MarkType }): InputRule[] {
|
||||
/**
|
||||
* Due to use of snake_case strings common in docs the matching conditions
|
||||
* are a bit more strict than e.g. the ** bold syntax to help prevent
|
||||
* false positives.
|
||||
*
|
||||
* Matches:
|
||||
* _1_
|
||||
* _123_
|
||||
* (_one_
|
||||
* [_one_
|
||||
*
|
||||
* No match:
|
||||
* __
|
||||
* __123_
|
||||
* __123__
|
||||
* _123
|
||||
* one_123_
|
||||
* ONE_123_
|
||||
* 1_123_
|
||||
*/
|
||||
return [
|
||||
markInputRule(/(?:^|[^_a-zA-Z0-9])(_([^_]+)_)$/, type),
|
||||
markInputRule(/(?:^|[^*a-zA-Z0-9])(\*([^*]+)\*)$/, type),
|
||||
markInputRuleForPattern("_", type),
|
||||
markInputRuleForPattern("*", type),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,11 @@ export default class Link extends Mark {
|
||||
attrs: {
|
||||
href: {
|
||||
default: "",
|
||||
validate: "string",
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
validate: "string|null",
|
||||
},
|
||||
},
|
||||
inclusive: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { MarkSpec, MarkType } from "prosemirror-model";
|
||||
import markInputRule from "../lib/markInputRule";
|
||||
import { markInputRuleForPattern } from "../lib/markInputRule";
|
||||
import Mark from "./Mark";
|
||||
|
||||
export default class Strikethrough extends Mark {
|
||||
@@ -36,7 +36,7 @@ export default class Strikethrough extends Mark {
|
||||
}
|
||||
|
||||
inputRules({ type }: { type: MarkType }) {
|
||||
return [markInputRule(/~([^~]+)~$/, type)];
|
||||
return [markInputRuleForPattern("~", type)];
|
||||
}
|
||||
|
||||
toMarkdown() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { MarkSpec, MarkType } from "prosemirror-model";
|
||||
import markInputRule from "../lib/markInputRule";
|
||||
import { markInputRuleForPattern } from "../lib/markInputRule";
|
||||
import underlinesRule from "../rules/underlines";
|
||||
import Mark from "./Mark";
|
||||
|
||||
@@ -32,7 +32,7 @@ export default class Underline extends Mark {
|
||||
}
|
||||
|
||||
inputRules({ type }: { type: MarkType }) {
|
||||
return [markInputRule(/(?:__)([^_]+)(?:__)$/, type)];
|
||||
return [markInputRuleForPattern("__", type)];
|
||||
}
|
||||
|
||||
keys({ type }: { type: MarkType }) {
|
||||
|
||||
@@ -294,7 +294,7 @@ export default class CodeFence extends Node {
|
||||
if (
|
||||
$from.sameParent($to) &&
|
||||
event.detail === 3 &&
|
||||
isInCode(view.state)
|
||||
isInCode(view.state, { onlyBlock: true })
|
||||
) {
|
||||
dispatch?.(
|
||||
state.tr
|
||||
|
||||
@@ -40,23 +40,27 @@ export default class Emoji extends Extension {
|
||||
{
|
||||
tag: "strong.emoji",
|
||||
preserveWhitespace: "full",
|
||||
getAttrs: (dom: HTMLElement) => ({
|
||||
"data-name": dom.dataset.name,
|
||||
}),
|
||||
getAttrs: (dom: HTMLElement) =>
|
||||
dom.dataset.name
|
||||
? {
|
||||
"data-name": dom.dataset.name,
|
||||
}
|
||||
: false,
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
if (getEmojiFromName(node.attrs["data-name"])) {
|
||||
const name = node.attrs["data-name"];
|
||||
if (name && getEmojiFromName(name)) {
|
||||
return [
|
||||
"strong",
|
||||
{
|
||||
class: `emoji ${node.attrs["data-name"]}`,
|
||||
"data-name": node.attrs["data-name"],
|
||||
class: `emoji ${name}`,
|
||||
"data-name": name,
|
||||
},
|
||||
getEmojiFromName(node.attrs["data-name"]),
|
||||
getEmojiFromName(name),
|
||||
];
|
||||
}
|
||||
return ["strong", { class: "emoji" }, `:${node.attrs["data-name"]}:`];
|
||||
return ["strong", { class: "emoji" }, `:${name}:`];
|
||||
},
|
||||
toPlainText: (node) => getEmojiFromName(node.attrs["data-name"]),
|
||||
};
|
||||
|
||||
@@ -36,7 +36,10 @@ export default class Video extends Node {
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
title: {},
|
||||
title: {
|
||||
default: null,
|
||||
validate: "string|null",
|
||||
},
|
||||
},
|
||||
group: "block",
|
||||
selectable: true,
|
||||
|
||||
@@ -196,6 +196,11 @@
|
||||
"Install now": "Nainstalovat",
|
||||
"Deleted Collection": "Odstraněná sbírka",
|
||||
"Unpin": "Zrušit připnutí",
|
||||
"Select a location to copy": "Select a location to copy",
|
||||
"Document copied": "Document copied",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
"Include nested documents": "Zahrnout vnořené dokumenty",
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Search collections & documents": "Prohledat sbírky a dokumenty",
|
||||
"No results found": "Nebyly nalezeny žádné výsledky",
|
||||
"Untitled": "Bez názvu",
|
||||
@@ -227,9 +232,6 @@
|
||||
"Currently editing": "Právě upravuje",
|
||||
"Currently viewing": "Právě prohlíží",
|
||||
"Viewed {{ timeAgo }}": "Zobrazeno před {{ timeAgo }}",
|
||||
"Copy of {{ documentName }}": "Kopie {{ documentName }}",
|
||||
"Title": "Název",
|
||||
"Include nested documents": "Zahrnout vnořené dokumenty",
|
||||
"Module failed to load": "Modul se nepodařilo načíst",
|
||||
"Loading Failed": "Načítání selhalo",
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Litujeme, část aplikace se nepodařilo načíst. Může to být způsobeno tím, že byla aktualizována od chvíle, kdy jste kartu otevřeli, nebo kvůli neúspěšnému síťovému požadavku. Zkuste znovu načíst.",
|
||||
|
||||
@@ -196,6 +196,11 @@
|
||||
"Install now": "Install now",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Unpin": "Unpin",
|
||||
"Select a location to copy": "Select a location to copy",
|
||||
"Document copied": "Document copied",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
"Include nested documents": "Include nested documents",
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"No results found": "Ingen resultater fundet",
|
||||
"Untitled": "Unavngivet",
|
||||
@@ -227,9 +232,6 @@
|
||||
"Currently editing": "Redigerer nu",
|
||||
"Currently viewing": "Ser nu",
|
||||
"Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}",
|
||||
"Copy of {{ documentName }}": "Copy of {{ documentName }}",
|
||||
"Title": "Title",
|
||||
"Include nested documents": "Include nested documents",
|
||||
"Module failed to load": "Modulet kunne ikke indlæses",
|
||||
"Loading Failed": "Loading Failed",
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
|
||||
|
||||
@@ -196,6 +196,11 @@
|
||||
"Install now": "Jetzt installieren",
|
||||
"Deleted Collection": "Gelöschte Sammlung",
|
||||
"Unpin": "Lospinnen",
|
||||
"Select a location to copy": "Select a location to copy",
|
||||
"Document copied": "Document copied",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
"Include nested documents": "Verschachtelte Dokumente einbeziehen",
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Search collections & documents": "Sammlungen und Dokumente durchsuchen",
|
||||
"No results found": "Keine Ergebnisse gefunden",
|
||||
"Untitled": "Ohne Titel",
|
||||
@@ -227,9 +232,6 @@
|
||||
"Currently editing": "Derzeit in Bearbeitung",
|
||||
"Currently viewing": "Gerade angezeigt",
|
||||
"Viewed {{ timeAgo }}": "Angesehen {{ timeAgo }}",
|
||||
"Copy of {{ documentName }}": "Kopie von {{ documentName }}",
|
||||
"Title": "Überschrift",
|
||||
"Include nested documents": "Verschachtelte Dokumente einbeziehen",
|
||||
"Module failed to load": "Modul konnte nicht geladen werden",
|
||||
"Loading Failed": "Laden fehlgeschlagen",
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Leider konnte ein Teil der Software nicht geladen werden weil der Inhalt sich inzwischen geändert hat oder eine Abfrage nicht erfolgreich abgeschlossen werden konnte. Bitte neu laden.",
|
||||
|
||||
@@ -497,7 +497,7 @@
|
||||
"Could not import file": "Could not import file",
|
||||
"Unsubscribed from document": "Unsubscribed from document",
|
||||
"Account": "Account",
|
||||
"API": "API",
|
||||
"API Keys": "API Keys",
|
||||
"Details": "Details",
|
||||
"Security": "Security",
|
||||
"Features": "Features",
|
||||
@@ -557,6 +557,7 @@
|
||||
"created the collection": "created the collection",
|
||||
"mentioned you in": "mentioned you in",
|
||||
"left a comment on": "left a comment on",
|
||||
"resolved a comment on": "resolved a comment on",
|
||||
"shared": "shared",
|
||||
"invited you to": "invited you to",
|
||||
"Choose a date": "Choose a date",
|
||||
@@ -829,11 +830,12 @@
|
||||
"Something went wrong": "Something went wrong",
|
||||
"Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists",
|
||||
"No documents found for your search filters.": "No documents found for your search filters.",
|
||||
"API key copied to clipboard": "API key copied to clipboard",
|
||||
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Personal keys": "Personal keys",
|
||||
"API": "API",
|
||||
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"Last used": "Last used",
|
||||
"No expiry": "No expiry",
|
||||
"API key copied to clipboard": "API key copied to clipboard",
|
||||
"Copied": "Copied",
|
||||
"Revoking": "Revoking",
|
||||
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
|
||||
@@ -938,6 +940,7 @@
|
||||
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment": "Receive a notification when a document you are subscribed to or a thread you participated in receives a comment",
|
||||
"Mentioned": "Mentioned",
|
||||
"Receive a notification when someone mentions you in a document or comment": "Receive a notification when someone mentions you in a document or comment",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Receive a notification when a comment thread you were involved in is resolved",
|
||||
"Collection created": "Collection created",
|
||||
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
|
||||
"Invite accepted": "Invite accepted",
|
||||
@@ -958,6 +961,8 @@
|
||||
"Email address": "Email address",
|
||||
"Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.",
|
||||
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
|
||||
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Personal keys": "Personal keys",
|
||||
"Preferences saved": "Preferences saved",
|
||||
"Delete account": "Delete account",
|
||||
"Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user