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