mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e47eb3e41 | |||
| 39cb446748 | |||
| 1154432924 | |||
| 4c465dcabd | |||
| 067a376ada | |||
| e8bddbe104 | |||
| dddb12027c | |||
| 5cb3da82bc | |||
| 7a6f75c34f |
@@ -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);
|
||||
|
||||
@@ -57,11 +57,10 @@ 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);
|
||||
@@ -140,11 +139,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 +159,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>
|
||||
|
||||
@@ -111,11 +111,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>
|
||||
)}
|
||||
|
||||
@@ -140,7 +140,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",
|
||||
|
||||
@@ -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,6 +11,7 @@ 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";
|
||||
@@ -21,16 +23,22 @@ 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,8 +61,18 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
breadcrumbsRef.current = node?.firstElementChild as HTMLDivElement;
|
||||
}, []);
|
||||
|
||||
const size = useComponentSize(internalRef);
|
||||
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
|
||||
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
|
||||
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
@@ -62,7 +80,7 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
@@ -74,7 +92,7 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
|
||||
{isScrolled ? (
|
||||
{isScrolled && !isCompact ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
@@ -82,7 +100,7 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{actions}
|
||||
{typeof actions === "function" ? actions({ isCompact }) : actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -151,7 +169,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 16px;
|
||||
justify-content: center;
|
||||
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
|
||||
`};
|
||||
`;
|
||||
@@ -190,4 +207,4 @@ const MobileMenuButton = styled(Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Header);
|
||||
export default observer(React.forwardRef(Header));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,9 @@
|
||||
import Tippy, { TippyProps } from "@tippyjs/react";
|
||||
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 +12,9 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
shortcut?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const singleton = useTooltipContext();
|
||||
|
||||
let content = <>{tooltip}</>;
|
||||
|
||||
@@ -30,15 +31,7 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tippy
|
||||
arrow={roundArrow}
|
||||
animation="shift-away"
|
||||
content={content}
|
||||
delay={delay}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...rest}
|
||||
/>
|
||||
<Tippy content={content} delay={delay} singleton={singleton} {...rest} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +125,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,36 @@
|
||||
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;
|
||||
tippyProps?: TippyProps;
|
||||
};
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,10 +25,18 @@ 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>
|
||||
@@ -306,7 +317,7 @@ export default function FindAndReplace({
|
||||
width={420}
|
||||
>
|
||||
<Content column>
|
||||
<Flex gap={8}>
|
||||
<Flex gap={4}>
|
||||
<StyledInput
|
||||
ref={inputRef}
|
||||
maxLength={255}
|
||||
@@ -319,7 +330,6 @@ export default function FindAndReplace({
|
||||
<Tooltip
|
||||
content={t("Match case")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+c`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleCaseSensitive}>
|
||||
@@ -331,7 +341,6 @@ export default function FindAndReplace({
|
||||
<Tooltip
|
||||
content={t("Enable regex")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+r`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleRegex}>
|
||||
@@ -344,16 +353,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 +375,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 +404,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 +422,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,38 @@ 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
|
||||
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>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,18 @@ type Props = {
|
||||
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 = "";
|
||||
}
|
||||
|
||||
+18
-15
@@ -20,6 +20,7 @@ import { initI18n } from "~/utils/i18n";
|
||||
import Desktop from "./components/DesktopEventHandler";
|
||||
import LazyPolyfill from "./components/LazyPolyfills";
|
||||
import PageScroll from "./components/PageScroll";
|
||||
import { TooltipProvider } from "./components/TooltipContext";
|
||||
import Routes from "./routes";
|
||||
import Logger from "./utils/Logger";
|
||||
import { PluginManager } from "./utils/PluginManager";
|
||||
@@ -55,21 +56,23 @@ if (element) {
|
||||
<Theme>
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
<TooltipProvider>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</TooltipProvider>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,7 @@ 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";
|
||||
@@ -86,11 +87,13 @@ 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 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
|
||||
@@ -130,7 +133,6 @@ function DocumentHeader({
|
||||
: `${t("Show contents")} (${t("available when headings are added")})`
|
||||
}
|
||||
shortcut={`ctrl+${altDisplay}+h`}
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -148,7 +150,6 @@ function DocumentHeader({
|
||||
noun: document.noun,
|
||||
})}
|
||||
shortcut="e"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -168,7 +169,6 @@ function DocumentHeader({
|
||||
content={
|
||||
resolvedTheme === "light" ? t("Switch to dark") : t("Switch to light")
|
||||
}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -230,6 +230,7 @@ function DocumentHeader({
|
||||
return (
|
||||
<>
|
||||
<StyledHeader
|
||||
ref={ref}
|
||||
$hidden={isEditingFocus}
|
||||
hasSidebar
|
||||
left={
|
||||
@@ -254,7 +255,7 @@ function DocumentHeader({
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
</Flex>
|
||||
}
|
||||
actions={
|
||||
actions={({ isCompact }) => (
|
||||
<>
|
||||
<ObservingBanner />
|
||||
|
||||
@@ -262,11 +263,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 +287,6 @@ function DocumentHeader({
|
||||
<Tooltip
|
||||
content={t("Save")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -306,6 +310,7 @@ function DocumentHeader({
|
||||
{can.update &&
|
||||
can.createChildDocument &&
|
||||
!isRevision &&
|
||||
!isCompact &&
|
||||
!isMobile && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
@@ -314,7 +319,6 @@ function DocumentHeader({
|
||||
<Tooltip
|
||||
content={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
@@ -327,11 +331,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 +377,7 @@ function DocumentHeader({
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -939,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",
|
||||
|
||||
@@ -277,6 +277,7 @@ export enum NotificationEventType {
|
||||
CreateRevision = "revisions.create",
|
||||
CreateCollection = "collections.create",
|
||||
CreateComment = "comments.create",
|
||||
ResolveComment = "comments.resolve",
|
||||
MentionedInDocument = "documents.mentioned",
|
||||
MentionedInComment = "comments.mentioned",
|
||||
InviteAccepted = "emails.invite_accepted",
|
||||
@@ -305,6 +306,7 @@ export const NotificationEventDefaults: Record<NotificationEventType, boolean> =
|
||||
[NotificationEventType.UpdateDocument]: true,
|
||||
[NotificationEventType.CreateCollection]: false,
|
||||
[NotificationEventType.CreateComment]: true,
|
||||
[NotificationEventType.ResolveComment]: true,
|
||||
[NotificationEventType.CreateRevision]: false,
|
||||
[NotificationEventType.MentionedInDocument]: true,
|
||||
[NotificationEventType.MentionedInComment]: true,
|
||||
|
||||
@@ -6915,10 +6915,10 @@ cookie@^0.7.0:
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.0.tgz#2148f68a77245d5c2c0005d264bc3e08cfa0655d"
|
||||
integrity sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ==
|
||||
|
||||
cookie@~0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
|
||||
integrity "sha1-r9cT/ibr0hupXOth+agRblClN9E= sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA=="
|
||||
cookie@~0.7.2:
|
||||
version "0.7.2"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
|
||||
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
|
||||
|
||||
cookies@~0.9.0:
|
||||
version "0.9.1"
|
||||
@@ -7959,21 +7959,21 @@ engine.io-parser@~5.2.1:
|
||||
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.1.tgz#9f213c77512ff1a6cc0c7a86108a7ffceb16fcfb"
|
||||
integrity "sha1-nyE8d1Ev8abMDHqGEIp//OsW/Ps= sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ=="
|
||||
|
||||
engine.io@~6.5.2:
|
||||
version "6.5.2"
|
||||
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.2.tgz#769348ced9d56bd47bd83d308ec1c3375e85937c"
|
||||
integrity "sha1-dpNIztnVa9R72D0wjsHDN16Fk3w= sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA=="
|
||||
engine.io@~6.6.0:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.6.2.tgz#32bd845b4db708f8c774a4edef4e5c8a98b3da72"
|
||||
integrity sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==
|
||||
dependencies:
|
||||
"@types/cookie" "^0.4.1"
|
||||
"@types/cors" "^2.8.12"
|
||||
"@types/node" ">=10.0.0"
|
||||
accepts "~1.3.4"
|
||||
base64id "2.0.0"
|
||||
cookie "~0.4.1"
|
||||
cookie "~0.7.2"
|
||||
cors "~2.8.5"
|
||||
debug "~4.3.1"
|
||||
engine.io-parser "~5.2.1"
|
||||
ws "~8.11.0"
|
||||
ws "~8.17.1"
|
||||
|
||||
enhanced-resolve@^5.15.0:
|
||||
version "5.17.1"
|
||||
@@ -14185,16 +14185,16 @@ socket.io-redis@^6.1.1:
|
||||
socket.io-adapter "~2.2.0"
|
||||
uid2 "0.0.3"
|
||||
|
||||
socket.io@^4.7.5:
|
||||
version "4.7.5"
|
||||
resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.5.tgz#56eb2d976aef9d1445f373a62d781a41c7add8f8"
|
||||
integrity sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==
|
||||
socket.io@^4.8.1:
|
||||
version "4.8.1"
|
||||
resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.1.tgz#fa0eaff965cc97fdf4245e8d4794618459f7558a"
|
||||
integrity sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==
|
||||
dependencies:
|
||||
accepts "~1.3.4"
|
||||
base64id "~2.0.0"
|
||||
cors "~2.8.5"
|
||||
debug "~4.3.2"
|
||||
engine.io "~6.5.2"
|
||||
engine.io "~6.6.0"
|
||||
socket.io-adapter "~2.5.2"
|
||||
socket.io-parser "~4.2.4"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user