Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3a1cdde00 | |||
| bf68a1d2bf | |||
| e1b29bd854 | |||
| d07453d108 | |||
| c40ccd32f5 | |||
| 3ab3117e11 | |||
| 7d69198c91 | |||
| d29089c2ae | |||
| b39f231927 | |||
| f57a189077 | |||
| fc469ef9c2 | |||
| 24c01b1a9a | |||
| f1bc5f6216 | |||
| f3fe7283f8 | |||
| 839bf5cb91 | |||
| 6fa98ffe3a | |||
| a35d84976e | |||
| 19f9245e17 | |||
| 1da18c3101 | |||
| be194558bf | |||
| b945ac8999 | |||
| 6ec557cd20 | |||
| 866d30638e | |||
| 75df8fc18b | |||
| a44a612387 | |||
| 97fc848044 | |||
| ec0e7aaba4 | |||
| 5337770adb | |||
| b1b7b2b6fc | |||
| 1dcb8f8052 | |||
| 569c4b4849 | |||
| 5d5bed8270 | |||
| 58a41a6fde | |||
| 0bde1d5ef4 | |||
| 4a01fb7094 |
@@ -27,8 +27,8 @@ export const createApiKey = createAction({
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu
|
||||
? apiKey.isExpired
|
||||
? t("Delete")
|
||||
: `${t("Revoke")}…`
|
||||
|
||||
@@ -81,8 +81,7 @@ export const createCollection = createAction({
|
||||
});
|
||||
|
||||
export const editCollection = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}…` : t("Edit collection")),
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
@@ -107,8 +106,8 @@ export const editCollection = createActionV2({
|
||||
});
|
||||
|
||||
export const editCollectionPermissions = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
|
||||
@@ -384,8 +384,8 @@ export const subscribeDocument = createActionV2({
|
||||
analyticsName: "Subscribe to document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -393,8 +393,8 @@ export const subscribeDocument = createActionV2({
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
disabled: ({ activeCollectionId, isMenu, stores }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -430,8 +430,8 @@ export const unsubscribeDocument = createActionV2({
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -439,8 +439,8 @@ export const unsubscribeDocument = createActionV2({
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
disabled: ({ activeCollectionId, isMenu, stores }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -571,8 +571,7 @@ export const downloadDocumentAsMarkdown = createActionV2({
|
||||
});
|
||||
|
||||
export const downloadDocument = createActionV2WithChildren({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
@@ -678,8 +677,7 @@ export const copyDocument = createActionV2WithChildren({
|
||||
});
|
||||
|
||||
export const duplicateDocument = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
|
||||
analyticsName: "Duplicate document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DuplicateIcon />,
|
||||
@@ -829,8 +827,7 @@ export const searchInDocument = createInternalLinkActionV2({
|
||||
});
|
||||
|
||||
export const printDocument = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
|
||||
analyticsName: "Print document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
|
||||
@@ -131,8 +131,8 @@ export const navigateToTemplateSettings = createAction({
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createInternalLinkActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Notification settings") : t("Notifications"),
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? t("Notification settings") : t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
|
||||
@@ -37,8 +37,7 @@ export const changeToSystemTheme = createActionV2({
|
||||
});
|
||||
|
||||
export const changeTheme = createActionV2WithChildren({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: ({ stores }) =>
|
||||
|
||||
@@ -3,12 +3,8 @@ import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction, performActionV2, resolve } from "~/actions";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
} from "~/types";
|
||||
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
@@ -17,8 +13,6 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
hideOnActionDisabled?: boolean;
|
||||
/** Action to use on button */
|
||||
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
|
||||
/** Context of action, must be provided with action */
|
||||
context?: ActionContext;
|
||||
/** If tooltip props are provided the button will be wrapped in a tooltip */
|
||||
tooltip?: Omit<TooltipProps, "children">;
|
||||
};
|
||||
@@ -28,22 +22,20 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
*/
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function _ActionButton(
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const actionContext = useActionContext({
|
||||
isButton: true,
|
||||
});
|
||||
const isMounted = useIsMounted();
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (action && !context) {
|
||||
throw new Error("Context must be provided with action");
|
||||
}
|
||||
if (!context || !action) {
|
||||
if (!actionContext || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
const actionContext = { ...context, isButton: true };
|
||||
|
||||
if (
|
||||
action.visible &&
|
||||
!resolve<boolean>(action.visible, actionContext) &&
|
||||
@@ -53,9 +45,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
}
|
||||
|
||||
const label =
|
||||
typeof action.name === "function"
|
||||
rest["aria-label"] ??
|
||||
(typeof action.name === "function"
|
||||
? action.name(actionContext)
|
||||
: action.name;
|
||||
: action.name);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
|
||||
@@ -6,7 +6,6 @@ import Flex from "~/components/Flex";
|
||||
export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 0 0 12px;
|
||||
height: 32px;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
@@ -18,7 +17,6 @@ export const Action = styled(Flex)`
|
||||
|
||||
export const Separator = styled.div`
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: ${s("divider")};
|
||||
@@ -33,6 +31,7 @@ const Actions = styled(Flex)`
|
||||
background: ${s("background")};
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
gap: 12px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
@@ -25,6 +25,8 @@ type Props = {
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Size of the avatar, defaults to AvatarSize.Large */
|
||||
size?: AvatarSize;
|
||||
/** Optional alt text for the avatar image */
|
||||
alt?: string;
|
||||
/** Optional inline styles to apply to the avatar wrapper */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
@@ -53,6 +55,7 @@ function AvatarWithPresence({
|
||||
isCurrentUser,
|
||||
size = AvatarSize.Large,
|
||||
style,
|
||||
alt,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const status = isPresent
|
||||
@@ -83,7 +86,7 @@ function AvatarWithPresence({
|
||||
$color={user.color}
|
||||
style={style}
|
||||
>
|
||||
<Avatar model={user} onClick={onClick} size={size} />
|
||||
<Avatar model={user} onClick={onClick} size={size} alt={alt} />
|
||||
</AvatarPresence>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
@@ -25,7 +25,7 @@ function Breadcrumb(
|
||||
{ actions, highlightFirstItem, children, max = 2 }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const actionContext = useActionContext({ isContextMenu: true });
|
||||
const actionContext = useActionContext({ isMenu: true });
|
||||
|
||||
const visibleActions = useComputed(
|
||||
() =>
|
||||
|
||||
@@ -132,6 +132,7 @@ function Collaborators(props: Props) {
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
alt={t("Avatar of {{ name }}", { name: collaborator.name })}
|
||||
onClick={
|
||||
isObservable
|
||||
? handleAvatarClick(
|
||||
|
||||
@@ -143,13 +143,14 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
},
|
||||
[]
|
||||
);
|
||||
const contentEditable = !disabled && !readOnly;
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
|
||||
{children}
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
contentEditable={contentEditable}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onFocus={wrappedEvent(onFocus)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
@@ -157,7 +158,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
role={contentEditable ? "textbox" : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
@@ -8,10 +9,16 @@ type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({ className, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton className={className} {...props}>
|
||||
<NudeButton
|
||||
className={className}
|
||||
aria-label={t("More options")}
|
||||
{...props}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
|
||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isContextMenu: true,
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const templateItems = actions
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -94,98 +94,99 @@ function DocumentListItem(
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
const actionContext = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId ? document.collectionId : undefined,
|
||||
});
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ document });
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
context={actionContext}
|
||||
ariaLabel={t("Document options")}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
ariaLabel={t("Document options")}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</ContextMenu>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</ContextMenu>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,7 +280,7 @@ const DocumentLink = styled(Link)<{
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
const Heading = styled.span<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
@@ -289,6 +290,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
color: ${s("text")};
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
|
||||
@@ -168,13 +168,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Container
|
||||
align="center"
|
||||
rtl={document.dir === "rtl"}
|
||||
{...rest}
|
||||
dir="ltr"
|
||||
lang=""
|
||||
>
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{to ? (
|
||||
<Link to={to} replace={replace}>
|
||||
{content}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, truncateMultiline } from "@shared/styles";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
@@ -128,17 +128,21 @@ function EditableTitle(
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span
|
||||
<Text
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
className={rest.className}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Text = styled.span`
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
color: ${s("text")};
|
||||
background: ${s("background")};
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import Text from "~/components/Text";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import PaginatedList, { PaginatedItem } from "./PaginatedList";
|
||||
import { MenuProvider } from "./primitives/Menu/MenuContext";
|
||||
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
|
||||
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
key: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -34,19 +31,17 @@ type Props = {
|
||||
const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys = [],
|
||||
defaultLabel = "Filter options",
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const menu = useMenuState({
|
||||
modal: false,
|
||||
});
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selectedItems = options.filter((option) =>
|
||||
selectedKeys.includes(option.key)
|
||||
);
|
||||
@@ -58,32 +53,26 @@ const FilterOptions = ({
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option) => (
|
||||
<MenuItem
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
setOpen(false);
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
/>
|
||||
),
|
||||
[menu, onSelect, selectedKeys]
|
||||
[onSelect, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
};
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const normalizedQuery = deburr(query.toLowerCase());
|
||||
@@ -121,13 +110,13 @@ const FilterOptions = ({
|
||||
|
||||
switch (ev.key) {
|
||||
case "Escape":
|
||||
menu.hide();
|
||||
setOpen(false);
|
||||
break;
|
||||
case "Enter":
|
||||
if (filteredOptions.length === 1) {
|
||||
ev.preventDefault();
|
||||
onSelect(filteredOptions[0].key);
|
||||
menu.hide();
|
||||
setOpen(false);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
@@ -138,7 +127,7 @@ const FilterOptions = ({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filteredOptions, menu, onSelect]
|
||||
[filteredOptions, onSelect]
|
||||
);
|
||||
|
||||
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
|
||||
@@ -150,21 +139,21 @@ const FilterOptions = ({
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (menu.visible) {
|
||||
if (open) {
|
||||
searchInputRef.current?.focus();
|
||||
} else {
|
||||
setQuery("");
|
||||
}
|
||||
}, [menu.visible]);
|
||||
}, [open]);
|
||||
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
const defaultLabel = rest.defaultLabel || t("Filter options");
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={setOpen}>
|
||||
<MenuTrigger>
|
||||
<StyledButton
|
||||
{...props}
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
neutral
|
||||
@@ -172,31 +161,31 @@ const FilterOptions = ({
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
autoFocus
|
||||
</MenuTrigger>
|
||||
<MenuContent aria-label={defaultLabel} align="start">
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -242,24 +231,6 @@ const SearchInput = styled(Input)`
|
||||
}
|
||||
`;
|
||||
|
||||
const Note = styled(Text)`
|
||||
display: block;
|
||||
margin: 2px 0;
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
|
||||
&:hover ${Note} {
|
||||
color: ${(props) => props.theme.white50};
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
|
||||
@@ -125,6 +125,7 @@ const Actions = styled(Flex)`
|
||||
flex-basis: 0;
|
||||
min-width: auto;
|
||||
padding-left: 8px;
|
||||
gap: 12px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
position: unset;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ArrowIcon as ArrowRightIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
|
||||
export { ArrowIcon as ArrowRightIcon } from "outline-icons";
|
||||
|
||||
export const ArrowUpIcon = styled(ArrowRightIcon)`
|
||||
transform: rotate(-90deg);
|
||||
`;
|
||||
|
||||
export const ArrowDownIcon = styled(ArrowRightIcon)`
|
||||
transform: rotate(90deg);
|
||||
`;
|
||||
|
||||
export const ArrowLeftIcon = styled(ArrowRightIcon)`
|
||||
transform: rotate(180deg);
|
||||
`;
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CloseIcon,
|
||||
CrossIcon,
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
NextIcon,
|
||||
} from "outline-icons";
|
||||
import { depths, extraArea, s } from "@shared/styles";
|
||||
@@ -25,6 +26,9 @@ import Tooltip from "~/components/Tooltip";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
import Fade from "./Fade";
|
||||
import Button from "./Button";
|
||||
import CopyToClipboard from "./CopyToClipboard";
|
||||
import { Separator } from "./Actions";
|
||||
import useSwipe from "~/hooks/useSwipe";
|
||||
|
||||
export enum LightboxStatus {
|
||||
READY_TO_OPEN,
|
||||
@@ -287,7 +291,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
// in editor
|
||||
const editorImageEl = imageElements[currentImageIndex];
|
||||
let to;
|
||||
if (editorImageEl) {
|
||||
if (editorImageEl?.isConnected) {
|
||||
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
|
||||
const {
|
||||
top: editorImgTop,
|
||||
@@ -439,6 +443,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
if (animation.current?.fadeIn) {
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
zoomIn: undefined,
|
||||
fadeIn: undefined,
|
||||
startTime: undefined,
|
||||
};
|
||||
setStatus({
|
||||
@@ -457,6 +463,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
|
||||
|
||||
return (
|
||||
<Dialog.Root open={!!activePos}>
|
||||
<Dialog.Portal>
|
||||
@@ -474,6 +482,18 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</Dialog.Description>
|
||||
</VisuallyHidden.Root>
|
||||
<Actions animation={animation.current}>
|
||||
<Tooltip content={t("Copy link")} placement="bottom">
|
||||
<CopyToClipboard text={imgRef.current?.src ?? ""}>
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
aria-label={t("Copy link")}
|
||||
size={32}
|
||||
icon={<LinkIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Download")} placement="bottom">
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
@@ -485,6 +505,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
<Separator />
|
||||
<Dialog.Close asChild>
|
||||
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
|
||||
<Button
|
||||
@@ -508,7 +529,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
)}
|
||||
<Image
|
||||
ref={imgRef}
|
||||
src={sanitizeUrl(currentImageNode.attrs.src) ?? ""}
|
||||
src={src}
|
||||
alt={currentImageNode.attrs.alt ?? ""}
|
||||
onLoading={() =>
|
||||
setStatus({
|
||||
@@ -530,7 +551,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
onSwipeRight={prev}
|
||||
onSwipeLeft={next}
|
||||
onSwipeUpOrDown={close}
|
||||
onSwipeUp={close}
|
||||
onSwipeDown={close}
|
||||
status={status}
|
||||
animation={animation.current}
|
||||
/>
|
||||
@@ -555,7 +577,8 @@ type ImageProps = {
|
||||
onError: () => void;
|
||||
onSwipeRight: () => void;
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeUpOrDown: () => void;
|
||||
onSwipeUp: () => void;
|
||||
onSwipeDown: () => void;
|
||||
status: Status;
|
||||
animation: Animation | null;
|
||||
};
|
||||
@@ -569,59 +592,21 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onError,
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
onSwipeUpOrDown,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
status,
|
||||
animation,
|
||||
}: ImageProps,
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const touchXStart = useRef<number>();
|
||||
const touchXEnd = useRef<number>();
|
||||
const touchYStart = useRef<number>();
|
||||
const touchYEnd = useRef<number>();
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
touchXStart.current = e.changedTouches[0].screenX;
|
||||
touchYStart.current = e.changedTouches[0].screenY;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
touchXEnd.current = e.changedTouches[0].screenX;
|
||||
touchYEnd.current = e.changedTouches[0].screenY;
|
||||
const dx = touchXEnd.current - (touchXStart.current ?? 0);
|
||||
const dy = touchYEnd.current - (touchYStart.current ?? 0);
|
||||
|
||||
const swipeRight = dx > 0 && Math.abs(dy) < Math.abs(dx);
|
||||
if (swipeRight) {
|
||||
return onSwipeRight();
|
||||
}
|
||||
|
||||
const swipeLeft = dx < 0 && Math.abs(dy) < Math.abs(dx);
|
||||
if (swipeLeft) {
|
||||
return onSwipeLeft();
|
||||
}
|
||||
|
||||
const swipeDown = dy > 0 && Math.abs(dy) > Math.abs(dx);
|
||||
const swipeUp = dy < 0 && Math.abs(dy) > Math.abs(dx);
|
||||
if (swipeUp || swipeDown) {
|
||||
return onSwipeUpOrDown();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
touchXStart.current = undefined;
|
||||
touchXEnd.current = undefined;
|
||||
touchYStart.current = undefined;
|
||||
touchYEnd.current = undefined;
|
||||
};
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
touchXStart.current = undefined;
|
||||
touchXEnd.current = undefined;
|
||||
touchYStart.current = undefined;
|
||||
touchYEnd.current = undefined;
|
||||
};
|
||||
const swipeHandlers = useSwipe({
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
});
|
||||
|
||||
const [hidden, setHidden] = useState(
|
||||
status.image === null || status.image === ImageStatus.LOADING
|
||||
@@ -640,7 +625,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
}, [status.image]);
|
||||
|
||||
return status.image === ImageStatus.ERROR ? (
|
||||
<StyledError animation={animation}>
|
||||
<StyledError animation={animation} {...swipeHandlers}>
|
||||
<CrossIcon size={16} /> {t("Image failed to load")}
|
||||
</StyledError>
|
||||
) : (
|
||||
@@ -653,10 +638,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
alt={alt}
|
||||
animation={animation}
|
||||
onAnimationStart={() => setHidden(false)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchCancel}
|
||||
{...swipeHandlers}
|
||||
onError={onError}
|
||||
onLoad={onLoad}
|
||||
$hidden={hidden}
|
||||
@@ -757,7 +739,8 @@ const Actions = styled.div<{
|
||||
right: 0;
|
||||
margin: 16px 12px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ActionContext, ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
@@ -12,8 +12,6 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Action context to use - new context will be created if not provided */
|
||||
context?: ActionContext;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** ARIA label for the menu */
|
||||
@@ -25,15 +23,12 @@ type Props = {
|
||||
};
|
||||
|
||||
export const ContextMenu = observer(
|
||||
({ action, children, ariaLabel, context, onOpen, onClose }: Props) => {
|
||||
({ action, children, ariaLabel, onOpen, onClose }: Props) => {
|
||||
const isMobile = useMobile();
|
||||
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
const actionContext = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
@@ -80,7 +75,7 @@ export const ContextMenu = observer(
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<MenuProvider variant={"context"}>
|
||||
<MenuProvider variant="context">
|
||||
<Menu onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
|
||||
<MenuContent
|
||||
|
||||
@@ -14,7 +14,6 @@ import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import {
|
||||
ActionContext,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
MenuItem,
|
||||
@@ -27,8 +26,6 @@ import { useComputed } from "~/hooks/useComputed";
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Action context to use - new context will be created if not provided */
|
||||
context?: ActionContext;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** Alignment w.r.t trigger - defaults to start */
|
||||
@@ -49,7 +46,6 @@ export const DropdownMenu = observer(
|
||||
(
|
||||
{
|
||||
action,
|
||||
context,
|
||||
children,
|
||||
align = "start",
|
||||
ariaLabel,
|
||||
@@ -64,12 +60,9 @@ export const DropdownMenu = observer(
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
const actionContext = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
@@ -126,7 +119,7 @@ export const DropdownMenu = observer(
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<MenuProvider variant={"dropdown"}>
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
|
||||
@@ -6,7 +6,6 @@ import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
@@ -32,7 +31,6 @@ function Notifications(
|
||||
{ onRequestClose }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const context = useActionContext();
|
||||
const { notifications } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isEmpty = notifications.active.length === 0;
|
||||
@@ -67,7 +65,10 @@ function Notifications(
|
||||
<Flex gap={8}>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button action={markNotificationsAsRead} context={context}>
|
||||
<Button
|
||||
action={markNotificationsAsRead}
|
||||
aria-label={t("Mark all as read")}
|
||||
>
|
||||
<MarkAsReadIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -63,6 +63,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
alt={t("OAuth client icon")}
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import "../stores";
|
||||
import { render } from "@testing-library/react";
|
||||
import { TFunction } from "i18next";
|
||||
import { Provider } from "mobx-react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const i18n = getI18n();
|
||||
const authStore = {};
|
||||
|
||||
const props = {
|
||||
i18n,
|
||||
tReady: true,
|
||||
t: ((key: string) => key) as TFunction,
|
||||
} as any;
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const result = render(
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
</Provider>
|
||||
);
|
||||
expect(result.container.innerHTML).toEqual("");
|
||||
});
|
||||
|
||||
it("with no items renders empty prop", async () => {
|
||||
const result = render(
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
await expect(
|
||||
result.findAllByText("Sorry, no results")
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("calls fetch with options + pagination on mount", () => {
|
||||
const fetch = jest.fn();
|
||||
const options = {
|
||||
id: "one",
|
||||
};
|
||||
render(
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
...options,
|
||||
limit: Pagination.defaultLimit,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -255,6 +255,7 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
<React.Fragment>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
role={rest.role}
|
||||
aria-label={rest["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={className}
|
||||
|
||||
@@ -168,6 +168,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
||||
<PopoverAnchor>
|
||||
<StyledInputSearch
|
||||
role="combobox"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
@@ -176,6 +177,8 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
onFocus={handleSearchInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
label={t("Search")}
|
||||
labelHidden
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
@@ -194,6 +197,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
}}
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
|
||||
@@ -25,6 +25,11 @@ export const AppearanceAction = observer(() => {
|
||||
onClick={() =>
|
||||
ui.setTheme(resolvedTheme === "light" ? Theme.Dark : Theme.Light)
|
||||
}
|
||||
aria-label={
|
||||
resolvedTheme === "light"
|
||||
? t("Switch to dark")
|
||||
: t("Switch to light")
|
||||
}
|
||||
neutral
|
||||
borderOnHover
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Inner } from "~/components/Button";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import Fade from "~/components/Fade";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { Action, Permission } from "~/types";
|
||||
|
||||
export function PermissionAction({
|
||||
@@ -21,7 +20,6 @@ export function PermissionAction({
|
||||
onChange: (permission: CollectionPermission | DocumentPermission) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Fade timing="150ms" key="invite">
|
||||
@@ -31,9 +29,7 @@ export function PermissionAction({
|
||||
onChange={onChange}
|
||||
value={permission}
|
||||
/>
|
||||
<ButtonSmall action={action} context={context}>
|
||||
{t("Add")}
|
||||
</ButtonSmall>
|
||||
<ButtonSmall action={action}>{t("Add")}</ButtonSmall>
|
||||
</Flex>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
@@ -81,6 +81,11 @@ function AppSidebar() {
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
|
||||
@@ -52,6 +52,9 @@ function SettingsSidebar() {
|
||||
>
|
||||
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
|
||||
<ToggleButton
|
||||
aria-label={
|
||||
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
|
||||
}
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
|
||||
@@ -96,6 +96,9 @@ const ToggleSidebar = () => {
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
|
||||
}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
|
||||
@@ -21,6 +21,7 @@ import { TooltipProvider } from "../TooltipContext";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
|
||||
@@ -35,6 +36,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const location = useLocation();
|
||||
@@ -237,7 +239,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
position="bottom"
|
||||
image={
|
||||
<Avatar
|
||||
alt={user.name}
|
||||
alt={t("Avatar of {{ name }}", { name: user.name })}
|
||||
model={user}
|
||||
size={24}
|
||||
style={{ marginLeft: 4 }}
|
||||
@@ -245,7 +247,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
<SidebarButton position="bottom" image={<NotificationIcon />} />
|
||||
<SidebarButton
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
aria-label={t("Notifications")}
|
||||
/>
|
||||
</NotificationsPopover>
|
||||
</SidebarButton>
|
||||
</AccountMenu>
|
||||
|
||||
@@ -150,6 +150,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
{can.createDocument && (
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
|
||||
@@ -364,7 +364,6 @@ function InnerDocumentLink(
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
@@ -61,7 +62,12 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
$isDragActive={isDragActive}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<VisuallyHidden>
|
||||
<label>
|
||||
{t("Import files")}
|
||||
<input {...getInputProps()} />
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
{isImporting && <LoadingIndicator />}
|
||||
{children}
|
||||
</DropzoneContainer>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { extraArea, s } from "@shared/styles";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
@@ -71,17 +71,18 @@ const Button = styled.button`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
color: ${s("textTertiary")};
|
||||
color: ${s("sidebarText")};
|
||||
position: relative;
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0;
|
||||
padding: 4px 2px 4px 12px;
|
||||
height: 22px;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
-webkit-appearance: none;
|
||||
transition: all 100ms ease;
|
||||
${undraggableOnDesktop()}
|
||||
${extraArea(4)}
|
||||
|
||||
&:not(:disabled):hover,
|
||||
&:not(:disabled):active {
|
||||
@@ -102,7 +103,8 @@ const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
|
||||
const H3 = styled.h3`
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
|
||||
function SidebarAction({ action, ...rest }: Props) {
|
||||
const context = useActionContext({
|
||||
isContextMenu: false,
|
||||
isMenu: false,
|
||||
isCommandBar: false,
|
||||
activeCollectionId: undefined,
|
||||
activeDocumentId: undefined,
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s, truncateMultiline } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import { isMobile } from "@shared/utils/browser";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||
@@ -273,7 +273,6 @@ const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
line-height: 24px;
|
||||
${truncateMultiline(3)}
|
||||
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
unstarCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
@@ -27,10 +27,6 @@ type Props = {
|
||||
function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
activeCollectionId: collection?.id,
|
||||
});
|
||||
|
||||
const target = document || collection;
|
||||
|
||||
@@ -39,37 +35,43 @@ function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NudeButton
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
tooltip={{
|
||||
content: target.isStarred ? t("Unstar document") : t("Star document"),
|
||||
delay: 500,
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document?.id,
|
||||
activeCollectionId: collection?.id,
|
||||
}}
|
||||
action={
|
||||
collection
|
||||
? collection.isStarred
|
||||
? unstarCollection
|
||||
: starCollection
|
||||
: document
|
||||
? document.isStarred
|
||||
? unstarDocument
|
||||
: starDocument
|
||||
: undefined
|
||||
}
|
||||
size={size}
|
||||
{...rest}
|
||||
>
|
||||
{target.isStarred ? (
|
||||
<AnimatedStar size={size} color={theme.yellow} />
|
||||
) : (
|
||||
<AnimatedStar
|
||||
size={size}
|
||||
color={color ?? theme.textTertiary}
|
||||
as={UnstarredIcon}
|
||||
/>
|
||||
)}
|
||||
</NudeButton>
|
||||
<NudeButton
|
||||
hideOnActionDisabled
|
||||
tooltip={{
|
||||
content: target.isStarred ? t("Unstar document") : t("Star document"),
|
||||
delay: 500,
|
||||
}}
|
||||
action={
|
||||
collection
|
||||
? collection.isStarred
|
||||
? unstarCollection
|
||||
: starCollection
|
||||
: document
|
||||
? document.isStarred
|
||||
? unstarDocument
|
||||
: starDocument
|
||||
: undefined
|
||||
}
|
||||
size={size}
|
||||
{...rest}
|
||||
>
|
||||
{target.isStarred ? (
|
||||
<AnimatedStar size={size} color={theme.yellow} />
|
||||
) : (
|
||||
<AnimatedStar
|
||||
size={size}
|
||||
color={color ?? theme.textTertiary}
|
||||
as={UnstarredIcon}
|
||||
/>
|
||||
)}
|
||||
</NudeButton>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@ export default function FindAndReplace({
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.prevSearchMatch()}
|
||||
aria-label={t("Previous match")}
|
||||
>
|
||||
<CaretUpIcon />
|
||||
</ButtonLarge>
|
||||
@@ -355,6 +356,7 @@ export default function FindAndReplace({
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
aria-label={t("Next match")}
|
||||
>
|
||||
<CaretDownIcon />
|
||||
</ButtonLarge>
|
||||
@@ -390,7 +392,10 @@ export default function FindAndReplace({
|
||||
shortcut={`${altDisplay}+${metaDisplay}+c`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleCaseSensitive}>
|
||||
<ButtonSmall
|
||||
onClick={handleCaseSensitive}
|
||||
aria-label={t("Match case")}
|
||||
>
|
||||
<CaseSensitiveIcon
|
||||
color={caseSensitive ? theme.accent : theme.textSecondary}
|
||||
/>
|
||||
@@ -401,7 +406,10 @@ export default function FindAndReplace({
|
||||
shortcut={`${altDisplay}+${metaDisplay}+r`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleRegex}>
|
||||
<ButtonSmall
|
||||
onClick={handleRegex}
|
||||
aria-label={t("Enable regex")}
|
||||
>
|
||||
<RegexIcon
|
||||
color={regexEnabled ? theme.accent : theme.textSecondary}
|
||||
/>
|
||||
@@ -416,7 +424,10 @@ export default function FindAndReplace({
|
||||
shortcut={`${altDisplay}+${metaDisplay}+f`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ButtonLarge
|
||||
onClick={handleMore}
|
||||
aria-label={t("Replace options")}
|
||||
>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { extraArea } from "@shared/styles";
|
||||
import Input, { NativeInput, Outline } from "~/components/Input";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Dimension = {
|
||||
width: string;
|
||||
@@ -20,6 +21,7 @@ export function MediaDimension() {
|
||||
width: { min: number; max: number };
|
||||
height: { min: number; max: number };
|
||||
}>();
|
||||
const { t } = useTranslation();
|
||||
const { view, commands } = useEditor();
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
@@ -31,8 +33,8 @@ export function MediaDimension() {
|
||||
height = node.attrs.height as number;
|
||||
|
||||
const [localDimension, setLocalDimension] = useState<Dimension>(() => ({
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
width: width ? String(width) : "",
|
||||
height: height ? String(height) : "",
|
||||
changed: "none",
|
||||
}));
|
||||
const [error, setError] = useState<{ width: boolean; height: boolean }>({
|
||||
@@ -57,8 +59,8 @@ export function MediaDimension() {
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setLocalDimension({
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
width: width ? String(width) : "",
|
||||
height: height ? String(height) : "",
|
||||
changed: "none",
|
||||
});
|
||||
setError({ width: false, height: false });
|
||||
@@ -205,6 +207,9 @@ export function MediaDimension() {
|
||||
return (
|
||||
<StyledFlex ref={ref} align="center">
|
||||
<StyledInput
|
||||
label={t("Image width")}
|
||||
labelHidden
|
||||
placeholder={t("Width")}
|
||||
value={localDimension.width}
|
||||
onChange={handleChange("width")}
|
||||
onBlur={handleBlur}
|
||||
@@ -212,9 +217,12 @@ export function MediaDimension() {
|
||||
$error={error.width}
|
||||
/>
|
||||
<Text size="xsmall" type="tertiary">
|
||||
x
|
||||
×
|
||||
</Text>
|
||||
<StyledInput
|
||||
label={t("Image height")}
|
||||
labelHidden
|
||||
placeholder={t("Height")}
|
||||
value={localDimension.height}
|
||||
onChange={handleChange("height")}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -64,7 +64,11 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(buttonProps) => (
|
||||
<ToolbarButton {...buttonProps} hovering={menu.visible}>
|
||||
<ToolbarButton
|
||||
{...buttonProps}
|
||||
hovering={menu.visible}
|
||||
aria-label={item.tooltip}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
@@ -118,6 +122,7 @@ function ToolbarMenu(props: Props) {
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
aria-label={item.label ? undefined : item.tooltip}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
|
||||
@@ -291,6 +291,12 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
const from = type === "inline" ? pos + i : pos;
|
||||
const to = from + (type === "inline" ? m[0].length : node.nodeSize);
|
||||
|
||||
// Prevent wrap around matches when the regex matches at the end of the deburred
|
||||
// string and continues matching at the start of the original string
|
||||
if (i + this.searchTerm.length > text.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
if (this.results.some((r) => r.from === from && r.to === to)) {
|
||||
|
||||
@@ -459,6 +459,7 @@ export default class PasteHandler extends Extension {
|
||||
const { view, schema } = this.editor;
|
||||
const { state } = view;
|
||||
const { from } = state.selection;
|
||||
let tr = state.tr;
|
||||
|
||||
const links: string[] = [];
|
||||
let allLinks = true;
|
||||
@@ -480,22 +481,26 @@ export default class PasteHandler extends Extension {
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!allLinks || !links.length) {
|
||||
return;
|
||||
}
|
||||
const showPasteMenu = allLinks && links.length;
|
||||
|
||||
const placeholderId = links[0];
|
||||
const to = from + listNode.nodeSize;
|
||||
// it's possible that the links can be converted to mentions
|
||||
if (showPasteMenu) {
|
||||
const placeholderId = links[0];
|
||||
const to = from + listNode.nodeSize;
|
||||
|
||||
const transaction = state.tr
|
||||
.replaceSelectionWith(listNode)
|
||||
.setMeta(this.key, {
|
||||
tr = state.tr.replaceSelectionWith(listNode).setMeta(this.key, {
|
||||
add: { from, to, id: placeholderId },
|
||||
});
|
||||
} else {
|
||||
// Paste as simple list
|
||||
tr = tr.replaceSelectionWith(listNode, this.shiftKey);
|
||||
}
|
||||
|
||||
view.dispatch(transaction);
|
||||
view.dispatch(tr);
|
||||
|
||||
this.showPasteMenu(links);
|
||||
if (showPasteMenu) {
|
||||
this.showPasteMenu(links);
|
||||
}
|
||||
}
|
||||
|
||||
private placeholderId = () =>
|
||||
|
||||
@@ -498,6 +498,7 @@ export class Editor extends React.PureComponent<
|
||||
|
||||
// Tell third-party libraries and screen-readers that this is an input
|
||||
view.dom.setAttribute("role", "textbox");
|
||||
view.dom.setAttribute("aria-label", "Editor content");
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@ import {
|
||||
AlignCenterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
ArrowIcon,
|
||||
MoreIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
TableSplitCellsIcon,
|
||||
AlphabeticalSortIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import styled from "styled-components";
|
||||
import { CellSelection, selectedRect } from "prosemirror-tables";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import {
|
||||
isMergedCellSelection,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
export default function tableColMenuItems(
|
||||
state: EditorState,
|
||||
@@ -34,6 +35,8 @@ export default function tableColMenuItems(
|
||||
return [];
|
||||
}
|
||||
|
||||
const tableMap = selectedRect(state);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
@@ -75,13 +78,13 @@ export default function tableColMenuItems(
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortAsc,
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscIcon />,
|
||||
icon: <AlphabeticalSortIcon />,
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortDesc,
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescIcon />,
|
||||
icon: <AlphabeticalReverseSortIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
@@ -107,6 +110,23 @@ export default function tableColMenuItems(
|
||||
icon: <InsertRightIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: dictionary.moveColumnLeft,
|
||||
icon: <ArrowLeftIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: dictionary.moveColumnRight,
|
||||
icon: <ArrowRightIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.width - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: dictionary.mergeCells,
|
||||
@@ -132,11 +152,3 @@ export default function tableColMenuItems(
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SortAscIcon = styled(ArrowIcon)`
|
||||
transform: rotate(-90deg);
|
||||
`;
|
||||
|
||||
const SortDescIcon = styled(ArrowIcon)`
|
||||
transform: rotate(90deg);
|
||||
`;
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
TableMergeCellsIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import { CellSelection, selectedRect } from "prosemirror-tables";
|
||||
import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
export default function tableRowMenuItems(
|
||||
state: EditorState,
|
||||
@@ -22,10 +23,13 @@ export default function tableRowMenuItems(
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { selection } = state;
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tableMap = selectedRect(state);
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
@@ -48,6 +52,23 @@ export default function tableRowMenuItems(
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: dictionary.moveRowUp,
|
||||
icon: <ArrowUpIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: dictionary.moveRowDown,
|
||||
icon: <ArrowDownIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.height - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: dictionary.mergeCells,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActionContext } from "~/types";
|
||||
|
||||
/**
|
||||
* Hook to get the current action context, an object that is passed to all
|
||||
* action definitions.
|
||||
*
|
||||
* @param overrides Overides of the default action context.
|
||||
* @returns The current action context.
|
||||
*/
|
||||
export default function useActionContext(
|
||||
overrides?: Partial<ActionContext>
|
||||
): ActionContext {
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
return {
|
||||
isContextMenu: false,
|
||||
isCommandBar: false,
|
||||
isButton: false,
|
||||
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
|
||||
activeDocumentId: stores.ui.activeDocumentId,
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
...overrides,
|
||||
location,
|
||||
stores,
|
||||
t,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React, { createContext, useContext, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActionContext as ActionContextType } from "~/types";
|
||||
|
||||
export const ActionContext = createContext<ActionContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
type ActionContextProviderProps = {
|
||||
children: ReactNode;
|
||||
value?: Partial<ActionContextType>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider that allows overriding the action context at different levels
|
||||
* of the React component tree.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Override context for a command bar
|
||||
* <ActionContextProvider value={{ isCommandBar: true }}>
|
||||
* <CommandBar />
|
||||
* </ActionContextProvider>
|
||||
*
|
||||
* // Nested overrides
|
||||
* <ActionContextProvider value={{ activeCollectionId: "collection-1" }}>
|
||||
* <CollectionView />
|
||||
* <ActionContextProvider value={{ activeDocumentId: "doc-1" }}>
|
||||
* <DocumentView />
|
||||
* </ActionContextProvider>
|
||||
* </ActionContextProvider>
|
||||
* ```
|
||||
*/
|
||||
export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
children,
|
||||
value = {},
|
||||
}: ActionContextProviderProps) {
|
||||
const parentContext = useContext(ActionContext);
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// Create the base context if we don't have a parent context
|
||||
const baseContext: ActionContextType = parentContext ?? {
|
||||
isMenu: false,
|
||||
isCommandBar: false,
|
||||
isButton: false,
|
||||
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
|
||||
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
location,
|
||||
stores,
|
||||
t,
|
||||
};
|
||||
|
||||
// Merge the parent context with the provided overrides
|
||||
const contextValue: ActionContextType = {
|
||||
...baseContext,
|
||||
...value,
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ActionContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hook to get the current action context, an object that is passed to all
|
||||
* action definitions.
|
||||
*
|
||||
* This hook respects the ActionContextProvider hierarchy, merging values from:
|
||||
* 1. Default system context (stores, location, translation)
|
||||
* 2. Parent ActionContextProvider values (if any)
|
||||
* 3. Local overrides parameter (highest priority)
|
||||
*
|
||||
* @param overrides Optional overrides of the action context. These will be
|
||||
* merged with any provider context and take highest priority.
|
||||
* @returns The current action context with all overrides applied.
|
||||
*/
|
||||
export default function useActionContext(
|
||||
overrides?: Partial<ActionContextType>
|
||||
): ActionContextType {
|
||||
const contextValue = useContext(ActionContext);
|
||||
|
||||
// If we have a context value from a provider, use it as the base
|
||||
if (contextValue) {
|
||||
return {
|
||||
...contextValue,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"useActionContext must be used within an ActionContextProvider"
|
||||
);
|
||||
}
|
||||
@@ -11,10 +11,14 @@ export default function useDictionary() {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
addColumnAfter: t("Add column after"),
|
||||
addColumnBefore: t("Add column before"),
|
||||
addRowAfter: t("Add row after"),
|
||||
addRowBefore: t("Add row before"),
|
||||
addColumnAfter: t("Insert after"),
|
||||
addColumnBefore: t("Insert before"),
|
||||
moveRowUp: t("Move up"),
|
||||
moveRowDown: t("Move down"),
|
||||
moveColumnLeft: t("Move left"),
|
||||
moveColumnRight: t("Move right"),
|
||||
addRowAfter: t("Insert after"),
|
||||
addRowBefore: t("Insert before"),
|
||||
alignCenter: t("Align center"),
|
||||
alignLeft: t("Align left"),
|
||||
alignRight: t("Align right"),
|
||||
@@ -35,7 +39,7 @@ export default function useDictionary() {
|
||||
deleteRow: t("Delete"),
|
||||
deleteTable: t("Delete table"),
|
||||
deleteAttachment: t("Delete file"),
|
||||
dimensions: t("Width x Height"),
|
||||
dimensions: `${t("Width")} × ${t("Height")}`,
|
||||
download: t("Download"),
|
||||
downloadAttachment: t("Download file"),
|
||||
replaceAttachment: t("Replace file"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import useStores from "./useStores";
|
||||
import { isFirefox } from "@shared/utils/browser";
|
||||
|
||||
type Params = {
|
||||
/** The share ID of the document being viewed, if any */
|
||||
@@ -78,6 +79,12 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
window.open(navigateTo, "_blank");
|
||||
}
|
||||
} else {
|
||||
// Middle-click events in Firefox are not prevented in the same way as other browsers
|
||||
// so we need to explicitly return here to prevent two tabs from being opened when
|
||||
// middle-clicking a link (#10083).
|
||||
if (event?.button === 1 && isFirefox()) {
|
||||
return;
|
||||
}
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { isNumber } from "lodash";
|
||||
import { useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
onSwipeRight: () => void;
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeUp: () => void;
|
||||
onSwipeDown: () => void;
|
||||
};
|
||||
|
||||
export default function useSwipe({
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
}: Props) {
|
||||
const touchXStart = useRef<number>();
|
||||
const touchXEnd = useRef<number>();
|
||||
const touchYStart = useRef<number>();
|
||||
const touchYEnd = useRef<number>();
|
||||
|
||||
const resetTouchPoints = () => {
|
||||
touchXStart.current = undefined;
|
||||
touchXEnd.current = undefined;
|
||||
touchYStart.current = undefined;
|
||||
touchYEnd.current = undefined;
|
||||
};
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
touchXStart.current = e.changedTouches[0].screenX;
|
||||
touchYStart.current = e.changedTouches[0].screenY;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
if (isNumber(touchXStart.current) && isNumber(touchYStart.current)) {
|
||||
touchXEnd.current = e.changedTouches[0].screenX;
|
||||
touchYEnd.current = e.changedTouches[0].screenY;
|
||||
const dx = touchXEnd.current - touchXStart.current;
|
||||
const dy = touchYEnd.current - touchYStart.current;
|
||||
|
||||
const swipeRight = dx > 0 && Math.abs(dy) < Math.abs(dx);
|
||||
if (swipeRight) {
|
||||
resetTouchPoints();
|
||||
return onSwipeRight();
|
||||
}
|
||||
|
||||
const swipeLeft = dx < 0 && Math.abs(dy) < Math.abs(dx);
|
||||
if (swipeLeft) {
|
||||
resetTouchPoints();
|
||||
return onSwipeLeft();
|
||||
}
|
||||
|
||||
const swipeDown = dy > 0 && Math.abs(dy) > Math.abs(dx);
|
||||
if (swipeDown) {
|
||||
resetTouchPoints();
|
||||
return onSwipeDown();
|
||||
}
|
||||
|
||||
const swipeUp = dy < 0 && Math.abs(dy) > Math.abs(dx);
|
||||
if (swipeUp) {
|
||||
resetTouchPoints();
|
||||
return onSwipeUp();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchCancel = () => {
|
||||
resetTouchPoints();
|
||||
};
|
||||
|
||||
return {
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchCancel,
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import Logger from "./utils/Logger";
|
||||
import { PluginManager } from "./utils/PluginManager";
|
||||
import history from "./utils/history";
|
||||
import { initSentry } from "./utils/sentry";
|
||||
import { ActionContextProvider } from "./hooks/useActionContext";
|
||||
|
||||
// Load plugins as soon as possible
|
||||
void PluginManager.loadPlugins();
|
||||
@@ -53,25 +54,27 @@ if (element) {
|
||||
<Provider {...stores}>
|
||||
<Analytics>
|
||||
<Theme>
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
<Router history={history}>
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<ActionContextProvider>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</ActionContextProvider>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
</Theme>
|
||||
</Analytics>
|
||||
</Provider>
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
createDocument,
|
||||
exportCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -130,11 +130,6 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const can = usePolicy(collection);
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeCollectionId: collection.id,
|
||||
});
|
||||
|
||||
const sortAlphabetical = collection.sort.field === "title";
|
||||
const sortDir = collection.sort.direction;
|
||||
|
||||
@@ -228,7 +223,7 @@ function CollectionMenu({
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
{t("Import document")}
|
||||
@@ -244,7 +239,6 @@ function CollectionMenu({
|
||||
</VisuallyHidden.Root>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
context={context}
|
||||
align={align}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
@@ -255,7 +249,7 @@ function CollectionMenu({
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import Document from "~/models/Document";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import Switch from "~/components/Switch";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -132,13 +132,6 @@ function DocumentMenu({
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId ? document.collectionId : undefined,
|
||||
});
|
||||
|
||||
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
|
||||
if (!can.update || !(showDisplayOptions || showToggleEmbeds)) {
|
||||
return;
|
||||
@@ -203,20 +196,29 @@ function DocumentMenu({
|
||||
]);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
context={context}
|
||||
align={align}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
ariaLabel={t("Document options")}
|
||||
append={toggleSwitches}
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<OverflowMenuButton
|
||||
neutral={neutral}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align={align}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
ariaLabel={t("Document options")}
|
||||
append={toggleSwitches}
|
||||
>
|
||||
<OverflowMenuButton
|
||||
neutral={neutral}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const NotificationMenu: React.FC = () => {
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Notifications")}>
|
||||
<Button>
|
||||
<Button aria-label={t("Notifications")}>
|
||||
<MoreIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
copyLinkToRevision,
|
||||
restoreRevision,
|
||||
} from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMemo } from "react";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -19,11 +19,6 @@ type Props = {
|
||||
|
||||
function RevisionMenu({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
() => [restoreRevision, ActionV2Separator, copyLinkToRevision],
|
||||
[]
|
||||
@@ -32,14 +27,15 @@ function RevisionMenu({ document }: Props) {
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
context={context}
|
||||
align="end"
|
||||
ariaLabel={t("Revision options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
<ActionContextProvider value={{ activeDocumentId: document.id }}>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Revision options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ type Props = {
|
||||
|
||||
const TeamMenu: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext({ isContextMenu: true });
|
||||
const context = useActionContext({ isMenu: true });
|
||||
|
||||
// NOTE: it's useful to memoize on the team id and session because the action
|
||||
// menu is not cached at all.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, observable } from "mobx";
|
||||
import GroupMembership from "./GroupMembership";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
|
||||
class Group extends Model {
|
||||
static modelName = "Group";
|
||||
@@ -25,6 +26,18 @@ class Group extends Model {
|
||||
return users.inGroup(this.id);
|
||||
}
|
||||
|
||||
@computed
|
||||
get admins() {
|
||||
const { groupUsers } = this.store.rootStore;
|
||||
return groupUsers.orderedData
|
||||
.filter(
|
||||
(groupUser) =>
|
||||
groupUser.groupId === this.id &&
|
||||
groupUser.permission === GroupPermission.Admin
|
||||
)
|
||||
.map((groupUser) => groupUser.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the direct memberships that this group has to documents. Documents that the current
|
||||
* user already has access to through a collection, archived, and trashed documents are not included.
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AvatarSize } from "~/components/Avatar";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -24,7 +23,6 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { memberships, groupMemberships, users } = useStores();
|
||||
const collectionUsers = users.inCollection(collection.id);
|
||||
const context = useActionContext();
|
||||
const isMobile = useMobile();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,7 +70,6 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
|
||||
return (
|
||||
<NudeButton
|
||||
context={context}
|
||||
tooltip={{
|
||||
content:
|
||||
usersCount > 0
|
||||
|
||||
@@ -24,7 +24,6 @@ import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { resolveCommentFactory } from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
@@ -312,14 +311,12 @@ const ResolveButton = ({
|
||||
comment: Comment;
|
||||
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||
}) => {
|
||||
const context = useActionContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Mark as resolved")} placement="top">
|
||||
<Action
|
||||
as={NudeButton}
|
||||
context={context}
|
||||
action={resolveCommentFactory({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowIcon } from "outline-icons";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
@@ -24,6 +23,7 @@ import CommentForm from "./CommentForm";
|
||||
import CommentSortMenu from "./CommentSortMenu";
|
||||
import CommentThread from "./CommentThread";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
import { ArrowDownIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
function Comments() {
|
||||
const { ui, comments, documents } = useStores();
|
||||
@@ -230,10 +230,6 @@ const JumpToRecent = styled(ButtonSmall)`
|
||||
}
|
||||
`;
|
||||
|
||||
const ArrowDownIcon = styled(ArrowIcon)`
|
||||
transform: rotate(90deg);
|
||||
`;
|
||||
|
||||
const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>`
|
||||
padding: 12px;
|
||||
padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")};
|
||||
|
||||
@@ -11,12 +11,12 @@ import Revision from "~/models/Revision";
|
||||
import { openDocumentInsights } from "~/actions/definitions/documents";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Fade from "~/components/Fade";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = {
|
||||
/* The document to display meta data for */
|
||||
@@ -36,9 +36,6 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].userId;
|
||||
const viewsLoadedOnMount = useRef(totalViewers > 0);
|
||||
const can = usePolicy(document);
|
||||
const actionContext = useActionContext({
|
||||
activeDocumentId: document.id,
|
||||
});
|
||||
|
||||
const Wrapper = viewsLoadedOnMount.current ? Fragment : Fade;
|
||||
|
||||
@@ -70,9 +67,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
!document.isTemplate ? (
|
||||
<Wrapper>
|
||||
•
|
||||
<InsightsButton
|
||||
onClick={() => openDocumentInsights.perform(actionContext)}
|
||||
>
|
||||
<InsightsButton action={openDocumentInsights}>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? t("only you")
|
||||
@@ -91,7 +86,7 @@ const CommentLink = styled(Link)`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const InsightsButton = styled.button`
|
||||
const InsightsButton = styled(NudeButton)`
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
@@ -70,6 +71,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
}: Props,
|
||||
externalRef: React.RefObject<RefHandle>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const ref = React.useRef<RefHandle>(null);
|
||||
const [iconPickerIsOpen, handleOpen, setIconPickerClosed] = useBoolean();
|
||||
const { editor } = useDocumentContext();
|
||||
@@ -249,6 +251,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
autoFocus={!title}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
readOnly={readOnly}
|
||||
aria-label={t("Document title")}
|
||||
dir="auto"
|
||||
ref={mergeRefs([ref, externalRef])}
|
||||
>
|
||||
|
||||
@@ -23,7 +23,6 @@ import Tooltip from "~/components/Tooltip";
|
||||
import { publishDocument } from "~/actions/definitions/documents";
|
||||
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
|
||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
@@ -109,10 +108,6 @@ function DocumentHeader({
|
||||
}
|
||||
}, [ui, isShare]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
});
|
||||
|
||||
const can = usePolicy(document);
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const isTemplateEditable = can.update && isTemplate;
|
||||
@@ -134,6 +129,7 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
aria-label={t("Show contents")}
|
||||
onClick={handleToggle}
|
||||
icon={<TableOfContentsIcon />}
|
||||
borderOnHover
|
||||
@@ -278,7 +274,6 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
context={context}
|
||||
action={isTemplate ? navigateToTemplateSettings : undefined}
|
||||
onClick={isTemplate ? undefined : handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
@@ -307,12 +302,7 @@ function DocumentHeader({
|
||||
{revision && revision.createdAt !== document.updatedAt && (
|
||||
<Action>
|
||||
<Tooltip content={t("Restore version")} placement="bottom">
|
||||
<Button
|
||||
action={restoreRevision}
|
||||
context={context}
|
||||
neutral
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<Button action={restoreRevision} neutral hideOnActionDisabled>
|
||||
{t("Restore")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -322,7 +312,6 @@ function DocumentHeader({
|
||||
<Action>
|
||||
<Button
|
||||
action={publishDocument}
|
||||
context={context}
|
||||
disabled={publishingIsDisabled}
|
||||
hideOnActionDisabled
|
||||
hideIcon
|
||||
|
||||
@@ -23,7 +23,11 @@ function KeyboardShortcutsButton() {
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
|
||||
<Button onClick={handleOpenKeyboardShortcuts} $hidden={isEditingFocus}>
|
||||
<Button
|
||||
onClick={handleOpenKeyboardShortcuts}
|
||||
$hidden={isEditingFocus}
|
||||
aria-label={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -6,12 +6,10 @@ import Empty from "~/components/Empty";
|
||||
import Heading from "~/components/Heading";
|
||||
import Scene from "~/components/Scene";
|
||||
import { navigateToHome } from "~/actions/definitions/navigation";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
const Error403 = () => {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene title={t("No access to this doc")}>
|
||||
@@ -24,7 +22,7 @@ const Error403 = () => {
|
||||
{t("Please request access from the document owner.")}
|
||||
</Empty>
|
||||
<Flex gap={8}>
|
||||
<Button action={navigateToHome} context={context} hideIcon>
|
||||
<Button action={navigateToHome} hideIcon>
|
||||
{t("Home")}
|
||||
</Button>
|
||||
<Button onClick={history.goBack} neutral>
|
||||
|
||||
@@ -8,11 +8,9 @@ import {
|
||||
navigateToHome,
|
||||
navigateToSearch,
|
||||
} from "~/actions/definitions/navigation";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
const Error404 = () => {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene title={t("Not found")}>
|
||||
@@ -25,10 +23,10 @@ const Error404 = () => {
|
||||
</Trans>
|
||||
</Empty>
|
||||
<Flex gap={8}>
|
||||
<Button action={navigateToHome} context={context} neutral hideIcon>
|
||||
<Button action={navigateToHome} neutral hideIcon>
|
||||
{t("Home")}
|
||||
</Button>
|
||||
<Button action={navigateToSearch} context={context} neutral>
|
||||
<Button action={navigateToSearch} neutral>
|
||||
{t("Search")}…
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -6,11 +6,9 @@ import Empty from "~/components/Empty";
|
||||
import Heading from "~/components/Heading";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import { navigateToHome } from "~/actions/definitions/navigation";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
const ErrorUnknown = () => {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
@@ -24,7 +22,7 @@ const ErrorUnknown = () => {
|
||||
</Trans>
|
||||
</Empty>
|
||||
<Flex gap={8}>
|
||||
<Button action={navigateToHome} context={context} neutral hideIcon>
|
||||
<Button action={navigateToHome} neutral hideIcon>
|
||||
{t("Home")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -33,7 +33,7 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
|
||||
"Setup your workspace by providing a name and details for admin login. You can change these later."
|
||||
)}
|
||||
</Content>
|
||||
<Flex column gap={12} style={{ width: "100%" }}>
|
||||
<Inputs column gap={12}>
|
||||
<Input
|
||||
name="teamName"
|
||||
type="text"
|
||||
@@ -57,7 +57,7 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
|
||||
required
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
</Inputs>
|
||||
<ButtonLarge type="submit" fullwidth>
|
||||
{t("Continue")} →
|
||||
</ButtonLarge>
|
||||
@@ -66,6 +66,11 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Inputs = styled(Flex)`
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled(Heading)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
@@ -11,7 +11,6 @@ import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import env from "~/env";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -25,7 +24,6 @@ function APIAndApps() {
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys, oauthAuthentications } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
@@ -40,7 +38,6 @@ function APIAndApps() {
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -20,7 +19,6 @@ function ApiKeys() {
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -34,7 +32,6 @@ function ApiKeys() {
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -161,6 +161,7 @@ const Application = observer(function Application({ oauthClient }: Props) {
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
alt={t("Application icon")}
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
|
||||
@@ -9,7 +9,6 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createOAuthClient } from "~/actions/definitions/oauthClients";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -20,7 +19,6 @@ function Applications() {
|
||||
const { t } = useTranslation();
|
||||
const { oauthClients } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -34,7 +32,6 @@ function Applications() {
|
||||
type="submit"
|
||||
value={`${t("New App")}…`}
|
||||
action={createOAuthClient}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -193,6 +193,7 @@ function Details() {
|
||||
)}
|
||||
>
|
||||
<ImageInput
|
||||
alt={t("Workspace logo")}
|
||||
onSuccess={handleAvatarChange}
|
||||
onError={handleAvatarError}
|
||||
model={team}
|
||||
|
||||
@@ -16,7 +16,6 @@ import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { inviteUser } from "~/actions/definitions/users";
|
||||
import env from "~/env";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
@@ -32,7 +31,6 @@ function Members() {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
const context = useActionContext();
|
||||
const { users } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const params = useQuery();
|
||||
@@ -128,7 +126,6 @@ function Members() {
|
||||
data-event-category="invite"
|
||||
data-event-action="peoplePage"
|
||||
action={inviteUser}
|
||||
context={context}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("Invite people")}…
|
||||
|
||||
@@ -72,6 +72,7 @@ const Profile = () => {
|
||||
description={t("Choose a photo or image to represent yourself.")}
|
||||
>
|
||||
<ImageInput
|
||||
alt={t("Profile picture")}
|
||||
onSuccess={handleAvatarChange}
|
||||
onError={handleAvatarError}
|
||||
model={user}
|
||||
|
||||
@@ -430,7 +430,7 @@ const GroupMemberListItem = observer(function ({
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("Manage"),
|
||||
label: t("Group admin"),
|
||||
value: GroupPermission.Admin,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -59,7 +59,7 @@ export function GroupsTable(props: Props) {
|
||||
<Title onClick={() => handleViewMembers(group)}>
|
||||
{group.name}
|
||||
</Title>
|
||||
<Text type="tertiary" size="small">
|
||||
<Text type="tertiary" size="small" weight="normal">
|
||||
<Trans
|
||||
defaults="{{ count }} member"
|
||||
values={{ count: group.memberCount }}
|
||||
@@ -97,6 +97,30 @@ export function GroupsTable(props: Props) {
|
||||
width: "1fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "admins",
|
||||
header: t("Admins"),
|
||||
accessor: (group) => `${group.memberCount} admins`,
|
||||
component: (group) => {
|
||||
const users = group.admins.slice(0, MAX_AVATAR_DISPLAY);
|
||||
|
||||
if (users.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupMembers
|
||||
onClick={() => handleViewMembers(group)}
|
||||
width={users.length * AvatarSize.Large}
|
||||
>
|
||||
<Facepile users={users} />
|
||||
</GroupMembers>
|
||||
);
|
||||
},
|
||||
width: "1fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
|
||||
@@ -10,9 +10,10 @@ import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload";
|
||||
|
||||
type Props = ImageUploadProps & {
|
||||
model: IAvatar;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
export default function ImageInput({ model, onSuccess, ...rest }: Props) {
|
||||
export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -27,6 +28,7 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
|
||||
model={model}
|
||||
size={AvatarSize.Upload}
|
||||
variant={AvatarVariant.Square}
|
||||
alt={alt}
|
||||
/>
|
||||
<Flex auto align="center" justify="center" className="upload">
|
||||
<EditIcon />
|
||||
|
||||
@@ -222,6 +222,8 @@ function SharedScene() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasSidebar = !!share.tree?.children.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -238,7 +240,10 @@ function SharedScene() {
|
||||
<TeamContext.Provider value={team}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<DocumentContextProvider>
|
||||
<Layout title={pageTitle} sidebar={<Sidebar share={share} />}>
|
||||
<Layout
|
||||
title={pageTitle}
|
||||
sidebar={hasSidebar ? <Sidebar share={share} /> : null}
|
||||
>
|
||||
{model instanceof Document ? (
|
||||
<DocumentScene
|
||||
document={model}
|
||||
|
||||
@@ -8,24 +8,19 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import { permanentlyDeleteDocumentsInTrash } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function Trash() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
icon={<TrashIcon />}
|
||||
title={t("Trash")}
|
||||
actions={
|
||||
documents.deleted.length > 0 && (
|
||||
<Button
|
||||
neutral
|
||||
action={permanentlyDeleteDocumentsInTrash}
|
||||
context={context}
|
||||
>
|
||||
<Button neutral action={permanentlyDeleteDocumentsInTrash}>
|
||||
{t("Empty trash")}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -242,8 +242,8 @@ export default class AuthStore extends Store<Team> {
|
||||
// Update the user's timezone if it has changed
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (data.user.timezone !== timezone) {
|
||||
const user = this.rootStore.users.get(data.user.id)!;
|
||||
void user.save({ timezone });
|
||||
const user = this.rootStore.users.get(data.user.id);
|
||||
void user?.save({ timezone });
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -92,7 +92,7 @@ export type MenuItem =
|
||||
| MenuGroup;
|
||||
|
||||
export type ActionContext = {
|
||||
isContextMenu: boolean;
|
||||
isMenu: boolean;
|
||||
isCommandBar: boolean;
|
||||
isButton: boolean;
|
||||
sidebarContext?: SidebarContextType;
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
// TypeScript files
|
||||
"**/*.[tj]s?(x)": [
|
||||
(f) => `prettier --write ${f.join(" ")}`,
|
||||
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix`),
|
||||
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix --type-aware`),
|
||||
() => `yarn build:i18n`,
|
||||
() => "git add shared/i18n/locales/en_US/translation.json",
|
||||
],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"dev": "NODE_ENV=development yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=cron,collaboration,websockets,admin,web,worker\"",
|
||||
"dev:backend": "NODE_ENV=development nodemon --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --ignore *.test.ts --ignore data/ --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
|
||||
"dev:watch": "NODE_ENV=development yarn concurrently -n backend,frontend \"yarn dev:backend\" \"yarn vite:dev\"",
|
||||
"lint": "oxlint app server shared plugins",
|
||||
"lint": "oxlint --type-aware app server shared plugins",
|
||||
"lint:changed": "git diff --name-only --diff-filter=ACMRTUXB | grep -E '\\.(js|jsx|ts|tsx)$' | xargs -r oxlint",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@linear/sdk": "^39.2.1",
|
||||
"@linear/sdk": "^58.1.0",
|
||||
"@node-oauth/oauth2-server": "^5.2.0",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@octokit/auth-app": "^6.1.4",
|
||||
@@ -203,7 +203,7 @@
|
||||
"prosemirror-model": "^1.25.2",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.7.1",
|
||||
"prosemirror-tables": "^1.8.1",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.40.1",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
@@ -280,7 +280,6 @@
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.3.1",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
@@ -360,7 +359,8 @@
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"lint-staged": "^13.3.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"oxlint": "^1.7.0",
|
||||
"oxlint": "1.11.2",
|
||||
"oxlint-tsgolint": "^0.1.6",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"react-refresh": "^0.17.0",
|
||||
|
||||
@@ -15,7 +15,6 @@ import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { disconnectAnalyticsIntegrationFactory } from "~/actions/definitions/integrations";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -26,7 +25,6 @@ type FormData = {
|
||||
function GoogleAnalytics() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
const integration = find(integrations.orderedData, {
|
||||
type: IntegrationType.Analytics,
|
||||
@@ -108,7 +106,6 @@ function GoogleAnalytics() {
|
||||
|
||||
<Button
|
||||
action={disconnectAnalyticsIntegrationFactory(integration)}
|
||||
context={context}
|
||||
disabled={formState.isSubmitting}
|
||||
neutral
|
||||
hideIcon
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Linear } from "../linear";
|
||||
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
|
||||
import * as T from "./schema";
|
||||
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
|
||||
import { addSeconds } from "date-fns";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -52,6 +53,10 @@ router.get(
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: oauth.access_token,
|
||||
refreshToken: oauth.refresh_token,
|
||||
expiresAt: oauth.expires_in
|
||||
? addSeconds(Date.now(), oauth.expires_in)
|
||||
: undefined,
|
||||
scopes: oauth.scope.split(" "),
|
||||
},
|
||||
{ transaction }
|
||||
|
||||
@@ -12,9 +12,13 @@ import User from "@server/models/User";
|
||||
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import { LinearUtils } from "../shared/LinearUtils";
|
||||
import env from "./env";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
|
||||
const AccessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
// Linear is in the process of switching to short-lived refresh tokens. Some apps
|
||||
// may not return a refresh token before April 2026, hence it's optional here.
|
||||
refresh_token: z.string().optional(),
|
||||
token_type: z.string(),
|
||||
expires_in: z.number(),
|
||||
scope: z.string(),
|
||||
@@ -51,6 +55,33 @@ export class Linear {
|
||||
return AccessTokenResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
static async refreshToken(refreshToken: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set("refresh_token", refreshToken);
|
||||
body.set("client_id", env.LINEAR_CLIENT_ID!);
|
||||
body.set("client_secret", env.LINEAR_CLIENT_SECRET!);
|
||||
body.set("grant_type", "refresh_token");
|
||||
|
||||
const res = await fetch(LinearUtils.tokenUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Error while refreshing access token from Linear; status: ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return AccessTokenResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
static async revokeAccess(accessToken: string) {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -93,9 +124,12 @@ export class Linear {
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new LinearClient({
|
||||
accessToken: integration.authentication.token,
|
||||
});
|
||||
const accessToken = await integration.authentication.refreshTokenIfNeeded(
|
||||
async (refreshToken: string) => Linear.refreshToken(refreshToken),
|
||||
5 * Minute.ms
|
||||
);
|
||||
|
||||
const client = new LinearClient({ accessToken });
|
||||
const issue = await client.issue(resource.id);
|
||||
|
||||
if (!issue) {
|
||||
@@ -193,7 +227,7 @@ export class Linear {
|
||||
* Parses a given URL and returns resource identifiers for Linear specific URLs
|
||||
*
|
||||
* @param url URL to parse
|
||||
* @returns {object} Containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
|
||||
* @returns An object containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
|
||||
*/
|
||||
private static parseUrl(url: string) {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
|
||||
@@ -15,7 +15,6 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Icon from "./Icon";
|
||||
import { disconnectAnalyticsIntegrationFactory } from "~/actions/definitions/integrations";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -27,7 +26,6 @@ type FormData = {
|
||||
function Matomo() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
const integration = find(integrations.orderedData, {
|
||||
type: IntegrationType.Analytics,
|
||||
@@ -129,7 +127,6 @@ function Matomo() {
|
||||
|
||||
<Button
|
||||
action={disconnectAnalyticsIntegrationFactory(integration)}
|
||||
context={context}
|
||||
disabled={formState.isSubmitting}
|
||||
neutral
|
||||
hideIcon
|
||||
|
||||
@@ -28,7 +28,7 @@ const router = new Router();
|
||||
router.post(
|
||||
"files.create",
|
||||
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
||||
auth({ allowMultipart: true }),
|
||||
auth(),
|
||||
validate(T.FilesCreateSchema),
|
||||
multipart({
|
||||
maximumFileSize: Math.max(
|
||||
|
||||
@@ -16,7 +16,6 @@ import useStores from "~/hooks/useStores";
|
||||
import Icon from "./Icon";
|
||||
import Flex from "~/components/Flex";
|
||||
import { disconnectAnalyticsIntegrationFactory } from "~/actions/definitions/integrations";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import styled from "styled-components";
|
||||
|
||||
type FormData = {
|
||||
@@ -28,7 +27,6 @@ type FormData = {
|
||||
function Umami() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
const integration = find(integrations.orderedData, {
|
||||
type: IntegrationType.Analytics,
|
||||
@@ -149,7 +147,6 @@ function Umami() {
|
||||
|
||||
<Button
|
||||
action={disconnectAnalyticsIntegrationFactory(integration)}
|
||||
context={context}
|
||||
disabled={formState.isSubmitting}
|
||||
neutral
|
||||
hideIcon
|
||||
|
||||
|
Before Width: | Height: | Size: 598 B After Width: | Height: | Size: 534 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 774 B After Width: | Height: | Size: 693 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1003 B After Width: | Height: | Size: 893 B |