Compare commits

...

22 Commits

Author SHA1 Message Date
Tom Moor 878860215a docs 2024-12-07 13:56:35 -05:00
Tom Moor cb5ca55d9e fix: Allow inline mark matching to work with preceding brackets
Refactor markInputRule, add markInputRuleForPattern
2024-12-07 13:53:07 -05:00
Tom Moor 8b2ccc6caf stash 2024-12-07 13:35:10 -05:00
Tom Moor 2db7690e27 feat: Triple clicking in code mark should select entire mark, closes #8072 2024-12-07 12:58:15 -05:00
Tom Moor 06b89635be Improved tooltip context – separate for header,sidebar,editor. 2024-12-07 12:45:29 -05:00
Tom Moor 1ff23756ac fix: Make FindAndReplace popover dynamic, fixes button overflow.
closes #8079
2024-12-06 19:55:45 -05:00
Tom Moor a00b677076 fix: Use sidebarContext in header breadcrumbs (#8077) 2024-12-06 08:00:50 -05:00
Tom Moor 6c1e4a5b40 Add shortcuts to formatting menu tooltips (#8080)
* Add shortcuts to formatting menu tooltips

* Tooltip styling

* tsc
2024-12-05 20:50:16 -08:00
Tom Moor 59078704c8 fix: Embed toggle is unresponsive (#8078)
* fix: Embed toggle is unresponsive

* fix: View recorded when toggling embeds
2024-12-05 20:01:16 -08:00
Hemachandar f1a20b27fd fix: auto-scroll sidebar to show active document (#7956) 2024-12-05 17:23:13 -08:00
Tom Moor 313b046e4e fix: Use singleton for tooltips, ensures that only one is visible at a time. (#8069)
* fix: Use singleton for tooltips, ensures that only one is visible at a time and animations are shared

* fix: give toolbar menu its own context

* Remove duplicate props
2024-12-05 16:10:12 -08:00
Tom Moor 1154432924 Adds count of occurences and index to find and replace (#8070)
* Adds count of occurences and index to find and replace

* Disable replace buttons also
2024-12-05 15:58:24 -08:00
infinite-persistence e8bddbe104 Notification for resolved comment (#8045)
* fix: probably copy-pasted function description

* fix: userIdsMentioned was always empty

* add: NotificationEventType.ResolveComment

* move: split handler for "mentioned" vs. "resolved"

The recipients for "resolved" will include more people (creator, repliers, mentioned), so it's easier to just split the handler than trying to augment it.

* implement: handleResolvedComment

* clone: CommentMentionedEmail as CommentResolvedEmail

Changes coming up in next commit...

* implement: CommentResolvedEmail

* Fix "New Comment↓" incorrectly showing in Resolved

## Repro 1 (with production code)
1. In a list of long resolved comments, scroll up and select the first one.
2. From another account, resolve another comment. The hint appears.

## Repro 2 (with production code)
1. Select Most-Recent, then Resolved.
2. F5. It's scrolled all the way to the bottom.

## Repro 3 (after this PR)
1. Click on the notification when someone resolved a comment. The screen jumps to "Resolved" + showing hint unnecessarily.

## Fix
The scrolling and hint was meant for Most Recent only, but missed out this case since "Resolve" is not part of the enum.

* Better sentences

* Refactor "mentions + author" calculation

* Remove unnecessary check

The resolver is already added to `userIdsNotified` from the start, so no point checking it again here.
2024-12-04 15:10:03 -08:00
Tom Moor dddb12027c fix: Crash in header ref, regressed in 7a6f75c34f closes #8068 2024-12-04 08:35:55 -05:00
dependabot[bot] 5cb3da82bc chore(deps): bump socket.io from 4.7.5 to 4.8.1 (#8056)
Bumps [socket.io](https://github.com/socketio/socket.io) from 4.7.5 to 4.8.1.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io@4.7.5...socket.io@4.8.1)

---
updated-dependencies:
- dependency-name: socket.io
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 19:15:07 -08:00
Tom Moor 7a6f75c34f fix: Improved responsiveness of document header elements (#8066)
* fix: Made the document header components more responsive to the available space

* doc
2024-12-03 19:11:36 -08:00
Tom Moor 5d09be4add More improvements to LaTeX fence detection 2024-12-02 22:28:59 -05:00
Tom Moor 48cae96a56 fix: Improve validation around emoji node serialization/deserialization 2024-12-02 21:58:22 -05:00
Tom Moor e8ab7a4885 chore: Add additional node validation 2024-12-02 21:22:58 -05:00
dependabot[bot] 183d02d5c6 chore(deps-dev): bump react-refresh from 0.14.0 to 0.14.2 (#8053)
Bumps [react-refresh](https://github.com/facebook/react/tree/HEAD/packages/react) from 0.14.0 to 0.14.2.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v0.14.2/packages/react)

---
updated-dependencies:
- dependency-name: react-refresh
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-02 18:10:54 -08:00
Tom Moor 4b833b3e2e Add screen for API key management (#8049)
* API keys

* Add api key list for admins

* permissions
2024-12-02 18:10:36 -08:00
Translate-O-Tron d1b75d44f6 New Crowdin updates (#8040) 2024-12-02 14:36:10 -08:00
124 changed files with 1504 additions and 733 deletions
+10 -2
View File
@@ -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();
+10 -11
View File
@@ -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);
+4 -2
View File
@@ -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);
+19 -17
View File
@@ -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));
+1 -6
View File
@@ -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>
+1 -1
View File
@@ -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 = {
+11 -5
View File
@@ -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>
)}
+6 -2
View File
@@ -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
View File
@@ -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));
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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;
+1 -3
View File
@@ -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 && <>&middot; {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>
+15 -1
View File
@@ -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
+1 -1
View File
@@ -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>
) : (
+1 -6
View File
@@ -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} />
+1 -2
View File
@@ -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"
+1 -1
View File
@@ -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;
+2 -6
View File
@@ -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 />}
+2 -6
View File
@@ -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 />}
+3 -2
View File
@@ -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;
+40 -43
View File
@@ -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>
);
}
+1 -1
View File
@@ -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>
);
+32 -9
View File
@@ -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} &middot; <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
+40
View File
@@ -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>
</>
);
}
+48 -21
View File
@@ -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;
`;
+36 -29
View File
@@ -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>
);
}
+13 -9
View File
@@ -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>
);
+6
View File
@@ -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 = "";
}
+1 -1
View File
@@ -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;
+19
View File
@@ -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 }),
+2 -2
View File
@@ -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 -1
View File
@@ -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;
@@ -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;
}>();
+13 -4
View File
@@ -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"),
+3 -3
View File
@@ -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,
+1 -6
View File
@@ -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>
+13 -4
View File
@@ -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>
+3
View File
@@ -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 && <> &middot; </>}
{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}
+8 -4
View File
@@ -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;
+14 -10
View File
@@ -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>
</>
);
}
+33 -18
View File
@@ -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) {
<>
&nbsp;&nbsp;
<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>
&nbsp;&nbsp;
<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"));
+18 -11
View File
@@ -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
}
+24 -19
View File
@@ -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>
</>
}
)}
/>
</>
);
+6 -1
View File
@@ -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();
}
+6 -1
View File
@@ -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}
+25 -2
View File
@@ -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}
+14 -1
View File
@@ -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
+1 -1
View File
@@ -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) => {
+6 -33
View File
@@ -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>
+9
View File
@@ -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 />,
+77
View File
@@ -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 /> &middot;{" "}
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
{apiKey.userId === user.id
? ""
: t(`by {{ name }}`, { name: user.name })}{" "}
&middot;{" "}
</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
View File
@@ -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>
);
}
}
+8 -1
View File
@@ -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);
}
}
}
}
+6 -1
View File
@@ -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", () => {
+2 -2
View File
@@ -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;
}
+49 -11
View File
@@ -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
);
}
+2 -2
View File
@@ -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
View File
@@ -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,
+2
View File
@@ -21,9 +21,11 @@ export default class Comment extends Mark {
userId: {},
resolved: {
default: false,
validate: "boolean",
},
draft: {
default: false,
validate: "boolean",
},
},
inclusive: false,
+3 -2
View File
@@ -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 }) {
+3 -23
View File
@@ -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),
];
}
+2
View File
@@ -56,9 +56,11 @@ export default class Link extends Mark {
attrs: {
href: {
default: "",
validate: "string",
},
title: {
default: null,
validate: "string|null",
},
},
inclusive: false,
+2 -2
View File
@@ -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() {
+2 -2
View File
@@ -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 }) {
+1 -1
View File
@@ -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
+12 -8
View File
@@ -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"]),
};
+4 -1
View File
@@ -36,7 +36,10 @@ export default class Video extends Node {
height: {
default: null,
},
title: {},
title: {
default: null,
validate: "string|null",
},
},
group: "block",
selectable: true,
+5 -3
View File
@@ -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",
"Couldnt copy the document, try again?": "Couldnt 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.",
+5 -3
View File
@@ -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",
"Couldnt copy the document, try again?": "Couldnt 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.",
+5 -3
View File
@@ -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",
"Couldnt copy the document, try again?": "Couldnt 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.",
+9 -4
View File
@@ -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