Compare commits

..

1 Commits

Author SHA1 Message Date
tommoor 8a303282ba chore: Compressed inefficient images automatically 2025-09-07 20:05:39 +00:00
219 changed files with 3778 additions and 4432 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
var path = require('path');
module.exports = {
'config': path.resolve('server/config', 'database.js'),
'config': path.resolve('server/config', 'database.json'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
}
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.87.4
Licensed Work: Outline 0.87.3
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-09-18
Change Date: 2029-09-01
Change License: Apache License, Version 2.0
+11 -12
View File
@@ -7,13 +7,14 @@
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
@@ -50,14 +51,13 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
```shell
# To run all tests
@@ -68,14 +68,14 @@ make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly with jest:
frontend and backend tests directly.
```shell
# To run backend tests
yarn test:server
# To run a specific backend test in watch mode
yarn test path/to/file.test.ts --watch
# To run a specific backend test
yarn test:server myTestFile
# To run frontend tests
yarn test:app
@@ -86,15 +86,14 @@ yarn test:app
Sequelize is used to create and run migrations, for example:
```shell
yarn db:create-migration --name my-migration
yarn db:migrate
yarn db:rollback
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
Or, to run migrations on test database:
Or to run migrations on test database:
```shell
yarn db:migrate --env test
yarn sequelize db:migrate --env test
```
# Activity
+7
View File
@@ -13,6 +13,13 @@
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
"importNames": ["useMenuState"],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
]
+2 -2
View File
@@ -27,8 +27,8 @@ export const createApiKey = createAction({
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createActionV2({
name: ({ t, isMenu }) =>
isMenu
name: ({ t, isContextMenu }) =>
isContextMenu
? apiKey.isExpired
? t("Delete")
: `${t("Revoke")}`
+4 -3
View File
@@ -81,7 +81,8 @@ export const createCollection = createAction({
});
export const editCollection = createActionV2({
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}` : t("Edit collection")),
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
@@ -106,8 +107,8 @@ export const editCollection = createActionV2({
});
export const editCollectionPermissions = createActionV2({
name: ({ t, isMenu }) =>
isMenu ? `${t("Permissions")}` : t("Collection permissions"),
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
+14 -11
View File
@@ -384,8 +384,8 @@ export const subscribeDocument = createActionV2({
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
icon: <SubscribeIcon />,
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
return undefined;
}
@@ -393,8 +393,8 @@ export const subscribeDocument = createActionV2({
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
return false;
}
@@ -430,8 +430,8 @@ export const unsubscribeDocument = createActionV2({
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
icon: <UnsubscribeIcon />,
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
return undefined;
}
@@ -439,8 +439,8 @@ export const unsubscribeDocument = createActionV2({
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
return false;
}
@@ -571,7 +571,8 @@ export const downloadDocumentAsMarkdown = createActionV2({
});
export const downloadDocument = createActionV2WithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
analyticsName: "Download document",
section: ActiveDocumentSection,
icon: <DownloadIcon />,
@@ -677,7 +678,8 @@ export const copyDocument = createActionV2WithChildren({
});
export const duplicateDocument = createActionV2({
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
icon: <DuplicateIcon />,
@@ -827,7 +829,8 @@ export const searchInDocument = createInternalLinkActionV2({
});
export const printDocument = createActionV2({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
analyticsName: "Print document",
section: ActiveDocumentSection,
icon: <PrintIcon />,
+2 -2
View File
@@ -131,8 +131,8 @@ export const navigateToTemplateSettings = createAction({
});
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isMenu }) =>
isMenu ? t("Notification settings") : t("Notifications"),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
section: NavigationSection,
iconInContextMenu: false,
+2 -1
View File
@@ -37,7 +37,8 @@ export const changeToSystemTheme = createActionV2({
});
export const changeTheme = createActionV2WithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: ({ stores }) =>
+15 -7
View File
@@ -3,8 +3,12 @@ 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, ActionV2Variant, ActionV2WithChildren } from "~/types";
import useActionContext from "~/hooks/useActionContext";
import {
Action,
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
} from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -13,6 +17,8 @@ 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">;
};
@@ -22,20 +28,22 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function _ActionButton(
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
{ action, context, 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 (!actionContext || !action) {
if (action && !context) {
throw new Error("Context must be provided with action");
}
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
const actionContext = { ...context, isButton: true };
if (
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
+2 -1
View File
@@ -6,6 +6,7 @@ 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;
@@ -17,6 +18,7 @@ export const Action = styled(Flex)`
export const Separator = styled.div`
flex-shrink: 0;
margin-left: 12px;
width: 1px;
height: 28px;
background: ${s("divider")};
@@ -31,7 +33,6 @@ const Actions = styled(Flex)`
background: ${s("background")};
padding: 12px;
backdrop-filter: blur(20px);
gap: 12px;
@media print {
display: none;
+7 -4
View File
@@ -13,6 +13,7 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
@@ -108,10 +109,12 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
</Route>
)}
</AnimatePresence>
+1 -1
View File
@@ -25,7 +25,7 @@ function Breadcrumb(
{ actions, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const actionContext = useActionContext({ isMenu: true });
const actionContext = useActionContext({ isContextMenu: true });
const visibleActions = useComputed(
() =>
+13
View File
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("sidebarText")};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export default MenuIconWrapper;
+217
View File
@@ -0,0 +1,217 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
href?: string;
target?: string;
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
disabled,
as,
hide,
icon,
...rest
}: Props,
ref: React.Ref<HTMLAnchorElement>
) => {
const content = React.useCallback(
(props) => {
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const handleClick = async (ev: React.MouseEvent) => {
hide?.();
if (onClick) {
preventDefault(ev);
await onClick(ev);
}
};
return (
<MenuAnchor
{...props}
$active={active}
as={onClick ? "button" : as}
onClick={handleClick}
onPointerDown={preventDefault}
onMouseDown={preventDefault}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
])}
>
{selected !== undefined && (
<SelectedWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
</MenuAnchor>
);
},
[active, as, hide, icon, onClick, ref, children, selected]
);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
>
{content}
</BaseMenuItem>
);
};
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
$active?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
display: flex;
margin: 0;
border: 0;
padding: 12px;
border-radius: 4px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 16px;
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) => props.disabled && "pointer-events: none;"}
${(props) =>
props.$active === undefined &&
!props.disabled &&
`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
}
}
`}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${props.theme.accentText};
}
`}
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`}
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
@@ -0,0 +1,70 @@
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = {
/** Sub-menu x */
x: number;
/** Sub-menu y */
y: number;
/** Sub-menu height */
h: number;
/** Sub-menu width */
w: number;
/** Mouse x */
mouseX: number;
/** Mouse y */
mouseY: number;
};
/**
* Component to cover the area between the mouse cursor and the sub-menu, to
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
x = 0,
y = 0,
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const [mouseX, mouseY] = useMousePosition();
const positions = { x, y, h, w, mouseX, mouseY };
return (
<div
style={{
position: "absolute",
top: 0,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
@@ -0,0 +1,27 @@
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";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
const { t } = useTranslation();
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton
className={className}
aria-label={t("More options")}
{...props}
>
<MoreIcon />
</NudeButton>
)}
</MenuButton>
);
}
+15
View File
@@ -0,0 +1,15 @@
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 6px 0;
`;
+264
View File
@@ -0,0 +1,264 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import {
Action,
ActionContext,
MenuSeparator,
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
`;
type SubMenuProps = MenuStateReturn & {
templateItems: TMenuItem[];
parentMenuState: Omit<MenuStateReturn, "items">;
title: React.ReactNode;
};
const SubMenu = React.forwardRef(function _Template(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState({
parentId: parentMenuState.baseId,
});
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isContextMenu: true,
});
const templateItems = actions
? actions.map((item) =>
item.type === "separator" || item.type === "heading"
? item
: actionToMenuItem(item, ctx)
)
: items || [];
const filteredTemplates = filterTemplateItems(templateItems);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) =>
item.type !== "separator" && item.type !== "heading" && !!item.icon
);
return (
<>
{filteredTemplates.map((item, index) => {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading" &&
showIcons !== false
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
if (item.type === "route") {
return (
<MenuItem
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "link") {
return (
<MenuItem
id={`${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={
typeof item.href === "string" ? undefined : item.href.target
}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "button") {
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={`${item.type}-${item.title}-${index}`}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
// Skip rendering empty submenus
return item.items.length > 0 ? (
<BaseMenuItem
key={`${item.type}-${item.title}-${index}`}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
) : null;
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
}
// This should never be reached for Reakit dropdown menu.
// Added for exhaustiveness check.
if (item.type === "group") {
return null;
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
</>
);
}
function Title({
title,
icon,
}: {
title: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
+317
View File
@@ -0,0 +1,317 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuStateReturn } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "~/styles/animations";
export type Placement =
| "auto-start"
| "auto"
| "auto-end"
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "bottom-end"
| "bottom"
| "bottom-start"
| "left-end"
| "left"
| "left-start";
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
onOpen?: () => void;
/** Called when the context menu is closed. */
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode;
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
parentMenuState,
...rest
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
const isSubMenu = !!parentMenuState;
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
onOpen?.();
if (!isSubMenu) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
onClose?.();
if (!isSubMenu) {
setIsMenuOpen(false);
}
}
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
isSubMenu,
t,
]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
}
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
</Menu>
</>
);
};
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = props;
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor =
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
if (scrollElement && !props.isSubMenu) {
enableBodyScroll(scrollElement);
}
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
maxHeight,
}
: undefined;
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={style}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu;
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${s("backdrop")};
z-index: ${depths.menu - 1};
`;
export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
}
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
top: auto !important;
right: 8px !important;
bottom: 16px !important;
left: 8px !important;
`};
`;
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px;
min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px;
max-height: 75vh;
font-weight: normal;
@media print {
display: none;
}
${breakpoint("tablet")`
animation: ${(props: BackgroundProps) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
max-height: 100vh;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
`;
+84 -86
View File
@@ -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 { ActionContextProvider } from "~/hooks/useActionContext";
import useActionContext from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
import useStores from "~/hooks/useStores";
@@ -94,99 +94,97 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const actionContext = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId ? document.collectionId : undefined,
});
const contextMenuAction = useDocumentMenuAction({ document });
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
<ContextMenu
action={contextMenuAction}
context={actionContext}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
>
<ContextMenu
action={contextMenuAction}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
<DocumentLink
ref={itemRef}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<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}
/>
&nbsp;
</>
)}
<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}
/>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
{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}
/>
</Actions>
</DocumentLink>
</ContextMenu>
</ActionContextProvider>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
</ContextMenu>
);
}
+5 -16
View File
@@ -155,16 +155,14 @@ const DocumentMeta: React.FC<Props> = ({
}
return (
<Viewed>
<Separator />
<Modified highlight>{t("Never viewed")}</Modified>
&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
</Viewed>
);
}
return (
<Viewed>
<Separator />
{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</Viewed>
);
};
@@ -188,17 +186,16 @@ const DocumentMeta: React.FC<Props> = ({
)}
{showParentDocuments && nestedDocumentsCount > 0 && (
<span>
<Separator />
{nestedDocumentsCount}{" "}
&nbsp; {nestedDocumentsCount}{" "}
{t("nested document", {
count: nestedDocumentsCount,
})}
</span>
)}
{timeSinceNow()}
&nbsp;{timeSinceNow()}
{canShowProgressBar && (
<>
<Separator />
&nbsp;&nbsp;
<DocumentTasks document={document} />
</>
)}
@@ -207,14 +204,6 @@ const DocumentMeta: React.FC<Props> = ({
);
};
export const Separator = styled.span`
padding: 0 0.4em;
&::after {
content: "•";
}
`;
const Strong = styled.strong`
font-weight: 550;
`;
+78 -49
View File
@@ -1,19 +1,22 @@
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;
}
@@ -31,17 +34,19 @@ 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 [open, setOpen] = React.useState(false);
const menu = useMenuState({
modal: false,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
);
@@ -53,26 +58,32 @@ const FilterOptions = ({
const renderItem = React.useCallback(
(option) => (
<MenuButton
<MenuItem
key={option.key}
icon={option.icon}
label={option.label}
onClick={() => {
onSelect(option.key);
setOpen(false);
menu.hide();
}}
selected={selectedKeys.includes(option.key)}
/>
{...menu}
>
{option.icon}
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
),
[onSelect, selectedKeys]
[menu, onSelect, selectedKeys]
);
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
},
[]
);
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
};
const filteredOptions = React.useMemo(() => {
const normalizedQuery = deburr(query.toLowerCase());
@@ -110,13 +121,13 @@ const FilterOptions = ({
switch (ev.key) {
case "Escape":
setOpen(false);
menu.hide();
break;
case "Enter":
if (filteredOptions.length === 1) {
ev.preventDefault();
onSelect(filteredOptions[0].key);
setOpen(false);
menu.hide();
}
break;
case "ArrowDown":
@@ -127,7 +138,7 @@ const FilterOptions = ({
break;
}
},
[filteredOptions, onSelect]
[filteredOptions, menu, onSelect]
);
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
@@ -139,21 +150,21 @@ const FilterOptions = ({
}, []);
React.useEffect(() => {
if (open) {
if (menu.visible) {
searchInputRef.current?.focus();
} else {
setQuery("");
}
}, [open]);
}, [menu.visible]);
const showFilterInput = showFilter || options.length > 10;
const defaultLabel = rest.defaultLabel || t("Filter options");
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger>
<>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
@@ -161,31 +172,31 @@ const FilterOptions = ({
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
</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 />}
)}
</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
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
/>
)}
</MenuContent>
</Menu>
</MenuProvider>
)}
</ContextMenu>
</>
);
};
@@ -231,6 +242,24 @@ 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;
-1
View File
@@ -125,7 +125,6 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
gap: 12px;
${breakpoint("tablet")`
position: unset;
-16
View File
@@ -1,16 +0,0 @@
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);
`;
+3 -2
View File
@@ -6,6 +6,7 @@ import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import NudeButton from "./NudeButton";
@@ -218,9 +219,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
);
const renderOption = React.useCallback(
(option: Option, idx: number) => {
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator key={`separator-${idx}`} />;
return <Separator />;
}
const isSelected = option === selectedOption;
+30 -27
View File
@@ -12,6 +12,7 @@ import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
type Props = {
@@ -37,39 +38,41 @@ const Layout = React.forwardRef(function Layout_(
});
return (
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<MenuProvider>
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<SkipNavLink />
<SkipNavLink />
{ui.progressBarVisible && <LoadingIndicatorBar />}
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
{sidebar}
<Container auto>
<MenuProvider>{sidebar}</MenuProvider>
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
{sidebarRight}
{sidebarRight}
</Container>
</Container>
</Container>
</MenuProvider>
);
});
+56 -39
View File
@@ -12,7 +12,6 @@ import {
CloseIcon,
CrossIcon,
DownloadIcon,
LinkIcon,
NextIcon,
} from "outline-icons";
import { depths, extraArea, s } from "@shared/styles";
@@ -26,9 +25,6 @@ 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,
@@ -291,7 +287,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
// in editor
const editorImageEl = imageElements[currentImageIndex];
let to;
if (editorImageEl?.isConnected) {
if (editorImageEl) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
@@ -443,8 +439,6 @@ function Lightbox({ onUpdate, activePos }: Props) {
if (animation.current?.fadeIn) {
animation.current = {
...(animation.current ?? {}),
zoomIn: undefined,
fadeIn: undefined,
startTime: undefined,
};
setStatus({
@@ -463,8 +457,6 @@ function Lightbox({ onUpdate, activePos }: Props) {
return null;
}
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
return (
<Dialog.Root open={!!activePos}>
<Dialog.Portal>
@@ -482,18 +474,6 @@ 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}
@@ -505,7 +485,6 @@ function Lightbox({ onUpdate, activePos }: Props) {
neutral
/>
</Tooltip>
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<Button
@@ -529,7 +508,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
)}
<Image
ref={imgRef}
src={src}
src={sanitizeUrl(currentImageNode.attrs.src) ?? ""}
alt={currentImageNode.attrs.alt ?? ""}
onLoading={() =>
setStatus({
@@ -551,8 +530,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUp={close}
onSwipeDown={close}
onSwipeUpOrDown={close}
status={status}
animation={animation.current}
/>
@@ -577,8 +555,7 @@ type ImageProps = {
onError: () => void;
onSwipeRight: () => void;
onSwipeLeft: () => void;
onSwipeUp: () => void;
onSwipeDown: () => void;
onSwipeUpOrDown: () => void;
status: Status;
animation: Animation | null;
};
@@ -592,21 +569,59 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onError,
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
onSwipeUpOrDown,
status,
animation,
}: ImageProps,
ref
) {
const { t } = useTranslation();
const touchXStart = useRef<number>();
const touchXEnd = useRef<number>();
const touchYStart = useRef<number>();
const touchYEnd = useRef<number>();
const swipeHandlers = useSwipe({
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
});
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 [hidden, setHidden] = useState(
status.image === null || status.image === ImageStatus.LOADING
@@ -625,7 +640,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
}, [status.image]);
return status.image === ImageStatus.ERROR ? (
<StyledError animation={animation} {...swipeHandlers}>
<StyledError animation={animation}>
<CrossIcon size={16} /> {t("Image failed to load")}
</StyledError>
) : (
@@ -638,7 +653,10 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
alt={alt}
animation={animation}
onAnimationStart={() => setHidden(false)}
{...swipeHandlers}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchCancel}
onError={onError}
onLoad={onLoad}
$hidden={hidden}
@@ -739,8 +757,7 @@ const Actions = styled.div<{
right: 0;
margin: 16px 12px;
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
${(props) =>
props.animation === null
+11 -6
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
import { ActionContext, ActionV2Variant, ActionV2WithChildren } from "~/types";
import { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
@@ -12,6 +12,8 @@ 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 */
@@ -23,12 +25,15 @@ type Props = {
};
export const ContextMenu = observer(
({ action, children, ariaLabel, onOpen, onClose }: Props) => {
({ action, children, ariaLabel, context, onOpen, onClose }: Props) => {
const isMobile = useMobile();
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext = useActionContext({
isMenu: true,
});
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
@@ -75,7 +80,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
+11 -4
View File
@@ -14,6 +14,7 @@ import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import {
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
MenuItem,
@@ -26,6 +27,8 @@ 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 */
@@ -46,6 +49,7 @@ export const DropdownMenu = observer(
(
{
action,
context,
children,
align = "start",
ariaLabel,
@@ -60,9 +64,12 @@ export const DropdownMenu = observer(
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext = useActionContext({
isMenu: true,
});
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
@@ -119,7 +126,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,6 +6,7 @@ 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";
@@ -31,6 +32,7 @@ function Notifications(
{ onRequestClose }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const context = useActionContext();
const { notifications } = useStores();
const { t } = useTranslation();
const isEmpty = notifications.active.length === 0;
@@ -67,6 +69,7 @@ function Notifications(
<Tooltip content={t("Mark all as read")}>
<Button
action={markNotificationsAsRead}
context={context}
aria-label={t("Mark all as read")}
>
<MarkAsReadIcon />
@@ -6,6 +6,7 @@ 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({
@@ -20,6 +21,7 @@ export function PermissionAction({
onChange: (permission: CollectionPermission | DocumentPermission) => void;
}) {
const { t } = useTranslation();
const context = useActionContext();
return (
<Fade timing="150ms" key="invite">
@@ -29,7 +31,9 @@ export function PermissionAction({
onChange={onChange}
value={permission}
/>
<ButtonSmall action={action}>{t("Add")}</ButtonSmall>
<ButtonSmall action={action} context={context}>
{t("Add")}
</ButtonSmall>
</Flex>
</Fade>
);
+9 -5
View File
@@ -7,6 +7,7 @@ import { depths, s } from "@shared/styles";
import ErrorBoundary from "~/components/ErrorBoundary";
import Flex from "~/components/Flex";
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { sidebarAppearDuration } from "~/styles/animations";
@@ -19,6 +20,7 @@ function Right({ children, border, className }: Props) {
const theme = useTheme();
const { ui } = useStores();
const [isResizing, setResizing] = React.useState(false);
const isMobile = useMobile();
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -98,11 +100,13 @@ function Right({ children, border, className }: Props) {
<Sidebar {...animationProps} $border={border} className={className}>
<Position style={style} column>
<ErrorBoundary>{children}</ErrorBoundary>
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
{!isMobile && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
)}
</Position>
</Sidebar>
);
+3 -1
View File
@@ -7,6 +7,7 @@ import { depths, s } from "@shared/styles";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMenuContext from "~/hooks/useMenuContext";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
@@ -40,10 +41,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobile = useMobile();
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -12,7 +12,7 @@ type Props = {
function SidebarAction({ action, ...rest }: Props) {
const context = useActionContext({
isMenu: false,
isContextMenu: false,
isCommandBar: false,
activeCollectionId: undefined,
activeDocumentId: undefined,
+34 -36
View File
@@ -10,7 +10,7 @@ import {
unstarCollection,
} from "~/actions/definitions/collections";
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useActionContext from "~/hooks/useActionContext";
import NudeButton from "./NudeButton";
type Props = {
@@ -27,6 +27,10 @@ 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;
@@ -35,43 +39,37 @@ function Star({ size, document, collection, color, ...rest }: Props) {
}
return (
<ActionContextProvider
value={{
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
<NudeButton
context={context}
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}
>
<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>
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
size={size}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
</NudeButton>
);
}
+6 -6
View File
@@ -18,8 +18,6 @@ Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerHandle = DrawerPrimitive.Handle;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
@@ -58,9 +56,11 @@ const DrawerTitle = React.forwardRef<
const { hidden, children, ...rest } = props;
const title = (
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
{children}
</Text>
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
);
return (
@@ -100,4 +100,4 @@ const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerHandle, DrawerContent, DrawerTitle };
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
+1 -4
View File
@@ -3,6 +3,7 @@ import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Props as ButtonProps } from "~/components/Button";
import Separator from "~/components/ContextMenu/Separator";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
@@ -98,10 +99,6 @@ const InputSelectSeparator = React.forwardRef<
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
const Separator = styled.hr`
margin: 6px 0;
`;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
+1 -1
View File
@@ -104,7 +104,7 @@ const MenuContent = React.forwardRef<
return (
<Portal>
<Content ref={ref} {...offsetProp} {...rest} collisionPadding={6} asChild>
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
+1 -1
View File
@@ -90,7 +90,7 @@ type StyledContentProps = {
const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
z-index: ${depths.modal};
max-height: min(85vh, var(--radix-popover-content-available-height));
max-height: var(--radix-popover-content-available-height);
transform-origin: var(--radix-popover-content-transform-origin);
background: ${s("menuBackground")};
+1 -20
View File
@@ -9,7 +9,6 @@ import { fadeAndScaleIn } from "~/styles/animations";
type BaseMenuItemProps = {
disabled?: boolean;
$active?: boolean;
$dangerous?: boolean;
};
@@ -45,24 +44,6 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
outline: 0; // Disable default outline on Firefox
}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.$dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
`}
${(props) =>
!props.disabled &&
`
@@ -77,7 +58,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
+12 -40
View File
@@ -17,7 +17,6 @@ import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
type Props = {
align?: "start" | "end" | "center";
active?: boolean;
children: React.ReactNode;
width?: number;
@@ -36,18 +35,16 @@ const defaultPosition = {
function usePosition({
menuRef,
active,
align = "center",
}: {
menuRef: React.RefObject<HTMLDivElement>;
active?: boolean;
align?: Props["align"];
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const menuHeight = menuRef.current?.offsetHeight ?? 0;
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
if (!active || !menuRef.current) {
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
}
@@ -97,7 +94,7 @@ function usePosition({
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
}
}
@@ -183,11 +180,7 @@ function usePosition({
),
Math.max(
Math.max(offsetParent.x, margin),
align === "center"
? centerOfSelection - menuWidth / 2
: align === "start"
? selectionBounds.left
: selectionBounds.right
centerOfSelection - menuWidth / 2
)
);
const top = Math.max(
@@ -223,7 +216,6 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
let position = usePosition({
menuRef,
active: props.active,
align: props.align,
});
if (isSelectingText) {
@@ -285,7 +277,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
left: `${position.left}px`,
}}
>
<Background align={props.align}>{props.children}</Background>
{props.children}
</Wrapper>
</Portal>
);
@@ -310,7 +302,7 @@ const arrow = (props: WrapperProps) =>
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -3px;
bottom: -2px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
}
@@ -343,42 +335,22 @@ const MobileWrapper = styled.div`
}
`;
const Background = styled.div<{ align: Props["align"] }>`
position: relative;
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 4px;
height: 36px;
padding: 6px;
${(props) =>
props.align === "start" &&
`
position: absolute;
left: 0;
bottom: 0;
`}
${(props) =>
props.align === "end" &&
`
position: absolute;
right: 0;
bottom: 0;
`}
`;
const Wrapper = styled.div<WrapperProps>`
will-change: opacity, transform;
padding: 6px;
position: absolute;
z-index: ${depths.editorToolbar};
opacity: 0;
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 4px;
transform: scale(0.95);
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
height: 36px;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
+4 -6
View File
@@ -33,8 +33,8 @@ export function MediaDimension() {
height = node.attrs.height as number;
const [localDimension, setLocalDimension] = useState<Dimension>(() => ({
width: width ? String(width) : "",
height: height ? String(height) : "",
width: String(width),
height: String(height),
changed: "none",
}));
const [error, setError] = useState<{ width: boolean; height: boolean }>({
@@ -59,8 +59,8 @@ export function MediaDimension() {
const reset = useCallback(() => {
setLocalDimension({
width: width ? String(width) : "",
height: height ? String(height) : "",
width: String(width),
height: String(height),
changed: "none",
});
setError({ width: false, height: false });
@@ -209,7 +209,6 @@ export function MediaDimension() {
<StyledInput
label={t("Image width")}
labelHidden
placeholder={t("Width")}
value={localDimension.width}
onChange={handleChange("width")}
onBlur={handleBlur}
@@ -222,7 +221,6 @@ export function MediaDimension() {
<StyledInput
label={t("Image height")}
labelHidden
placeholder={t("Height")}
value={localDimension.height}
onChange={handleChange("height")}
onBlur={handleBlur}
@@ -199,11 +199,9 @@ export default function SelectionToolbar(props: Props) {
const isNoticeSelection = isInNotice(state);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelection) {
items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
@@ -222,7 +220,6 @@ export default function SelectionToolbar(props: Props) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
align = "end";
} else {
items = getFormattingMenuItems(state, isTemplate, dictionary);
}
@@ -254,7 +251,6 @@ export default function SelectionToolbar(props: Props) {
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
+2 -4
View File
@@ -14,13 +14,13 @@ import { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Header from "~/components/ContextMenu/Header";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import Input from "./Input";
import { MenuHeader } from "~/components/primitives/components/Menu";
type TopAnchor = {
top: number;
@@ -647,9 +647,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<MenuHeader key={currentHeading}>
{currentHeading}
</MenuHeader>
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
onPointerMove={handlePointerMove}
+9 -14
View File
@@ -2,8 +2,8 @@ import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import MenuItem from "~/components/ContextMenu/MenuItem";
import { usePortalContext } from "~/components/Portal";
import { MenuButton, MenuLabel } from "~/components/primitives/components/Menu";
export type Props = {
/** Whether the item is selected */
@@ -53,22 +53,17 @@ function SuggestionsMenuItem({
);
return (
<MenuButton
<MenuItem
ref={ref}
disabled={disabled}
onClick={onClick}
active={selected}
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
$active={selected}
icon={icon}
>
{icon}
<MenuLabel>
{title}
{subtitle && (
<Subtitle $active={selected}>&middot; {subtitle}</Subtitle>
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
</MenuButton>
{title}
{subtitle && <Subtitle $active={selected}>&middot; {subtitle}</Subtitle>}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuItem>
);
}
+55 -71
View File
@@ -1,9 +1,12 @@
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { useMenuState } from "reakit";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Toolbar from "@radix-ui/react-toolbar";
import { MenuItem } from "@shared/editor/types";
import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { TooltipProvider } from "~/components/TooltipContext";
import { MenuItem as TMenuItem } from "~/types";
import { useEditor } from "./EditorContext";
@@ -11,12 +14,6 @@ import { MediaDimension } from "./MediaDimension";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
import Tooltip from "./Tooltip";
import { toMenuItems } from "~/components/Menu/transformer";
import { MenuContent } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { Menu, MenuTrigger } from "~/components/primitives/Menu";
import { useTranslation } from "react-i18next";
import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
@@ -26,8 +23,8 @@ type Props = {
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const menu = useMenuState();
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { state } = view;
@@ -63,30 +60,24 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
: [];
}, [item.children, commands, state]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
return (
<EventBoundary>
<MenuProvider variant="dropdown">
<Menu>
<MenuTrigger>
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
<>
<MenuButton {...menu}>
{(buttonProps) => (
<ToolbarButton
{...buttonProps}
hovering={menu.visible}
aria-label={item.tooltip}
>
{toMenuItems(items)}
</MenuContent>
</Menu>
</MenuProvider>
</EventBoundary>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</MenuButton>
<ContextMenu aria-label={item.label} {...menu}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
@@ -107,47 +98,40 @@ function ToolbarMenu(props: Props) {
return (
<TooltipProvider>
<Toolbar.Root asChild>
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
active={isActive && !item.label}
item={item}
/>
) : (
<Toolbar.Button asChild>
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</Toolbar.Button>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</Toolbar.Root>
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} />
) : (
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</TooltipProvider>
);
}
-6
View File
@@ -291,12 +291,6 @@ 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)) {
+10 -15
View File
@@ -459,7 +459,6 @@ 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;
@@ -481,26 +480,22 @@ export default class PasteHandler extends Extension {
return false;
});
const showPasteMenu = allLinks && links.length;
if (!allLinks || !links.length) {
return;
}
// it's possible that the links can be converted to mentions
if (showPasteMenu) {
const placeholderId = links[0];
const to = from + listNode.nodeSize;
const placeholderId = links[0];
const to = from + listNode.nodeSize;
tr = state.tr.replaceSelectionWith(listNode).setMeta(this.key, {
const transaction = 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(tr);
view.dispatch(transaction);
if (showPasteMenu) {
this.showPasteMenu(links);
}
this.showPasteMenu(links);
}
private placeholderId = () =>
+13 -25
View File
@@ -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, selectedRect } from "prosemirror-tables";
import { CellSelection } from "prosemirror-tables";
import styled from "styled-components";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
isMergedCellSelection,
@@ -21,7 +21,6 @@ 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,
@@ -35,8 +34,6 @@ export default function tableColMenuItems(
return [];
}
const tableMap = selectedRect(state);
return [
{
name: "setColumnAttr",
@@ -78,13 +75,13 @@ export default function tableColMenuItems(
name: "sortTable",
tooltip: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <AlphabeticalSortIcon />,
icon: <SortAscIcon />,
},
{
name: "sortTable",
tooltip: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <AlphabeticalReverseSortIcon />,
icon: <SortDescIcon />,
},
{
name: "separator",
@@ -110,23 +107,6 @@ 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,
@@ -152,3 +132,11 @@ export default function tableColMenuItems(
},
];
}
const SortAscIcon = styled(ArrowIcon)`
transform: rotate(-90deg);
`;
const SortDescIcon = styled(ArrowIcon)`
transform: rotate(90deg);
`;
+1 -22
View File
@@ -8,14 +8,13 @@ import {
TableMergeCellsIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { CellSelection } 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,
@@ -23,13 +22,10 @@ export default function tableRowMenuItems(
dictionary: Dictionary
): MenuItem[] {
const { selection } = state;
if (!(selection instanceof CellSelection)) {
return [];
}
const tableMap = selectedRect(state);
return [
{
icon: <MoreIcon />,
@@ -52,23 +48,6 @@ 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,
+33
View File
@@ -0,0 +1,33 @@
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,
};
}
-102
View File
@@ -1,102 +0,0 @@
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"
);
}
+1 -4
View File
@@ -9,7 +9,6 @@ import { CustomTheme } from "@shared/types";
import type { Theme } from "~/stores/UiStore";
import useMediaQuery from "~/hooks/useMediaQuery";
import useStores from "./useStores";
import useQuery from "./useQuery";
/**
* Builds a theme based on the current user's preferences, the current device
@@ -24,11 +23,9 @@ export default function useBuildTheme(
overrideTheme?: Theme
) {
const { ui } = useStores();
const params = useQuery();
const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`);
const isPrinting = useMediaQuery("print");
const queryTheme = (params.get("theme") as Theme) || undefined;
const resolvedTheme = overrideTheme ?? queryTheme ?? ui.resolvedTheme;
const resolvedTheme = overrideTheme ?? ui.resolvedTheme;
const theme = useMemo(
() =>
+4 -8
View File
@@ -11,14 +11,10 @@ export default function useDictionary() {
return useMemo(
() => ({
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"),
addColumnAfter: t("Add column after"),
addColumnBefore: t("Add column before"),
addRowAfter: t("Add row after"),
addRowBefore: t("Add row before"),
alignCenter: t("Align center"),
alignLeft: t("Align left"),
alignRight: t("Align right"),
-7
View File
@@ -5,7 +5,6 @@ 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 */
@@ -79,12 +78,6 @@ 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");
}
},
+75
View File
@@ -0,0 +1,75 @@
import noop from "lodash/noop";
import * as React from "react";
type MenuContextType = {
isMenuOpen: boolean;
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
registerMenu: (menuId: string, hideFunction: () => void) => void;
unregisterMenu: (menuId: string) => void;
closeOtherMenus: (...menuIds: (string | undefined)[]) => void;
};
const MenuContext = React.createContext<MenuContextType | null>(null);
// Registry to track all active menu instances
const menuRegistry = new Map();
type Props = {
children?: React.ReactNode;
};
export const MenuProvider: React.FC = ({ children }: Props) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const registerMenu = React.useCallback(
(menuId: string, hideFunction: () => void) => {
menuRegistry.set(menuId, hideFunction);
},
[]
);
const unregisterMenu = React.useCallback((menuId: string) => {
menuRegistry.delete(menuId);
}, []);
const closeOtherMenus = React.useCallback(
(...menuIds: (string | undefined)[]) => {
menuRegistry.forEach((hideFunction, menuId) => {
if (!menuIds.includes(menuId)) {
hideFunction();
}
});
},
[]
);
const memoized = React.useMemo(
() => ({
isMenuOpen,
setIsMenuOpen,
registerMenu,
unregisterMenu,
closeOtherMenus,
}),
[isMenuOpen, setIsMenuOpen, registerMenu, unregisterMenu, closeOtherMenus]
);
return (
<MenuContext.Provider value={memoized}>{children}</MenuContext.Provider>
);
};
const useMenuContext: () => MenuContextType = () => {
const value = React.useContext(MenuContext);
return value
? value
: {
isMenuOpen: false,
setIsMenuOpen: noop,
registerMenu: noop,
unregisterMenu: noop,
closeOtherMenus: noop,
};
};
export default useMenuContext;
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
const useMenuHeight = ({
visible,
elementRef,
maxViewportHeight = 90,
margin = 8,
}: {
/** Whether the menu is visible. */
visible: void | boolean;
/** The maximum height of the menu as a percentage of the viewport. */
maxViewportHeight?: number;
/** A ref pointing to the element for the menu disclosure. */
elementRef?: React.RefObject<HTMLElement | null>;
/** The margin to apply to the positioning. */
margin?: number;
}) => {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(10);
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useLayoutEffect(() => {
if (visible && !isMobile) {
const calculatedMaxHeight = (windowHeight / 100) * maxViewportHeight;
setMaxHeight(
Math.min(
calculatedMaxHeight,
elementRef?.current
? windowHeight -
elementRef.current.getBoundingClientRect().bottom -
margin
: 0
)
);
} else {
setMaxHeight(0);
}
}, [visible, elementRef, windowHeight, margin, isMobile, maxViewportHeight]);
return maxHeight;
};
export default useMenuHeight;
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react";
import {
// oxlint-disable-next-line no-restricted-imports
useMenuState as reakitUseMenuState,
MenuStateReturn,
} from "reakit/Menu";
import useMenuContext from "./useMenuContext";
type Props = Parameters<typeof reakitUseMenuState>[0] & {
parentId?: string;
};
/**
* A hook that wraps Reakit's useMenuState with coordination logic to ensure
* only one context menu can be open at a time across the application.
*/
export function useMenuState(options?: Props): MenuStateReturn {
const menuState = reakitUseMenuState(options);
const { registerMenu, unregisterMenu, closeOtherMenus } = useMenuContext();
const menuId = menuState.baseId;
const parentId = options?.parentId;
// Register this menu instance on mount and unregister on unmount
React.useEffect(() => {
registerMenu(menuId, menuState.hide);
return () => unregisterMenu(menuId);
}, [menuId, menuState.hide, registerMenu, unregisterMenu]);
const coordinatedShow = React.useCallback(() => {
closeOtherMenus(menuId, parentId);
menuState.show();
}, [closeOtherMenus, menuId, menuState, parentId]);
const coordinatedToggle = React.useCallback(() => {
closeOtherMenus(menuId, parentId);
menuState.toggle();
}, [menuId, menuState, closeOtherMenus, parentId]);
// Return the menu state with the coordinated show method
return React.useMemo(
() => ({
...menuState,
toggle: coordinatedToggle,
show: coordinatedShow,
}),
[menuState, coordinatedToggle, coordinatedShow]
);
}
+33
View File
@@ -0,0 +1,33 @@
import throttle from "lodash/throttle";
import { useState, useMemo } from "react";
import useEventListener from "./useEventListener";
import useIsMounted from "./useIsMounted";
/**
* Mouse position as a tuple of [x, y]
*/
type MousePosition = [number, number];
/**
* Hook to get the current mouse position
*
* @returns Mouse position as a tuple of [x, y]
*/
export const useMousePosition = () => {
const isMounted = useIsMounted();
const [mousePosition, setMousePosition] = useState<MousePosition>([0, 0]);
const updateMousePosition = useMemo(
() =>
throttle((ev: MouseEvent) => {
if (isMounted()) {
setMousePosition([ev.clientX, ev.clientY]);
}
}, 200),
[isMounted]
);
useEventListener("mousemove", updateMousePosition);
return mousePosition;
};
-76
View File
@@ -1,76 +0,0 @@
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,
};
}
+21 -24
View File
@@ -25,7 +25,6 @@ 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,29 +52,27 @@ if (element) {
<HelmetProvider>
<Provider {...stores}>
<Analytics>
<Router history={history}>
<Theme>
<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>
</Theme>
</Router>
<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>
</Theme>
</Analytics>
</Provider>
</HelmetProvider>
+9 -3
View File
@@ -36,7 +36,7 @@ import {
createDocument,
exportCollection,
} from "~/actions/definitions/collections";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useActionContext from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
@@ -130,6 +130,11 @@ 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;
@@ -223,7 +228,7 @@ function CollectionMenu({
const rootAction = useMenuAction(actions);
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<>
<VisuallyHidden.Root>
<label>
{t("Import document")}
@@ -239,6 +244,7 @@ function CollectionMenu({
</VisuallyHidden.Root>
<DropdownMenu
action={rootAction}
context={context}
align={align}
onOpen={onOpen}
onClose={onClose}
@@ -249,7 +255,7 @@ function CollectionMenu({
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
</ActionContextProvider>
</>
);
}
+21 -23
View File
@@ -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 { ActionContextProvider } from "~/hooks/useActionContext";
import useActionContext from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
@@ -132,6 +132,13 @@ 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;
@@ -196,29 +203,20 @@ function DocumentMenu({
]);
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
<DropdownMenu
action={rootAction}
context={context}
align={align}
onOpen={onOpen}
onClose={onClose}
ariaLabel={t("Document options")}
append={toggleSwitches}
>
<DropdownMenu
action={rootAction}
align={align}
onOpen={onOpen}
onClose={onClose}
ariaLabel={t("Document options")}
append={toggleSwitches}
>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
</ActionContextProvider>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
);
}
+14 -10
View File
@@ -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,6 +19,11 @@ type Props = {
function RevisionMenu({ document }: Props) {
const { t } = useTranslation();
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
});
const actions = useMemo(
() => [restoreRevision, ActionV2Separator, copyLinkToRevision],
[]
@@ -27,15 +32,14 @@ function RevisionMenu({ document }: Props) {
const rootAction = useMenuAction(actions);
return (
<ActionContextProvider value={{ activeDocumentId: document.id }}>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Revision options")}
>
<OverflowMenuButton />
</DropdownMenu>
</ActionContextProvider>
<DropdownMenu
action={rootAction}
context={context}
align="end"
ariaLabel={t("Revision options")}
>
<OverflowMenuButton />
</DropdownMenu>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ type Props = {
const TeamMenu: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const context = useActionContext({ isMenu: true });
const context = useActionContext({ isContextMenu: true });
// NOTE: it's useful to memoize on the team id and session because the action
// menu is not cached at all.
-2
View File
@@ -4,7 +4,6 @@ import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
import Field from "./decorators/Field";
import { observable } from "mobx";
/**
* Represents a user's membership to a group.
@@ -28,7 +27,6 @@ class GroupUser extends Model {
/** The permission of the user in the group. */
@Field
@observable
permission: GroupPermission;
}
@@ -8,6 +8,7 @@ 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";
@@ -23,6 +24,7 @@ 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(() => {
@@ -70,6 +72,7 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
return (
<NudeButton
context={context}
tooltip={{
content:
usersCount > 0
+1 -2
View File
@@ -48,7 +48,6 @@ import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import ShareButton from "./components/ShareButton";
import first from "lodash/first";
const IconPicker = lazy(() => import("~/components/IconPicker"));
@@ -207,7 +206,7 @@ const CollectionScene = observer(function _CollectionScene() {
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? (first(colorPalette) as string)}
color={collection.color ?? colorPalette[0]}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
@@ -24,6 +24,7 @@ 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";
@@ -311,12 +312,14 @@ 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 }),
+64 -68
View File
@@ -1,5 +1,6 @@
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";
@@ -23,8 +24,6 @@ import CommentForm from "./CommentForm";
import CommentSortMenu from "./CommentSortMenu";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
import useMobile from "~/hooks/useMobile";
import { ArrowDownIcon } from "~/components/Icons/ArrowIcon";
function Comments() {
const { ui, comments, documents } = useStores();
@@ -35,8 +34,6 @@ function Comments() {
const document = documents.get(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
const isMobile = useMobile();
const query = useQuery();
const [viewingResolved, setViewingResolved] = useState(
query.get("resolved") !== null || focusedComment?.isResolved || false
@@ -126,73 +123,15 @@ function Comments() {
prevThreadCount.current = threads.length;
}, [sortOption.type, threads.length, viewingResolved]);
const content =
!document || !isEditorInitialized ? null : (
<>
<Scrollable
id="comments"
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
ref={scrollableRef}
onScroll={handleScroll}
>
<Wrapper $hasComments={hasComments}>
{hasComments ? (
threads.map((thread) => (
<CommentThread
key={thread.id}
comment={thread}
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
/>
))
) : (
<NoComments align="center" justify="center" auto>
<PositionedEmpty>
{viewingResolved
? t("No resolved comments")
: t("No comments yet")}
</PositionedEmpty>
</NoComments>
)}
{showJumpToRecentBtn && (
<Fade>
<JumpToRecent onClick={scrollToBottom}>
<Flex align="center">
{t("New comments")}&nbsp;
<ArrowDownIcon size={20} />
</Flex>
</JumpToRecent>
</Fade>
)}
</Wrapper>
</Scrollable>
<AnimatePresence initial={false}>
{(!focusedComment || isMobile) && can.comment && !viewingResolved && (
<NewCommentForm
draft={draft}
onSaveDraft={onSaveDraft}
documentId={document.id}
placeholder={`${t("Add a comment")}`}
autoFocus={false}
dir={document.dir}
animatePresence
standalone
/>
)}
</AnimatePresence>
</>
);
if (!document || !isEditorInitialized) {
return null;
}
return (
<Sidebar
title={
<Flex align="center" justify="space-between" gap={8} auto>
<div style={isMobile ? { padding: "0 8px" } : undefined}>
{t("Comments")}
</div>
<Flex align="center" justify="space-between" auto>
<span>{t("Comments")}</span>
<CommentSortMenu
viewingResolved={viewingResolved}
onChange={(val) => {
@@ -204,7 +143,60 @@ function Comments() {
onClose={() => ui.set({ commentsExpanded: false })}
scrollable={false}
>
{content}
<Scrollable
id="comments"
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
ref={scrollableRef}
onScroll={handleScroll}
>
<Wrapper $hasComments={hasComments}>
{hasComments ? (
threads.map((thread) => (
<CommentThread
key={thread.id}
comment={thread}
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
/>
))
) : (
<NoComments align="center" justify="center" auto>
<PositionedEmpty>
{viewingResolved
? t("No resolved comments")
: t("No comments yet")}
</PositionedEmpty>
</NoComments>
)}
{showJumpToRecentBtn && (
<Fade>
<JumpToRecent onClick={scrollToBottom}>
<Flex align="center">
{t("New comments")}&nbsp;
<ArrowDownIcon size={20} />
</Flex>
</JumpToRecent>
</Fade>
)}
</Wrapper>
</Scrollable>
<AnimatePresence initial={false}>
{!focusedComment && can.comment && !viewingResolved && (
<NewCommentForm
draft={draft}
onSaveDraft={onSaveDraft}
documentId={document.id}
placeholder={`${t("Add a comment")}`}
autoFocus={false}
dir={document.dir}
animatePresence
standalone
/>
)}
</AnimatePresence>
</Sidebar>
);
}
@@ -238,6 +230,10 @@ 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 -17
View File
@@ -9,15 +9,14 @@ import { TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { openDocumentInsights } from "~/actions/definitions/documents";
import DocumentMeta, { Separator } from "~/components/DocumentMeta";
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 breakpoint from "styled-components-breakpoint";
import { documentPath } from "~/utils/routeHelpers";
import NudeButton from "~/components/NudeButton";
type Props = {
/* The document to display meta data for */
@@ -37,6 +36,9 @@ 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;
@@ -47,7 +49,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
<Meta document={document} revision={revision} to={to} replace {...rest}>
{commentingEnabled && can.comment && (
<>
<Separator />
&nbsp;&nbsp;
<CommentLink
to={{
pathname: documentPath(document),
@@ -67,8 +69,10 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
!document.isDraft &&
!document.isTemplate ? (
<Wrapper>
<Separator />
<InsightsButton action={openDocumentInsights}>
&nbsp;&nbsp;
<InsightsButton
onClick={() => openDocumentInsights.perform(actionContext)}
>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
@@ -87,7 +91,7 @@ const CommentLink = styled(Link)`
align-items: center;
`;
const InsightsButton = styled(NudeButton)`
const InsightsButton = styled.button`
background: none;
border: none;
padding: 0;
@@ -109,16 +113,6 @@ export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
user-select: none;
z-index: 1;
${breakpoint("mobile", "tablet")`
flex-direction: column;
align-items: flex-start;
line-height: 1.6;
${Separator} {
display: none;
}
`}
a {
color: inherit;
cursor: var(--pointer);
+2 -2
View File
@@ -1,3 +1,4 @@
import last from "lodash/last";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -32,7 +33,6 @@ import { decodeURIComponentSafe } from "~/utils/urls";
import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
import first from "lodash/first";
const extensions = withUIExtensions(withComments(richExtensions));
@@ -80,7 +80,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const can = usePolicy(document);
const commentingEnabled = !!team?.getPreference(TeamPreference.Commenting);
const iconColor = document.color ?? (first(colorPalette) as string);
const iconColor = document.color ?? (last(colorPalette) as string);
const childRef = React.useRef<HTMLDivElement>(null);
const focusAtStart = React.useCallback(() => {
if (ref.current) {
+13 -1
View File
@@ -23,6 +23,7 @@ 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";
@@ -108,6 +109,10 @@ function DocumentHeader({
}
}, [ui, isShare]);
const context = useActionContext({
activeDocumentId: document?.id,
});
const can = usePolicy(document);
const { isDeleted, isTemplate } = document;
const isTemplateEditable = can.update && isTemplate;
@@ -274,6 +279,7 @@ function DocumentHeader({
placement="bottom"
>
<Button
context={context}
action={isTemplate ? navigateToTemplateSettings : undefined}
onClick={isTemplate ? undefined : handleSave}
disabled={savingIsDisabled}
@@ -302,7 +308,12 @@ function DocumentHeader({
{revision && revision.createdAt !== document.updatedAt && (
<Action>
<Tooltip content={t("Restore version")} placement="bottom">
<Button action={restoreRevision} neutral hideOnActionDisabled>
<Button
action={restoreRevision}
context={context}
neutral
hideOnActionDisabled
>
{t("Restore")}
</Button>
</Tooltip>
@@ -312,6 +323,7 @@ function DocumentHeader({
<Action>
<Button
action={publishDocument}
context={context}
disabled={publishingIsDisabled}
hideOnActionDisabled
hideIcon
@@ -15,7 +15,6 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
import useMobile from "~/hooks/useMobile";
const DocumentEvents = [
"documents.publish",
@@ -38,7 +37,6 @@ function History() {
const document = documents.get(match.params.documentSlug);
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
const [eventsOffset, setEventsOffset] = React.useState(0);
const isMobile = useMobile();
const fetchHistory = React.useCallback(async () => {
if (!document) {
@@ -127,10 +125,6 @@ function History() {
}, [revisions, document, revisionEvents, nonRevisionEvents]);
const onCloseHistory = React.useCallback(() => {
if (isMobile) {
// Allow closing the history drawer on mobile to view revision content
return;
}
if (document) {
history.push({
pathname: documentPath(document),
@@ -3,19 +3,15 @@ import { BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { depths, s, ellipsis } from "@shared/styles";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import useMobile from "~/hooks/useMobile";
import { draggableOnDesktop } from "~/styles";
import RightSidebar from "~/components/Sidebar/Right";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
import { fadeIn } from "~/styles/animations";
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
/* The title of the sidebar */
@@ -23,7 +19,7 @@ type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
/* The content of the sidebar */
children: React.ReactNode;
/* Called when the sidebar is closed */
onClose: () => void;
onClose: React.MouseEventHandler;
/* Whether the sidebar should be scrollable */
scrollable?: boolean;
};
@@ -32,23 +28,8 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
const { t } = useTranslation();
const isMobile = useMobile();
const content = scrollable ? (
<Scrollable hiddenScrollbars topShadow>
{children}
</Scrollable>
) : (
children
);
return isMobile ? (
<Drawer onClose={onClose} defaultOpen>
<DrawerContent>
<DrawerTitle>{title}</DrawerTitle>
{content}
</DrawerContent>
</Drawer>
) : (
<RightSidebar>
return (
<>
<Header>
<Title>{title}</Title>
<Tooltip content={t("Close")} shortcut="Esc">
@@ -60,11 +41,35 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
/>
</Tooltip>
</Header>
{content}
</RightSidebar>
{scrollable ? (
<Scrollable hiddenScrollbars topShadow>
{children}
</Scrollable>
) : (
children
)}
{isMobile && (
<Portal>
<Backdrop onClick={onClose} />
</Portal>
)}
</>
);
}
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
cursor: default;
z-index: ${depths.mobileSidebar - 1};
background: ${s("backdrop")};
`;
const ForwardIcon = styled(BackIcon)`
transform: rotate(180deg);
flex-shrink: 0;
+3 -1
View File
@@ -6,10 +6,12 @@ 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")}>
@@ -22,7 +24,7 @@ const Error403 = () => {
{t("Please request access from the document owner.")}
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} hideIcon>
<Button action={navigateToHome} context={context} hideIcon>
{t("Home")}
</Button>
<Button onClick={history.goBack} neutral>
+4 -2
View File
@@ -8,9 +8,11 @@ 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")}>
@@ -23,10 +25,10 @@ const Error404 = () => {
</Trans>
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} neutral hideIcon>
<Button action={navigateToHome} context={context} neutral hideIcon>
{t("Home")}
</Button>
<Button action={navigateToSearch} neutral>
<Button action={navigateToSearch} context={context} neutral>
{t("Search")}
</Button>
</Flex>
+3 -1
View File
@@ -6,9 +6,11 @@ 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>
@@ -22,7 +24,7 @@ const ErrorUnknown = () => {
</Trans>
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} neutral hideIcon>
<Button action={navigateToHome} context={context} 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>
<Inputs column gap={12}>
<Flex column gap={12} style={{ width: "100%" }}>
<Input
name="teamName"
type="text"
@@ -57,7 +57,7 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
required
flex
/>
</Inputs>
</Flex>
<ButtonLarge type="submit" fullwidth>
{t("Continue")}
</ButtonLarge>
@@ -66,11 +66,6 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
);
};
const Inputs = styled(Flex)`
width: 100%;
text-align: left;
`;
const StyledHeading = styled(Heading)`
margin: 0;
`;
+3
View File
@@ -11,6 +11,7 @@ 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";
@@ -24,6 +25,7 @@ function APIAndApps() {
const { t } = useTranslation();
const { apiKeys, oauthAuthentications } = useStores();
const can = usePolicy(team);
const context = useActionContext();
const appName = env.APP_NAME;
return (
@@ -38,6 +40,7 @@ function APIAndApps() {
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
context={context}
/>
</Action>
)}
+3
View File
@@ -9,6 +9,7 @@ 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";
@@ -19,6 +20,7 @@ function ApiKeys() {
const { t } = useTranslation();
const { apiKeys } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
@@ -32,6 +34,7 @@ function ApiKeys() {
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
context={context}
/>
</Action>
)}
+3
View File
@@ -9,6 +9,7 @@ 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";
@@ -19,6 +20,7 @@ function Applications() {
const { t } = useTranslation();
const { oauthClients } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
@@ -32,6 +34,7 @@ function Applications() {
type="submit"
value={`${t("New App")}`}
action={createOAuthClient}
context={context}
/>
</Action>
)}
+3
View File
@@ -16,6 +16,7 @@ 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";
@@ -31,6 +32,7 @@ function Members() {
const location = useLocation();
const history = useHistory();
const team = useCurrentTeam();
const context = useActionContext();
const { users } = useStores();
const { t } = useTranslation();
const params = useQuery();
@@ -126,6 +128,7 @@ function Members() {
data-event-category="invite"
data-event-action="peoplePage"
action={inviteUser}
context={context}
icon={<PlusIcon />}
>
{t("Invite people")}
+1 -6
View File
@@ -222,8 +222,6 @@ function SharedScene() {
);
}
const hasSidebar = !!share.tree?.children.length;
return (
<>
<Helmet>
@@ -240,10 +238,7 @@ function SharedScene() {
<TeamContext.Provider value={team}>
<ThemeProvider theme={theme}>
<DocumentContextProvider>
<Layout
title={pageTitle}
sidebar={hasSidebar ? <Sidebar share={share} /> : null}
>
<Layout title={pageTitle} sidebar={<Sidebar share={share} />}>
{model instanceof Document ? (
<DocumentScene
document={model}
+7 -2
View File
@@ -8,19 +8,24 @@ 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}>
<Button
neutral
action={permanentlyDeleteDocumentsInTrash}
context={context}
>
{t("Empty trash")}
</Button>
)
+8 -14
View File
@@ -59,14 +59,11 @@ export default class GroupUsersStore extends Store<GroupUser> {
permission,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
return runInAction(`GroupUsersStore#create`, () => {
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
});
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
}
@action
@@ -99,14 +96,11 @@ export default class GroupUsersStore extends Store<GroupUser> {
permission,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
return runInAction(`GroupUsersStore#update`, () => {
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
});
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
}
@action
+1 -1
View File
@@ -92,7 +92,7 @@ export type MenuItem =
| MenuGroup;
export type ActionContext = {
isMenu: boolean;
isContextMenu: boolean;
isCommandBar: boolean;
isButton: boolean;
sidebarContext?: SidebarContextType;
+1 -1
View File
@@ -55,7 +55,7 @@ export function redirectTo(url: string) {
export const isAllowedLoginRedirect = (input: string) => {
const path = input.split("?")[0].split("#")[0];
return (
!["/", "/create", "/home", "/logout", "/desktop-redirect"].includes(path) &&
!["/", "/create", "/home", "/logout"].includes(path) &&
!path.startsWith("/auth/") &&
!path.startsWith("/s/")
);
+1 -1
View File
@@ -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 --type-aware`),
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix`),
() => `yarn build:i18n`,
() => "git add shared/i18n/locales/en_US/translation.json",
],
+19 -20
View File
@@ -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 --type-aware app server shared plugins",
"lint": "oxlint 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 .",
@@ -51,16 +51,16 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.893.0",
"@aws-sdk/lib-storage": "3.893.0",
"@aws-sdk/s3-presigned-post": "3.893.0",
"@aws-sdk/s3-request-presigner": "3.893.0",
"@aws-sdk/signature-v4-crt": "^3.893.0",
"@babel/core": "^7.28.4",
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/lib-storage": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@aws-sdk/signature-v4-crt": "^3.879.0",
"@babel/core": "^7.28.3",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-regenerator": "^7.28.4",
"@babel/plugin-transform-regenerator": "^7.28.3",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
@@ -73,9 +73,9 @@
"@dotenvx/dotenvx": "^1.49.0",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.5",
"@fortawesome/fontawesome-svg-core": "^7.0.1",
"@fortawesome/free-brands-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.1",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.6",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@hocuspocus/extension-redis": "1.1.2",
@@ -84,7 +84,7 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^58.1.0",
"@linear/sdk": "^39.2.1",
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.4",
@@ -100,7 +100,6 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toolbar": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.2",
"@sentry/node": "^7.120.4",
@@ -149,7 +148,7 @@
"i18next-fs-backend": "^2.6.0",
"i18next-http-backend": "^2.7.3",
"invariant": "^2.2.4",
"ioredis": "^5.7.0",
"ioredis": "^5.6.0",
"is-printable-key-event": "^1.0.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
@@ -204,7 +203,7 @@
"prosemirror-model": "^1.25.2",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.8.1",
"prosemirror-tables": "^1.7.1",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.40.1",
"proxy-from-env": "^1.1.0",
@@ -228,6 +227,7 @@
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.11",
"reakit": "^1.3.11",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"refractor": "^3.6.0",
@@ -315,7 +315,7 @@
"@types/node": "20.17.30",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.8.0",
"@types/passport-oauth2": "^1.4.17",
"@types/pluralize": "^0.0.33",
"@types/png-chunks-extract": "^1.0.2",
"@types/proxy-from-env": "^1.0.4",
@@ -327,7 +327,7 @@
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.21",
"@types/redis-info": "^3.0.3",
@@ -359,8 +359,7 @@
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^13.3.0",
"nodemon": "^3.1.10",
"oxlint": "1.11.2",
"oxlint-tsgolint": "^0.1.6",
"oxlint": "^1.7.0",
"postinstall-postinstall": "^2.1.0",
"prettier": "^3.6.2",
"react-refresh": "^0.17.0",
@@ -381,6 +380,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "0.87.4",
"version": "0.87.3",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
@@ -15,6 +15,7 @@ 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";
@@ -25,6 +26,7 @@ type FormData = {
function GoogleAnalytics() {
const { integrations } = useStores();
const { t } = useTranslation();
const context = useActionContext();
const integration = find(integrations.orderedData, {
type: IntegrationType.Analytics,
@@ -106,6 +108,7 @@ function GoogleAnalytics() {
<Button
action={disconnectAnalyticsIntegrationFactory(integration)}
context={context}
disabled={formState.isSubmitting}
neutral
hideIcon
-5
View File
@@ -11,7 +11,6 @@ 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();
@@ -53,10 +52,6 @@ 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 }
+4 -38
View File
@@ -12,13 +12,9 @@ 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(),
@@ -55,33 +51,6 @@ 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}`,
@@ -124,12 +93,9 @@ export class Linear {
}
try {
const accessToken = await integration.authentication.refreshTokenIfNeeded(
async (refreshToken: string) => Linear.refreshToken(refreshToken),
5 * Minute.ms
);
const client = new LinearClient({ accessToken });
const client = new LinearClient({
accessToken: integration.authentication.token,
});
const issue = await client.issue(resource.id);
if (!issue) {
@@ -227,7 +193,7 @@ export class Linear {
* Parses a given URL and returns resource identifiers for Linear specific URLs
*
* @param url URL to parse
* @returns An object containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
* @returns {object} Containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
+3
View File
@@ -15,6 +15,7 @@ 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";
@@ -26,6 +27,7 @@ type FormData = {
function Matomo() {
const { integrations } = useStores();
const { t } = useTranslation();
const context = useActionContext();
const integration = find(integrations.orderedData, {
type: IntegrationType.Analytics,
@@ -127,6 +129,7 @@ function Matomo() {
<Button
action={disconnectAnalyticsIntegrationFactory(integration)}
context={context}
disabled={formState.isSubmitting}
neutral
hideIcon
+3
View File
@@ -16,6 +16,7 @@ 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 = {
@@ -27,6 +28,7 @@ type FormData = {
function Umami() {
const { integrations } = useStores();
const { t } = useTranslation();
const context = useActionContext();
const integration = find(integrations.orderedData, {
type: IntegrationType.Analytics,
@@ -147,6 +149,7 @@ function Umami() {
<Button
action={disconnectAnalyticsIntegrationFactory(integration)}
context={context}
disabled={formState.isSubmitting}
neutral
hideIcon
Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Some files were not shown because too many files have changed in this diff Show More