mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33307c636a |
+8
-4
@@ -7,7 +7,8 @@
|
||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
@@ -21,7 +22,8 @@
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
@@ -36,7 +38,8 @@
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||
@@ -49,7 +52,8 @@
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.87.4
|
||||
Licensed Work: Outline 0.87.1
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-09-18
|
||||
Change Date: 2029-08-31
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
|
||||
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
|
||||
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
|
||||
|
||||
@@ -50,14 +51,13 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
|
||||
|
||||
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
|
||||
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
|
||||
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
|
||||
|
||||
## Tests
|
||||
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
|
||||
|
||||
```shell
|
||||
# To run all tests
|
||||
@@ -68,14 +68,14 @@ make watch
|
||||
```
|
||||
|
||||
Once the test database is created with `make test` you may individually run
|
||||
frontend and backend tests directly with jest:
|
||||
frontend and backend tests directly.
|
||||
|
||||
```shell
|
||||
# To run backend tests
|
||||
yarn test:server
|
||||
|
||||
# To run a specific backend test in watch mode
|
||||
yarn test path/to/file.test.ts --watch
|
||||
# To run a specific backend test
|
||||
yarn test:server myTestFile
|
||||
|
||||
# To run frontend tests
|
||||
yarn test:app
|
||||
@@ -86,15 +86,14 @@ yarn test:app
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
|
||||
```shell
|
||||
yarn db:create-migration --name my-migration
|
||||
yarn db:migrate
|
||||
yarn db:rollback
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
```
|
||||
|
||||
Or, to run migrations on test database:
|
||||
Or to run migrations on test database:
|
||||
|
||||
```shell
|
||||
yarn db:migrate --env test
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
|
||||
# Activity
|
||||
|
||||
@@ -27,8 +27,8 @@ export const createApiKey = createAction({
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createActionV2({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu
|
||||
? apiKey.isExpired
|
||||
? t("Delete")
|
||||
: `${t("Revoke")}…`
|
||||
|
||||
@@ -81,7 +81,8 @@ export const createCollection = createAction({
|
||||
});
|
||||
|
||||
export const editCollection = createActionV2({
|
||||
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}…` : t("Edit collection")),
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
@@ -106,8 +107,8 @@ export const editCollection = createActionV2({
|
||||
});
|
||||
|
||||
export const editCollectionPermissions = createActionV2({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
|
||||
@@ -384,8 +384,8 @@ export const subscribeDocument = createActionV2({
|
||||
analyticsName: "Subscribe to document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -393,8 +393,8 @@ export const subscribeDocument = createActionV2({
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isMenu, stores }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -430,8 +430,8 @@ export const unsubscribeDocument = createActionV2({
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -439,8 +439,8 @@ export const unsubscribeDocument = createActionV2({
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isMenu, stores }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -571,7 +571,8 @@ export const downloadDocumentAsMarkdown = createActionV2({
|
||||
});
|
||||
|
||||
export const downloadDocument = createActionV2WithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
@@ -677,7 +678,8 @@ export const copyDocument = createActionV2WithChildren({
|
||||
});
|
||||
|
||||
export const duplicateDocument = createActionV2({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
analyticsName: "Duplicate document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DuplicateIcon />,
|
||||
@@ -827,7 +829,8 @@ export const searchInDocument = createInternalLinkActionV2({
|
||||
});
|
||||
|
||||
export const printDocument = createActionV2({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
analyticsName: "Print document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
|
||||
@@ -131,8 +131,8 @@ export const navigateToTemplateSettings = createAction({
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createInternalLinkActionV2({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? t("Notification settings") : t("Notifications"),
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Notification settings") : t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
|
||||
@@ -37,7 +37,8 @@ export const changeToSystemTheme = createActionV2({
|
||||
});
|
||||
|
||||
export const changeTheme = createActionV2WithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: ({ stores }) =>
|
||||
|
||||
@@ -3,8 +3,12 @@ import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction, performActionV2, resolve } from "~/actions";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
} from "~/types";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
@@ -13,6 +17,8 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
hideOnActionDisabled?: boolean;
|
||||
/** Action to use on button */
|
||||
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
|
||||
/** Context of action, must be provided with action */
|
||||
context?: ActionContext;
|
||||
/** If tooltip props are provided the button will be wrapped in a tooltip */
|
||||
tooltip?: Omit<TooltipProps, "children">;
|
||||
};
|
||||
@@ -22,20 +28,22 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
*/
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function _ActionButton(
|
||||
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const actionContext = useActionContext({
|
||||
isButton: true,
|
||||
});
|
||||
const isMounted = useIsMounted();
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (!actionContext || !action) {
|
||||
if (action && !context) {
|
||||
throw new Error("Context must be provided with action");
|
||||
}
|
||||
if (!context || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
const actionContext = { ...context, isButton: true };
|
||||
|
||||
if (
|
||||
action.visible &&
|
||||
!resolve<boolean>(action.visible, actionContext) &&
|
||||
@@ -45,10 +53,9 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
}
|
||||
|
||||
const label =
|
||||
rest["aria-label"] ??
|
||||
(typeof action.name === "function"
|
||||
typeof action.name === "function"
|
||||
? action.name(actionContext)
|
||||
: action.name);
|
||||
: action.name;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,7 @@ import Flex from "~/components/Flex";
|
||||
export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 0 0 12px;
|
||||
height: 32px;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
@@ -17,6 +18,7 @@ export const Action = styled(Flex)`
|
||||
|
||||
export const Separator = styled.div`
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: ${s("divider")};
|
||||
@@ -31,7 +33,6 @@ const Actions = styled(Flex)`
|
||||
background: ${s("background")};
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
gap: 12px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
@@ -13,6 +13,7 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
@@ -108,10 +109,12 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
>
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -25,8 +25,6 @@ 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;
|
||||
};
|
||||
@@ -55,7 +53,6 @@ function AvatarWithPresence({
|
||||
isCurrentUser,
|
||||
size = AvatarSize.Large,
|
||||
style,
|
||||
alt,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const status = isPresent
|
||||
@@ -86,7 +83,7 @@ function AvatarWithPresence({
|
||||
$color={user.color}
|
||||
style={style}
|
||||
>
|
||||
<Avatar model={user} onClick={onClick} size={size} alt={alt} />
|
||||
<Avatar model={user} onClick={onClick} size={size} />
|
||||
</AvatarPresence>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
@@ -25,7 +25,7 @@ function Breadcrumb(
|
||||
{ actions, highlightFirstItem, children, max = 2 }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const actionContext = useActionContext({ isMenu: true });
|
||||
const actionContext = useActionContext({ isContextMenu: true });
|
||||
|
||||
const visibleActions = useComputed(
|
||||
() =>
|
||||
|
||||
@@ -132,7 +132,6 @@ function Collaborators(props: Props) {
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
alt={t("Avatar of {{ name }}", { name: collaborator.name })}
|
||||
onClick={
|
||||
isObservable
|
||||
? handleAvatarClick(
|
||||
|
||||
@@ -143,14 +143,13 @@ 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={contentEditable}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onFocus={wrappedEvent(onFocus)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
@@ -158,7 +157,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role={contentEditable ? "textbox" : undefined}
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -9,16 +8,10 @@ type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({ className, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton
|
||||
className={className}
|
||||
aria-label={t("More options")}
|
||||
{...props}
|
||||
>
|
||||
<NudeButton className={className} {...props}>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
|
||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isMenu: true,
|
||||
isContextMenu: 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 { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -94,99 +94,98 @@ function DocumentListItem(
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
const actionContext = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId ? document.collectionId : undefined,
|
||||
});
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ document });
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
}}
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
context={actionContext}
|
||||
ariaLabel={t("Document options")}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
ariaLabel={t("Document options")}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</ContextMenu>
|
||||
</ActionContextProvider>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,7 +279,7 @@ const DocumentLink = styled(Link)<{
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.span<{ rtl?: boolean }>`
|
||||
const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
@@ -290,8 +289,6 @@ const Heading = styled.span<{ rtl?: boolean }>`
|
||||
color: ${s("text")};
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
|
||||
@@ -155,22 +155,26 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
}
|
||||
return (
|
||||
<Viewed>
|
||||
<Separator />
|
||||
<Modified highlight>{t("Never viewed")}</Modified>
|
||||
• <Modified highlight>{t("Never viewed")}</Modified>
|
||||
</Viewed>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Viewed>
|
||||
<Separator />
|
||||
{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
• {t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</Viewed>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
<Container
|
||||
align="center"
|
||||
rtl={document.dir === "rtl"}
|
||||
{...rest}
|
||||
dir="ltr"
|
||||
lang=""
|
||||
>
|
||||
{to ? (
|
||||
<Link to={to} replace={replace}>
|
||||
{content}
|
||||
@@ -188,17 +192,16 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
)}
|
||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
<Separator />
|
||||
{nestedDocumentsCount}{" "}
|
||||
• {nestedDocumentsCount}{" "}
|
||||
{t("nested document", {
|
||||
count: nestedDocumentsCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{timeSinceNow()}
|
||||
{canShowProgressBar && (
|
||||
<>
|
||||
<Separator />
|
||||
•
|
||||
<DocumentTasks document={document} />
|
||||
</>
|
||||
)}
|
||||
@@ -207,14 +210,6 @@ 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, truncateMultiline } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
@@ -128,21 +128,17 @@ function EditableTitle(
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<Text
|
||||
<span
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
className={rest.className}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Text = styled.span`
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
color: ${s("text")};
|
||||
background: ${s("background")};
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import Text from "~/components/Text";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import PaginatedList, { PaginatedItem } from "./PaginatedList";
|
||||
import { MenuProvider } from "./primitives/Menu/MenuContext";
|
||||
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
|
||||
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
key: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -31,17 +34,19 @@ type Props = {
|
||||
const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys = [],
|
||||
defaultLabel = "Filter options",
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const menu = useMenuState({
|
||||
modal: false,
|
||||
});
|
||||
const selectedItems = options.filter((option) =>
|
||||
selectedKeys.includes(option.key)
|
||||
);
|
||||
@@ -53,26 +58,32 @@ const FilterOptions = ({
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option) => (
|
||||
<MenuButton
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
setOpen(false);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
{...menu}
|
||||
>
|
||||
{option.icon}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
),
|
||||
[onSelect, selectedKeys]
|
||||
[menu, onSelect, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
};
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const normalizedQuery = deburr(query.toLowerCase());
|
||||
@@ -110,13 +121,13 @@ const FilterOptions = ({
|
||||
|
||||
switch (ev.key) {
|
||||
case "Escape":
|
||||
setOpen(false);
|
||||
menu.hide();
|
||||
break;
|
||||
case "Enter":
|
||||
if (filteredOptions.length === 1) {
|
||||
ev.preventDefault();
|
||||
onSelect(filteredOptions[0].key);
|
||||
setOpen(false);
|
||||
menu.hide();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
@@ -127,7 +138,7 @@ const FilterOptions = ({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filteredOptions, onSelect]
|
||||
[filteredOptions, menu, onSelect]
|
||||
);
|
||||
|
||||
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
|
||||
@@ -139,21 +150,21 @@ const FilterOptions = ({
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
if (menu.visible) {
|
||||
searchInputRef.current?.focus();
|
||||
} else {
|
||||
setQuery("");
|
||||
}
|
||||
}, [open]);
|
||||
}, [menu.visible]);
|
||||
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
const defaultLabel = rest.defaultLabel || t("Filter options");
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={setOpen}>
|
||||
<MenuTrigger>
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
{...props}
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
neutral
|
||||
@@ -161,31 +172,31 @@ const FilterOptions = ({
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent aria-label={defaultLabel} align="start">
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -231,6 +242,24 @@ const SearchInput = styled(Input)`
|
||||
}
|
||||
`;
|
||||
|
||||
const Note = styled(Text)`
|
||||
display: block;
|
||||
margin: 2px 0;
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
|
||||
&:hover ${Note} {
|
||||
color: ${(props) => props.theme.white50};
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
|
||||
@@ -125,7 +125,6 @@ const Actions = styled(Flex)`
|
||||
flex-basis: 0;
|
||||
min-width: auto;
|
||||
padding-left: 8px;
|
||||
gap: 12px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
position: unset;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ArrowIcon as ArrowRightIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
|
||||
export { ArrowIcon as ArrowRightIcon } from "outline-icons";
|
||||
|
||||
export const ArrowUpIcon = styled(ArrowRightIcon)`
|
||||
transform: rotate(-90deg);
|
||||
`;
|
||||
|
||||
export const ArrowDownIcon = styled(ArrowRightIcon)`
|
||||
transform: rotate(90deg);
|
||||
`;
|
||||
|
||||
export const ArrowLeftIcon = styled(ArrowRightIcon)`
|
||||
transform: rotate(180deg);
|
||||
`;
|
||||
@@ -1,823 +0,0 @@
|
||||
import { useEditor } from "~/editor/components/EditorContext";
|
||||
import { observer } from "mobx-react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { findChildren } from "@shared/editor/queries/findChildren";
|
||||
import findIndex from "lodash/findIndex";
|
||||
import styled, { css, Keyframes, keyframes } from "styled-components";
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { sanitizeUrl } from "@shared/utils/urls";
|
||||
import { Error } from "@shared/editor/components/Image";
|
||||
import {
|
||||
BackIcon,
|
||||
CloseIcon,
|
||||
CrossIcon,
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
NextIcon,
|
||||
} from "outline-icons";
|
||||
import { depths, extraArea, s } from "@shared/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
import { Second } from "@shared/utils/time";
|
||||
import { downloadImageNode } from "@shared/editor/nodes/Image";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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,
|
||||
OPENING,
|
||||
OPENED,
|
||||
READY_TO_CLOSE,
|
||||
CLOSING,
|
||||
CLOSED,
|
||||
}
|
||||
|
||||
export enum ImageStatus {
|
||||
LOADING,
|
||||
ERROR,
|
||||
LOADED,
|
||||
}
|
||||
type Status = {
|
||||
lightbox: LightboxStatus | null;
|
||||
image: ImageStatus | null;
|
||||
};
|
||||
|
||||
type Animation = {
|
||||
fadeIn?: { apply: () => Keyframes; duration: number };
|
||||
fadeOut?: { apply: () => Keyframes; duration: number };
|
||||
zoomIn?: { apply: () => Keyframes; duration: number };
|
||||
zoomOut?: { apply: () => Keyframes; duration: number };
|
||||
startTime?: number;
|
||||
};
|
||||
|
||||
const ANIMATION_DURATION = 0.3 * Second.ms;
|
||||
|
||||
type Props = {
|
||||
/** Callback triggered when the active image position is updated */
|
||||
onUpdate: (pos: number | null) => void;
|
||||
/** The position of the currently active image in the document */
|
||||
activePos: number | null;
|
||||
};
|
||||
|
||||
function Lightbox({ onUpdate, activePos }: Props) {
|
||||
const { view } = useEditor();
|
||||
const isIdle = useIdle(3 * Second.ms);
|
||||
const { t } = useTranslation();
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
|
||||
const [imageElements] = useState(
|
||||
view?.dom.querySelectorAll(".component-image img")
|
||||
);
|
||||
const animation = useRef<Animation | null>(null);
|
||||
const finalImage = useRef<{
|
||||
center: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const imageNodes = useMemo(
|
||||
() =>
|
||||
view
|
||||
? findChildren(
|
||||
view.state.doc,
|
||||
(child) => child.type === view.state.schema.nodes.image,
|
||||
true
|
||||
)
|
||||
: [],
|
||||
[view]
|
||||
);
|
||||
const currentImageIndex = findIndex(
|
||||
imageNodes,
|
||||
(node) => node.pos === activePos
|
||||
);
|
||||
const currentImageNode =
|
||||
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
|
||||
|
||||
// Debugging status changes
|
||||
// useEffect(() => {
|
||||
// console.log(
|
||||
// `lstat:${status.lightbox === null ? status.lightbox : LightboxStatus[status.lightbox]}, istat:${status.image === null ? status.image : ImageStatus[status.image]}`
|
||||
// );
|
||||
// }, [status]);
|
||||
|
||||
useEffect(() => () => view.focus(), []);
|
||||
|
||||
useEffect(() => {
|
||||
!!activePos &&
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.READY_TO_OPEN,
|
||||
image: status.image,
|
||||
});
|
||||
}, [!!activePos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === ImageStatus.LOADED) {
|
||||
rememberImagePosition();
|
||||
}
|
||||
}, [status.image]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(status.image === ImageStatus.ERROR ||
|
||||
status.image === ImageStatus.LOADED) &&
|
||||
status.lightbox === LightboxStatus.READY_TO_OPEN
|
||||
) {
|
||||
setupFadeIn();
|
||||
setupZoomIn();
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.OPENING,
|
||||
image: status.image,
|
||||
});
|
||||
}
|
||||
}, [status.image, status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
|
||||
setupFadeOut();
|
||||
setupZoomOut();
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.CLOSING,
|
||||
image: status.image,
|
||||
});
|
||||
}
|
||||
}, [status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.lightbox === LightboxStatus.CLOSED) {
|
||||
onUpdate(null);
|
||||
}
|
||||
}, [status.lightbox]);
|
||||
|
||||
const rememberImagePosition = () => {
|
||||
if (imgRef.current) {
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
top: lightboxImgTop,
|
||||
left: lightboxImgLeft,
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
} = lightboxImgDOMRect;
|
||||
finalImage.current = {
|
||||
center: {
|
||||
x: lightboxImgLeft + lightboxImgWidth / 2,
|
||||
y: lightboxImgTop + lightboxImgHeight / 2,
|
||||
},
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const setupZoomIn = () => {
|
||||
if (imgRef.current) {
|
||||
// in editor
|
||||
const editorImageEl = imageElements[currentImageIndex];
|
||||
if (!editorImageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
|
||||
const {
|
||||
top: editorImgTop,
|
||||
left: editorImgLeft,
|
||||
width: editorImgWidth,
|
||||
height: editorImgHeight,
|
||||
} = editorImgDOMRect;
|
||||
|
||||
const from = {
|
||||
center: {
|
||||
x: editorImgLeft + editorImgWidth / 2,
|
||||
y: editorImgTop + editorImgHeight / 2,
|
||||
},
|
||||
width: editorImgWidth,
|
||||
height: editorImgHeight,
|
||||
};
|
||||
|
||||
// in lightbox
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
top: lightboxImgTop,
|
||||
left: lightboxImgLeft,
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
} = lightboxImgDOMRect;
|
||||
const to = {
|
||||
center: {
|
||||
x: lightboxImgLeft + lightboxImgWidth / 2,
|
||||
y: lightboxImgTop + lightboxImgHeight / 2,
|
||||
},
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
const tx = from.center.x - to.center.x;
|
||||
const ty = from.center.y - to.center.y;
|
||||
return keyframes`
|
||||
from {
|
||||
translate: ${tx}px ${ty}px;
|
||||
scale: ${from.width / to.width};
|
||||
}
|
||||
to {
|
||||
translate: 0;
|
||||
scale: 1;
|
||||
}
|
||||
`;
|
||||
};
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
zoomOut: undefined,
|
||||
zoomIn: { apply: zoomIn, duration: ANIMATION_DURATION },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const setupFadeIn = () => {
|
||||
const fadeIn = () => keyframes`
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
`;
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
fadeIn: { apply: fadeIn, duration: ANIMATION_DURATION },
|
||||
fadeOut: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const setupFadeOut = () => {
|
||||
const fadeOut = () => keyframes`
|
||||
from { opacity: ${overlayRef.current ? window.getComputedStyle(overlayRef.current).opacity : 1}; }
|
||||
to { opacity: 0; }
|
||||
`;
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
fadeIn: undefined,
|
||||
fadeOut: {
|
||||
apply: fadeOut,
|
||||
duration: animation.current?.startTime
|
||||
? Date.now() - animation.current.startTime
|
||||
: ANIMATION_DURATION,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const setupZoomOut = () => {
|
||||
if (imgRef.current) {
|
||||
// in lightbox
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
top: lightboxImgTop,
|
||||
left: lightboxImgLeft,
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
} = lightboxImgDOMRect;
|
||||
const from = {
|
||||
center: {
|
||||
x: lightboxImgLeft + lightboxImgWidth / 2,
|
||||
y: lightboxImgTop + lightboxImgHeight / 2,
|
||||
},
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
};
|
||||
|
||||
// in editor
|
||||
const editorImageEl = imageElements[currentImageIndex];
|
||||
let to;
|
||||
if (editorImageEl?.isConnected) {
|
||||
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
|
||||
const {
|
||||
top: editorImgTop,
|
||||
left: editorImgLeft,
|
||||
width: editorImgWidth,
|
||||
height: editorImgHeight,
|
||||
} = editorImgDOMRect;
|
||||
|
||||
to = {
|
||||
center: {
|
||||
x: editorImgLeft + editorImgWidth / 2,
|
||||
y:
|
||||
editorImgTop + editorImgHeight / 2 >
|
||||
window.innerHeight + editorImgHeight / 2
|
||||
? window.innerHeight + editorImgHeight / 2
|
||||
: editorImgTop + editorImgHeight / 2 < -editorImgHeight / 2
|
||||
? -editorImgHeight / 2
|
||||
: editorImgTop + editorImgHeight / 2,
|
||||
},
|
||||
width: editorImgWidth,
|
||||
height: editorImgHeight,
|
||||
};
|
||||
} else {
|
||||
to = {
|
||||
center: {
|
||||
x: from.center.x,
|
||||
y: window.innerHeight + lightboxImgHeight / 2,
|
||||
},
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
const final = finalImage.current;
|
||||
if (!final) {
|
||||
return keyframes``;
|
||||
}
|
||||
|
||||
const fromTx = from.center.x - final.center.x;
|
||||
const fromTy = from.center.y - final.center.y;
|
||||
const toTx = to.center.x - final.center.x;
|
||||
const toTy = to.center.y - final.center.y;
|
||||
|
||||
const fromSx = from.width / final.width;
|
||||
const fromSy = from.height / final.height;
|
||||
const toSx = to.width / final.width;
|
||||
const toSy = to.height / final.height;
|
||||
return keyframes`
|
||||
from {
|
||||
translate: ${fromTx}px ${fromTy}px;
|
||||
scale: ${fromSx} ${fromSy};
|
||||
}
|
||||
to {
|
||||
translate: ${toTx}px ${toTy}px;
|
||||
scale: ${toSx} ${toSy};
|
||||
}
|
||||
`;
|
||||
};
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
zoomIn: undefined,
|
||||
zoomOut: {
|
||||
apply: zoomOut,
|
||||
duration: animation.current?.startTime
|
||||
? Date.now() - animation.current.startTime
|
||||
: ANIMATION_DURATION,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (!activePos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prev = () => {
|
||||
if (status.lightbox === LightboxStatus.OPENED) {
|
||||
if (!activePos) {
|
||||
return;
|
||||
}
|
||||
const prevIndex = currentImageIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
return;
|
||||
}
|
||||
onUpdate(imageNodes[prevIndex].pos);
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (status.lightbox === LightboxStatus.OPENED) {
|
||||
if (!activePos) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = currentImageIndex + 1;
|
||||
if (nextIndex >= imageNodes.length) {
|
||||
return;
|
||||
}
|
||||
onUpdate(imageNodes[nextIndex].pos);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENING ||
|
||||
status.lightbox === LightboxStatus.OPENED
|
||||
) {
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.READY_TO_CLOSE,
|
||||
image: status.image,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
|
||||
void downloadImageNode(currentImageNode);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
ev.preventDefault();
|
||||
switch (ev.key) {
|
||||
case "ArrowLeft": {
|
||||
prev();
|
||||
break;
|
||||
}
|
||||
case "ArrowRight": {
|
||||
next();
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFadeStart = () => {
|
||||
if (animation.current?.fadeIn) {
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
startTime: Date.now(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleFadeEnd = () => {
|
||||
if (animation.current?.fadeIn) {
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
zoomIn: undefined,
|
||||
fadeIn: undefined,
|
||||
startTime: undefined,
|
||||
};
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.OPENED,
|
||||
image: status.image,
|
||||
});
|
||||
} else if (animation.current?.fadeOut) {
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.CLOSED,
|
||||
image: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentImageNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
|
||||
|
||||
return (
|
||||
<Dialog.Root open={!!activePos}>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay
|
||||
ref={overlayRef}
|
||||
animation={animation.current}
|
||||
onAnimationStart={handleFadeStart}
|
||||
onAnimationEnd={handleFadeEnd}
|
||||
/>
|
||||
<StyledContent onKeyDown={handleKeyDown}>
|
||||
<VisuallyHidden.Root>
|
||||
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{t("View, navigate, or download images in the document")}
|
||||
</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}
|
||||
onClick={download}
|
||||
aria-label={t("Download")}
|
||||
size={32}
|
||||
icon={<DownloadIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
<Separator />
|
||||
<Dialog.Close asChild>
|
||||
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
onClick={close}
|
||||
aria-label={t("Close")}
|
||||
size={32}
|
||||
icon={<CloseIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dialog.Close>
|
||||
</Actions>
|
||||
{currentImageIndex > 0 && (
|
||||
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
|
||||
<BackIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
<Image
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={currentImageNode.attrs.alt ?? ""}
|
||||
onLoading={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADING,
|
||||
})
|
||||
}
|
||||
onLoad={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADED,
|
||||
})
|
||||
}
|
||||
onError={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ERROR,
|
||||
})
|
||||
}
|
||||
onSwipeRight={prev}
|
||||
onSwipeLeft={next}
|
||||
onSwipeUp={close}
|
||||
onSwipeDown={close}
|
||||
status={status}
|
||||
animation={animation.current}
|
||||
/>
|
||||
{currentImageIndex < imageNodes.length - 1 && (
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
type ImageProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
onLoading: () => void;
|
||||
onLoad: () => void;
|
||||
onError: () => void;
|
||||
onSwipeRight: () => void;
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeUp: () => void;
|
||||
onSwipeDown: () => void;
|
||||
status: Status;
|
||||
animation: Animation | null;
|
||||
};
|
||||
|
||||
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
{
|
||||
src,
|
||||
alt,
|
||||
onLoading,
|
||||
onLoad,
|
||||
onError,
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
status,
|
||||
animation,
|
||||
}: ImageProps,
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const swipeHandlers = useSwipe({
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
});
|
||||
|
||||
const [hidden, setHidden] = useState(
|
||||
status.image === null || status.image === ImageStatus.LOADING
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLoading();
|
||||
}, [src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === null || status.image === ImageStatus.LOADING) {
|
||||
setHidden(true);
|
||||
} else if (status.image === ImageStatus.LOADED) {
|
||||
setHidden(false);
|
||||
}
|
||||
}, [status.image]);
|
||||
|
||||
return status.image === ImageStatus.ERROR ? (
|
||||
<StyledError animation={animation} {...swipeHandlers}>
|
||||
<CrossIcon size={16} /> {t("Image failed to load")}
|
||||
</StyledError>
|
||||
) : (
|
||||
<>
|
||||
{status.image === ImageStatus.LOADING && <LoadingIndicator />}
|
||||
<Figure>
|
||||
<StyledImg
|
||||
ref={ref}
|
||||
src={src}
|
||||
alt={alt}
|
||||
animation={animation}
|
||||
onAnimationStart={() => setHidden(false)}
|
||||
{...swipeHandlers}
|
||||
onError={onError}
|
||||
onLoad={onLoad}
|
||||
$hidden={hidden}
|
||||
/>
|
||||
<Caption>
|
||||
{status.image === ImageStatus.LOADED &&
|
||||
status.lightbox === LightboxStatus.OPENED ? (
|
||||
<Fade>{alt}</Fade>
|
||||
) : null}
|
||||
</Caption>
|
||||
</Figure>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const Figure = styled("figure")`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Caption = styled("figcaption")`
|
||||
font-size: 14px;
|
||||
min-height: 1.5em;
|
||||
font-weight: normal;
|
||||
margin-top: 8px;
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledOverlay = styled(Dialog.Overlay)<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: ${s("background")};
|
||||
z-index: ${depths.overlay};
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledImg = styled.img<{
|
||||
$hidden: boolean;
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
${(props) =>
|
||||
props.animation?.zoomIn
|
||||
? css`
|
||||
animation: ${props.animation.zoomIn.apply()}
|
||||
${props.animation.zoomIn.duration}ms;
|
||||
`
|
||||
: props.animation?.zoomOut
|
||||
? css`
|
||||
animation: ${props.animation.zoomOut.apply()}
|
||||
${props.animation.zoomOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
padding: 56px;
|
||||
`;
|
||||
|
||||
const Actions = styled.div<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 16px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const Nav = styled.div<{
|
||||
$hidden: boolean;
|
||||
dir: "left" | "right";
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
position: absolute;
|
||||
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
|
||||
transition: opacity 500ms ease-in-out;
|
||||
${(props) => props.$hidden && "opacity: 0;"}
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledError = styled(Error)<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const NavButton = styled(NudeButton)`
|
||||
margin: 16px;
|
||||
opacity: 0.75;
|
||||
color: ${s("text")};
|
||||
outline: none;
|
||||
${extraArea(12)}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Lightbox);
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import { ActionContext, ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
@@ -12,6 +12,8 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Action context to use - new context will be created if not provided */
|
||||
context?: ActionContext;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** ARIA label for the menu */
|
||||
@@ -23,12 +25,15 @@ type Props = {
|
||||
};
|
||||
|
||||
export const ContextMenu = observer(
|
||||
({ action, children, ariaLabel, onOpen, onClose }: Props) => {
|
||||
({ action, children, ariaLabel, context, onOpen, onClose }: Props) => {
|
||||
const isMobile = useMobile();
|
||||
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
const actionContext = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
@@ -75,7 +80,7 @@ export const ContextMenu = observer(
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<MenuProvider variant="context">
|
||||
<MenuProvider variant={"context"}>
|
||||
<Menu onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
|
||||
<MenuContent
|
||||
|
||||
@@ -14,6 +14,7 @@ import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import {
|
||||
ActionContext,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
MenuItem,
|
||||
@@ -26,6 +27,8 @@ import { useComputed } from "~/hooks/useComputed";
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Action context to use - new context will be created if not provided */
|
||||
context?: ActionContext;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** Alignment w.r.t trigger - defaults to start */
|
||||
@@ -46,6 +49,7 @@ export const DropdownMenu = observer(
|
||||
(
|
||||
{
|
||||
action,
|
||||
context,
|
||||
children,
|
||||
align = "start",
|
||||
ariaLabel,
|
||||
@@ -60,9 +64,12 @@ export const DropdownMenu = observer(
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
const actionContext = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
@@ -119,7 +126,7 @@ export const DropdownMenu = observer(
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<MenuProvider variant={"dropdown"}>
|
||||
<Menu open={open} onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
|
||||
@@ -6,6 +6,7 @@ import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
@@ -31,6 +32,7 @@ function Notifications(
|
||||
{ onRequestClose }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const context = useActionContext();
|
||||
const { notifications } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isEmpty = notifications.active.length === 0;
|
||||
@@ -65,10 +67,7 @@ function Notifications(
|
||||
<Flex gap={8}>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button
|
||||
action={markNotificationsAsRead}
|
||||
aria-label={t("Mark all as read")}
|
||||
>
|
||||
<Button action={markNotificationsAsRead} context={context}>
|
||||
<MarkAsReadIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -63,7 +63,6 @@ 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={{
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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,7 +255,6 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
<React.Fragment>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
role={rest.role}
|
||||
aria-label={rest["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={className}
|
||||
|
||||
@@ -168,7 +168,6 @@ 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"
|
||||
@@ -177,8 +176,6 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
onFocus={handleSearchInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
label={t("Search")}
|
||||
labelHidden
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
@@ -197,7 +194,6 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
}}
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
|
||||
@@ -25,11 +25,6 @@ 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,6 +6,7 @@ import { Inner } from "~/components/Button";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import Fade from "~/components/Fade";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { Action, Permission } from "~/types";
|
||||
|
||||
export function PermissionAction({
|
||||
@@ -20,6 +21,7 @@ export function PermissionAction({
|
||||
onChange: (permission: CollectionPermission | DocumentPermission) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Fade timing="150ms" key="invite">
|
||||
@@ -29,7 +31,9 @@ export function PermissionAction({
|
||||
onChange={onChange}
|
||||
value={permission}
|
||||
/>
|
||||
<ButtonSmall action={action}>{t("Add")}</ButtonSmall>
|
||||
<ButtonSmall action={action} context={context}>
|
||||
{t("Add")}
|
||||
</ButtonSmall>
|
||||
</Flex>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
@@ -81,11 +81,6 @@ 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,6 +7,7 @@ import { depths, s } from "@shared/styles";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sidebarAppearDuration } from "~/styles/animations";
|
||||
|
||||
@@ -19,6 +20,7 @@ function Right({ children, border, className }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isMobile = useMobile();
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
@@ -98,11 +100,13 @@ function Right({ children, border, className }: Props) {
|
||||
<Sidebar {...animationProps} $border={border} className={className}>
|
||||
<Position style={style} column>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleReset}
|
||||
dir="right"
|
||||
/>
|
||||
{!isMobile && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleReset}
|
||||
dir="right"
|
||||
/>
|
||||
)}
|
||||
</Position>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -52,9 +52,6 @@ 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,9 +96,6 @@ 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,7 +21,6 @@ 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;
|
||||
|
||||
@@ -36,7 +35,6 @@ 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();
|
||||
@@ -239,7 +237,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
position="bottom"
|
||||
image={
|
||||
<Avatar
|
||||
alt={t("Avatar of {{ name }}", { name: user.name })}
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
style={{ marginLeft: 4 }}
|
||||
@@ -247,11 +245,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
<SidebarButton
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
aria-label={t("Notifications")}
|
||||
/>
|
||||
<SidebarButton position="bottom" image={<NotificationIcon />} />
|
||||
</NotificationsPopover>
|
||||
</SidebarButton>
|
||||
</AccountMenu>
|
||||
|
||||
@@ -150,7 +150,6 @@ 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,6 +364,7 @@ function InnerDocumentLink(
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
@@ -62,12 +61,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
$isDragActive={isDragActive}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<label>
|
||||
{t("Import files")}
|
||||
<input {...getInputProps()} />
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
<input {...getInputProps()} />
|
||||
{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 { extraArea, s } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
@@ -71,18 +71,17 @@ const Button = styled.button`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
color: ${s("sidebarText")};
|
||||
position: relative;
|
||||
color: ${s("textTertiary")};
|
||||
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 {
|
||||
@@ -103,8 +102,7 @@ const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
|
||||
const H3 = styled.h3`
|
||||
margin: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
&:hover {
|
||||
${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
|
||||
function SidebarAction({ action, ...rest }: Props) {
|
||||
const context = useActionContext({
|
||||
isMenu: false,
|
||||
isContextMenu: 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 } from "@shared/styles";
|
||||
import { s, truncateMultiline } from "@shared/styles";
|
||||
import { isMobile } from "@shared/utils/browser";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||
@@ -273,6 +273,7 @@ const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
line-height: 24px;
|
||||
${truncateMultiline(3)}
|
||||
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
|
||||
+34
-36
@@ -10,7 +10,7 @@ import {
|
||||
unstarCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
@@ -27,6 +27,10 @@ type Props = {
|
||||
function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
activeCollectionId: collection?.id,
|
||||
});
|
||||
|
||||
const target = document || collection;
|
||||
|
||||
@@ -35,43 +39,37 @@ function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document?.id,
|
||||
activeCollectionId: collection?.id,
|
||||
<NudeButton
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
tooltip={{
|
||||
content: target.isStarred ? t("Unstar document") : t("Star document"),
|
||||
delay: 500,
|
||||
}}
|
||||
action={
|
||||
collection
|
||||
? collection.isStarred
|
||||
? unstarCollection
|
||||
: starCollection
|
||||
: document
|
||||
? document.isStarred
|
||||
? unstarDocument
|
||||
: starDocument
|
||||
: undefined
|
||||
}
|
||||
size={size}
|
||||
{...rest}
|
||||
>
|
||||
<NudeButton
|
||||
hideOnActionDisabled
|
||||
tooltip={{
|
||||
content: target.isStarred ? t("Unstar document") : t("Star document"),
|
||||
delay: 500,
|
||||
}}
|
||||
action={
|
||||
collection
|
||||
? collection.isStarred
|
||||
? unstarCollection
|
||||
: starCollection
|
||||
: document
|
||||
? document.isStarred
|
||||
? unstarDocument
|
||||
: starDocument
|
||||
: undefined
|
||||
}
|
||||
size={size}
|
||||
{...rest}
|
||||
>
|
||||
{target.isStarred ? (
|
||||
<AnimatedStar size={size} color={theme.yellow} />
|
||||
) : (
|
||||
<AnimatedStar
|
||||
size={size}
|
||||
color={color ?? theme.textTertiary}
|
||||
as={UnstarredIcon}
|
||||
/>
|
||||
)}
|
||||
</NudeButton>
|
||||
</ActionContextProvider>
|
||||
{target.isStarred ? (
|
||||
<AnimatedStar size={size} color={theme.yellow} />
|
||||
) : (
|
||||
<AnimatedStar
|
||||
size={size}
|
||||
color={color ?? theme.textTertiary}
|
||||
as={UnstarredIcon}
|
||||
/>
|
||||
)}
|
||||
</NudeButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ Drawer.displayName = "Drawer";
|
||||
/** Drawer's trigger. */
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerHandle = DrawerPrimitive.Handle;
|
||||
|
||||
/** Drawer's content - renders the overlay and the actual content. */
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
@@ -58,9 +56,11 @@ const DrawerTitle = React.forwardRef<
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
{children}
|
||||
</Text>
|
||||
<TitleWrapper justify="center">
|
||||
<Text size="medium" weight="bold">
|
||||
{children}
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -100,4 +100,4 @@ const TitleWrapper = styled(Flex)`
|
||||
padding: 8px 0;
|
||||
`;
|
||||
|
||||
export { Drawer, DrawerTrigger, DrawerHandle, DrawerContent, DrawerTitle };
|
||||
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
|
||||
|
||||
@@ -90,7 +90,7 @@ type StyledContentProps = {
|
||||
|
||||
const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
|
||||
z-index: ${depths.modal};
|
||||
max-height: min(85vh, var(--radix-popover-content-available-height));
|
||||
max-height: var(--radix-popover-content-available-height);
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
|
||||
@@ -347,7 +347,6 @@ export default function FindAndReplace({
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.prevSearchMatch()}
|
||||
aria-label={t("Previous match")}
|
||||
>
|
||||
<CaretUpIcon />
|
||||
</ButtonLarge>
|
||||
@@ -356,7 +355,6 @@ export default function FindAndReplace({
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
aria-label={t("Next match")}
|
||||
>
|
||||
<CaretDownIcon />
|
||||
</ButtonLarge>
|
||||
@@ -392,10 +390,7 @@ export default function FindAndReplace({
|
||||
shortcut={`${altDisplay}+${metaDisplay}+c`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall
|
||||
onClick={handleCaseSensitive}
|
||||
aria-label={t("Match case")}
|
||||
>
|
||||
<ButtonSmall onClick={handleCaseSensitive}>
|
||||
<CaseSensitiveIcon
|
||||
color={caseSensitive ? theme.accent : theme.textSecondary}
|
||||
/>
|
||||
@@ -406,10 +401,7 @@ export default function FindAndReplace({
|
||||
shortcut={`${altDisplay}+${metaDisplay}+r`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall
|
||||
onClick={handleRegex}
|
||||
aria-label={t("Enable regex")}
|
||||
>
|
||||
<ButtonSmall onClick={handleRegex}>
|
||||
<RegexIcon
|
||||
color={regexEnabled ? theme.accent : theme.textSecondary}
|
||||
/>
|
||||
@@ -424,10 +416,7 @@ export default function FindAndReplace({
|
||||
shortcut={`${altDisplay}+${metaDisplay}+f`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge
|
||||
onClick={handleMore}
|
||||
aria-label={t("Replace options")}
|
||||
>
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
|
||||
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -21,7 +20,6 @@ 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;
|
||||
@@ -33,8 +31,8 @@ export function MediaDimension() {
|
||||
height = node.attrs.height as number;
|
||||
|
||||
const [localDimension, setLocalDimension] = useState<Dimension>(() => ({
|
||||
width: width ? String(width) : "",
|
||||
height: height ? String(height) : "",
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
changed: "none",
|
||||
}));
|
||||
const [error, setError] = useState<{ width: boolean; height: boolean }>({
|
||||
@@ -59,8 +57,8 @@ export function MediaDimension() {
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setLocalDimension({
|
||||
width: width ? String(width) : "",
|
||||
height: height ? String(height) : "",
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
changed: "none",
|
||||
});
|
||||
setError({ width: false, height: false });
|
||||
@@ -207,9 +205,6 @@ 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}
|
||||
@@ -217,12 +212,9 @@ 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}
|
||||
|
||||
@@ -221,7 +221,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
|
||||
@@ -64,11 +64,7 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(buttonProps) => (
|
||||
<ToolbarButton
|
||||
{...buttonProps}
|
||||
hovering={menu.visible}
|
||||
aria-label={item.tooltip}
|
||||
>
|
||||
<ToolbarButton {...buttonProps} hovering={menu.visible}>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
@@ -122,7 +118,6 @@ 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,12 +291,6 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
const from = type === "inline" ? pos + i : pos;
|
||||
const to = from + (type === "inline" ? m[0].length : node.nodeSize);
|
||||
|
||||
// Prevent wrap around matches when the regex matches at the end of the deburred
|
||||
// string and continues matching at the start of the original string
|
||||
if (i + this.searchTerm.length > text.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
if (this.results.some((r) => r.from === from && r.to === to)) {
|
||||
|
||||
@@ -459,7 +459,6 @@ export default class PasteHandler extends Extension {
|
||||
const { view, schema } = this.editor;
|
||||
const { state } = view;
|
||||
const { from } = state.selection;
|
||||
let tr = state.tr;
|
||||
|
||||
const links: string[] = [];
|
||||
let allLinks = true;
|
||||
@@ -481,26 +480,22 @@ export default class PasteHandler extends Extension {
|
||||
return false;
|
||||
});
|
||||
|
||||
const showPasteMenu = allLinks && links.length;
|
||||
if (!allLinks || !links.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// it's possible that the links can be converted to mentions
|
||||
if (showPasteMenu) {
|
||||
const placeholderId = links[0];
|
||||
const to = from + listNode.nodeSize;
|
||||
const placeholderId = links[0];
|
||||
const to = from + listNode.nodeSize;
|
||||
|
||||
tr = state.tr.replaceSelectionWith(listNode).setMeta(this.key, {
|
||||
const transaction = state.tr
|
||||
.replaceSelectionWith(listNode)
|
||||
.setMeta(this.key, {
|
||||
add: { from, to, id: placeholderId },
|
||||
});
|
||||
} else {
|
||||
// Paste as simple list
|
||||
tr = tr.replaceSelectionWith(listNode, this.shiftKey);
|
||||
}
|
||||
|
||||
view.dispatch(tr);
|
||||
view.dispatch(transaction);
|
||||
|
||||
if (showPasteMenu) {
|
||||
this.showPasteMenu(links);
|
||||
}
|
||||
this.showPasteMenu(links);
|
||||
}
|
||||
|
||||
private placeholderId = () =>
|
||||
|
||||
@@ -54,7 +54,6 @@ import EditorContext from "./components/EditorContext";
|
||||
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
import Lightbox from "~/components/Lightbox";
|
||||
|
||||
export type Props = {
|
||||
/** An optional identifier for the editor context. It is used to persist local settings */
|
||||
@@ -146,8 +145,6 @@ type State = {
|
||||
isEditorFocused: boolean;
|
||||
/** If the toolbar for a text selection is visible */
|
||||
selectionToolbarOpen: boolean;
|
||||
/** Position of image in doc that's being currently viewed in Lightbox */
|
||||
activeLightboxImgPos: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -177,7 +174,6 @@ export class Editor extends React.PureComponent<
|
||||
isRTL: false,
|
||||
isEditorFocused: false,
|
||||
selectionToolbarOpen: false,
|
||||
activeLightboxImgPos: null,
|
||||
};
|
||||
|
||||
isInitialized = false;
|
||||
@@ -498,7 +494,6 @@ 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;
|
||||
}
|
||||
@@ -717,13 +712,6 @@ export class Editor extends React.PureComponent<
|
||||
dispatch(tr);
|
||||
};
|
||||
|
||||
public updateActiveLightbox = (pos: number | null) => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
activeLightboxImgPos: pos,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the plain text content of the current editor.
|
||||
*
|
||||
@@ -843,12 +831,6 @@ export class Editor extends React.PureComponent<
|
||||
)}
|
||||
</Observer>
|
||||
</Flex>
|
||||
{this.state.activeLightboxImgPos && (
|
||||
<Lightbox
|
||||
onUpdate={this.updateActiveLightbox}
|
||||
activePos={this.state.activeLightboxImgPos}
|
||||
/>
|
||||
)}
|
||||
</EditorContext.Provider>
|
||||
</PortalContext.Provider>
|
||||
);
|
||||
|
||||
@@ -30,22 +30,17 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import {
|
||||
isMobile as isMobileDevice,
|
||||
isTouchDevice,
|
||||
} from "@shared/utils/browser";
|
||||
|
||||
export default function formattingMenuItems(
|
||||
state: EditorState,
|
||||
isTemplate: boolean,
|
||||
isMobile: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { schema } = state;
|
||||
const isCode = isInCode(state);
|
||||
const isCodeBlock = isInCode(state, { onlyBlock: true });
|
||||
const isEmpty = state.selection.empty;
|
||||
const isMobile = isMobileDevice();
|
||||
const isTouch = isTouchDevice();
|
||||
|
||||
const highlight = getMarksBetween(
|
||||
state.selection.from,
|
||||
@@ -203,7 +198,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible:
|
||||
isTouch && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentList",
|
||||
@@ -211,21 +206,21 @@ export default function formattingMenuItems(
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible:
|
||||
isTouch && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
},
|
||||
{
|
||||
name: "outdentCheckboxList",
|
||||
tooltip: dictionary.outdent,
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentCheckboxList",
|
||||
tooltip: dictionary.indent,
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -5,15 +5,15 @@ import {
|
||||
AlignCenterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
ArrowIcon,
|
||||
MoreIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
TableSplitCellsIcon,
|
||||
AlphabeticalSortIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { CellSelection, selectedRect } from "prosemirror-tables";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import styled from "styled-components";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import {
|
||||
isMergedCellSelection,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
export default function tableColMenuItems(
|
||||
state: EditorState,
|
||||
@@ -35,8 +34,6 @@ export default function tableColMenuItems(
|
||||
return [];
|
||||
}
|
||||
|
||||
const tableMap = selectedRect(state);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
@@ -78,13 +75,13 @@ export default function tableColMenuItems(
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortAsc,
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <AlphabeticalSortIcon />,
|
||||
icon: <SortAscIcon />,
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortDesc,
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <AlphabeticalReverseSortIcon />,
|
||||
icon: <SortDescIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
@@ -110,23 +107,6 @@ export default function tableColMenuItems(
|
||||
icon: <InsertRightIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: dictionary.moveColumnLeft,
|
||||
icon: <ArrowLeftIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: dictionary.moveColumnRight,
|
||||
icon: <ArrowRightIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.width - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: dictionary.mergeCells,
|
||||
@@ -152,3 +132,11 @@ export default function tableColMenuItems(
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SortAscIcon = styled(ArrowIcon)`
|
||||
transform: rotate(-90deg);
|
||||
`;
|
||||
|
||||
const SortDescIcon = styled(ArrowIcon)`
|
||||
transform: rotate(90deg);
|
||||
`;
|
||||
|
||||
@@ -8,14 +8,13 @@ import {
|
||||
TableMergeCellsIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { CellSelection, selectedRect } from "prosemirror-tables";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
export default function tableRowMenuItems(
|
||||
state: EditorState,
|
||||
@@ -23,13 +22,10 @@ export default function tableRowMenuItems(
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { selection } = state;
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tableMap = selectedRect(state);
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
@@ -52,23 +48,6 @@ export default function tableRowMenuItems(
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: dictionary.moveRowUp,
|
||||
icon: <ArrowUpIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: dictionary.moveRowDown,
|
||||
icon: <ArrowDownIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.height - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: dictionary.mergeCells,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActionContext } from "~/types";
|
||||
|
||||
/**
|
||||
* Hook to get the current action context, an object that is passed to all
|
||||
* action definitions.
|
||||
*
|
||||
* @param overrides Overides of the default action context.
|
||||
* @returns The current action context.
|
||||
*/
|
||||
export default function useActionContext(
|
||||
overrides?: Partial<ActionContext>
|
||||
): ActionContext {
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
return {
|
||||
isContextMenu: false,
|
||||
isCommandBar: false,
|
||||
isButton: false,
|
||||
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
|
||||
activeDocumentId: stores.ui.activeDocumentId,
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
...overrides,
|
||||
location,
|
||||
stores,
|
||||
t,
|
||||
};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React, { createContext, useContext, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActionContext as ActionContextType } from "~/types";
|
||||
|
||||
export const ActionContext = createContext<ActionContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
type ActionContextProviderProps = {
|
||||
children: ReactNode;
|
||||
value?: Partial<ActionContextType>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider that allows overriding the action context at different levels
|
||||
* of the React component tree.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Override context for a command bar
|
||||
* <ActionContextProvider value={{ isCommandBar: true }}>
|
||||
* <CommandBar />
|
||||
* </ActionContextProvider>
|
||||
*
|
||||
* // Nested overrides
|
||||
* <ActionContextProvider value={{ activeCollectionId: "collection-1" }}>
|
||||
* <CollectionView />
|
||||
* <ActionContextProvider value={{ activeDocumentId: "doc-1" }}>
|
||||
* <DocumentView />
|
||||
* </ActionContextProvider>
|
||||
* </ActionContextProvider>
|
||||
* ```
|
||||
*/
|
||||
export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
children,
|
||||
value = {},
|
||||
}: ActionContextProviderProps) {
|
||||
const parentContext = useContext(ActionContext);
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// Create the base context if we don't have a parent context
|
||||
const baseContext: ActionContextType = parentContext ?? {
|
||||
isMenu: false,
|
||||
isCommandBar: false,
|
||||
isButton: false,
|
||||
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
|
||||
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
location,
|
||||
stores,
|
||||
t,
|
||||
};
|
||||
|
||||
// Merge the parent context with the provided overrides
|
||||
const contextValue: ActionContextType = {
|
||||
...baseContext,
|
||||
...value,
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ActionContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hook to get the current action context, an object that is passed to all
|
||||
* action definitions.
|
||||
*
|
||||
* This hook respects the ActionContextProvider hierarchy, merging values from:
|
||||
* 1. Default system context (stores, location, translation)
|
||||
* 2. Parent ActionContextProvider values (if any)
|
||||
* 3. Local overrides parameter (highest priority)
|
||||
*
|
||||
* @param overrides Optional overrides of the action context. These will be
|
||||
* merged with any provider context and take highest priority.
|
||||
* @returns The current action context with all overrides applied.
|
||||
*/
|
||||
export default function useActionContext(
|
||||
overrides?: Partial<ActionContextType>
|
||||
): ActionContextType {
|
||||
const contextValue = useContext(ActionContext);
|
||||
|
||||
// If we have a context value from a provider, use it as the base
|
||||
if (contextValue) {
|
||||
return {
|
||||
...contextValue,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"useActionContext must be used within an ActionContextProvider"
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { CustomTheme } from "@shared/types";
|
||||
import type { Theme } from "~/stores/UiStore";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useStores from "./useStores";
|
||||
import useQuery from "./useQuery";
|
||||
|
||||
/**
|
||||
* Builds a theme based on the current user's preferences, the current device
|
||||
@@ -24,11 +23,9 @@ export default function useBuildTheme(
|
||||
overrideTheme?: Theme
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const params = useQuery();
|
||||
const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`);
|
||||
const isPrinting = useMediaQuery("print");
|
||||
const queryTheme = (params.get("theme") as Theme) || undefined;
|
||||
const resolvedTheme = overrideTheme ?? queryTheme ?? ui.resolvedTheme;
|
||||
const resolvedTheme = overrideTheme ?? ui.resolvedTheme;
|
||||
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -11,14 +11,10 @@ export default function useDictionary() {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
addColumnAfter: t("Insert after"),
|
||||
addColumnBefore: t("Insert before"),
|
||||
moveRowUp: t("Move up"),
|
||||
moveRowDown: t("Move down"),
|
||||
moveColumnLeft: t("Move left"),
|
||||
moveColumnRight: t("Move right"),
|
||||
addRowAfter: t("Insert after"),
|
||||
addRowBefore: t("Insert before"),
|
||||
addColumnAfter: t("Add column after"),
|
||||
addColumnBefore: t("Add column before"),
|
||||
addRowAfter: t("Add row after"),
|
||||
addRowBefore: t("Add row before"),
|
||||
alignCenter: t("Align center"),
|
||||
alignLeft: t("Align left"),
|
||||
alignRight: t("Align right"),
|
||||
@@ -39,7 +35,7 @@ export default function useDictionary() {
|
||||
deleteRow: t("Delete"),
|
||||
deleteTable: t("Delete table"),
|
||||
deleteAttachment: t("Delete file"),
|
||||
dimensions: `${t("Width")} × ${t("Height")}`,
|
||||
dimensions: t("Width x Height"),
|
||||
download: t("Download"),
|
||||
downloadAttachment: t("Download file"),
|
||||
replaceAttachment: t("Replace file"),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import useStores from "./useStores";
|
||||
import { isFirefox } from "@shared/utils/browser";
|
||||
|
||||
type Params = {
|
||||
/** The share ID of the document being viewed, if any */
|
||||
@@ -79,12 +78,6 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
window.open(navigateTo, "_blank");
|
||||
}
|
||||
} else {
|
||||
// Middle-click events in Firefox are not prevented in the same way as other browsers
|
||||
// so we need to explicitly return here to prevent two tabs from being opened when
|
||||
// middle-clicking a link (#10083).
|
||||
if (event?.button === 1 && isFirefox()) {
|
||||
return;
|
||||
}
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { isNumber } from "lodash";
|
||||
import { useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
onSwipeRight: () => void;
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeUp: () => void;
|
||||
onSwipeDown: () => void;
|
||||
};
|
||||
|
||||
export default function useSwipe({
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
}: Props) {
|
||||
const touchXStart = useRef<number>();
|
||||
const touchXEnd = useRef<number>();
|
||||
const touchYStart = useRef<number>();
|
||||
const touchYEnd = useRef<number>();
|
||||
|
||||
const resetTouchPoints = () => {
|
||||
touchXStart.current = undefined;
|
||||
touchXEnd.current = undefined;
|
||||
touchYStart.current = undefined;
|
||||
touchYEnd.current = undefined;
|
||||
};
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
touchXStart.current = e.changedTouches[0].screenX;
|
||||
touchYStart.current = e.changedTouches[0].screenY;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
if (isNumber(touchXStart.current) && isNumber(touchYStart.current)) {
|
||||
touchXEnd.current = e.changedTouches[0].screenX;
|
||||
touchYEnd.current = e.changedTouches[0].screenY;
|
||||
const dx = touchXEnd.current - touchXStart.current;
|
||||
const dy = touchYEnd.current - touchYStart.current;
|
||||
|
||||
const swipeRight = dx > 0 && Math.abs(dy) < Math.abs(dx);
|
||||
if (swipeRight) {
|
||||
resetTouchPoints();
|
||||
return onSwipeRight();
|
||||
}
|
||||
|
||||
const swipeLeft = dx < 0 && Math.abs(dy) < Math.abs(dx);
|
||||
if (swipeLeft) {
|
||||
resetTouchPoints();
|
||||
return onSwipeLeft();
|
||||
}
|
||||
|
||||
const swipeDown = dy > 0 && Math.abs(dy) > Math.abs(dx);
|
||||
if (swipeDown) {
|
||||
resetTouchPoints();
|
||||
return onSwipeDown();
|
||||
}
|
||||
|
||||
const swipeUp = dy < 0 && Math.abs(dy) > Math.abs(dx);
|
||||
if (swipeUp) {
|
||||
resetTouchPoints();
|
||||
return onSwipeUp();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchCancel = () => {
|
||||
resetTouchPoints();
|
||||
};
|
||||
|
||||
return {
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchCancel,
|
||||
};
|
||||
}
|
||||
+21
-24
@@ -25,7 +25,6 @@ import Logger from "./utils/Logger";
|
||||
import { PluginManager } from "./utils/PluginManager";
|
||||
import history from "./utils/history";
|
||||
import { initSentry } from "./utils/sentry";
|
||||
import { ActionContextProvider } from "./hooks/useActionContext";
|
||||
|
||||
// Load plugins as soon as possible
|
||||
void PluginManager.loadPlugins();
|
||||
@@ -53,29 +52,27 @@ if (element) {
|
||||
<HelmetProvider>
|
||||
<Provider {...stores}>
|
||||
<Analytics>
|
||||
<Router history={history}>
|
||||
<Theme>
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<ActionContextProvider>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</ActionContextProvider>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</Router>
|
||||
<Theme>
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</Analytics>
|
||||
</Provider>
|
||||
</HelmetProvider>
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
createDocument,
|
||||
exportCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -130,6 +130,11 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const can = usePolicy(collection);
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeCollectionId: collection.id,
|
||||
});
|
||||
|
||||
const sortAlphabetical = collection.sort.field === "title";
|
||||
const sortDir = collection.sort.direction;
|
||||
|
||||
@@ -223,7 +228,7 @@ function CollectionMenu({
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<>
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
{t("Import document")}
|
||||
@@ -239,6 +244,7 @@ function CollectionMenu({
|
||||
</VisuallyHidden.Root>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
context={context}
|
||||
align={align}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
@@ -249,7 +255,7 @@ function CollectionMenu({
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</ActionContextProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+21
-23
@@ -10,7 +10,7 @@ import Document from "~/models/Document";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import Switch from "~/components/Switch";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -132,6 +132,13 @@ function DocumentMenu({
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId ? document.collectionId : undefined,
|
||||
});
|
||||
|
||||
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
|
||||
if (!can.update || !(showDisplayOptions || showToggleEmbeds)) {
|
||||
return;
|
||||
@@ -196,29 +203,20 @@ function DocumentMenu({
|
||||
]);
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
}}
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
context={context}
|
||||
align={align}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
ariaLabel={t("Document options")}
|
||||
append={toggleSwitches}
|
||||
>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align={align}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
ariaLabel={t("Document options")}
|
||||
append={toggleSwitches}
|
||||
>
|
||||
<OverflowMenuButton
|
||||
neutral={neutral}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</ActionContextProvider>
|
||||
<OverflowMenuButton
|
||||
neutral={neutral}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const NotificationMenu: React.FC = () => {
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Notifications")}>
|
||||
<Button aria-label={t("Notifications")}>
|
||||
<Button>
|
||||
<MoreIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
|
||||
+14
-10
@@ -8,9 +8,9 @@ import {
|
||||
copyLinkToRevision,
|
||||
restoreRevision,
|
||||
} from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMemo } from "react";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -19,6 +19,11 @@ type Props = {
|
||||
|
||||
function RevisionMenu({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
() => [restoreRevision, ActionV2Separator, copyLinkToRevision],
|
||||
[]
|
||||
@@ -27,15 +32,14 @@ function RevisionMenu({ document }: Props) {
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeDocumentId: document.id }}>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Revision options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
</ActionContextProvider>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
context={context}
|
||||
align="end"
|
||||
ariaLabel={t("Revision options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ type Props = {
|
||||
|
||||
const TeamMenu: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext({ isMenu: true });
|
||||
const context = useActionContext({ isContextMenu: true });
|
||||
|
||||
// NOTE: it's useful to memoize on the team id and session because the action
|
||||
// menu is not cached at all.
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -26,18 +25,6 @@ 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,6 +8,7 @@ import { AvatarSize } from "~/components/Avatar";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -23,6 +24,7 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { memberships, groupMemberships, users } = useStores();
|
||||
const collectionUsers = users.inCollection(collection.id);
|
||||
const context = useActionContext();
|
||||
const isMobile = useMobile();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,6 +72,7 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
|
||||
return (
|
||||
<NudeButton
|
||||
context={context}
|
||||
tooltip={{
|
||||
content:
|
||||
usersCount > 0
|
||||
|
||||
@@ -48,7 +48,6 @@ import MembershipPreview from "./components/MembershipPreview";
|
||||
import Notices from "./components/Notices";
|
||||
import Overview from "./components/Overview";
|
||||
import ShareButton from "./components/ShareButton";
|
||||
import first from "lodash/first";
|
||||
|
||||
const IconPicker = lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
@@ -207,7 +206,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
<Suspense fallback={fallbackIcon}>
|
||||
<IconPicker
|
||||
icon={collection.icon ?? "collection"}
|
||||
color={collection.color ?? (first(colorPalette) as string)}
|
||||
color={collection.color ?? colorPalette[0]}
|
||||
initial={collection.initial}
|
||||
size={40}
|
||||
popoverPosition="bottom-start"
|
||||
|
||||
@@ -24,6 +24,7 @@ import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { resolveCommentFactory } from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
@@ -311,12 +312,14 @@ const ResolveButton = ({
|
||||
comment: Comment;
|
||||
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||
}) => {
|
||||
const context = useActionContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Mark as resolved")} placement="top">
|
||||
<Action
|
||||
as={NudeButton}
|
||||
context={context}
|
||||
action={resolveCommentFactory({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowIcon } from "outline-icons";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
@@ -23,8 +24,6 @@ import CommentForm from "./CommentForm";
|
||||
import CommentSortMenu from "./CommentSortMenu";
|
||||
import CommentThread from "./CommentThread";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ArrowDownIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
function Comments() {
|
||||
const { ui, comments, documents } = useStores();
|
||||
@@ -35,8 +34,6 @@ function Comments() {
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const focusedComment = useFocusedComment();
|
||||
const can = usePolicy(document);
|
||||
const isMobile = useMobile();
|
||||
|
||||
const query = useQuery();
|
||||
const [viewingResolved, setViewingResolved] = useState(
|
||||
query.get("resolved") !== null || focusedComment?.isResolved || false
|
||||
@@ -126,73 +123,15 @@ function Comments() {
|
||||
prevThreadCount.current = threads.length;
|
||||
}, [sortOption.type, threads.length, viewingResolved]);
|
||||
|
||||
const content =
|
||||
!document || !isEditorInitialized ? null : (
|
||||
<>
|
||||
<Scrollable
|
||||
id="comments"
|
||||
bottomShadow={!focusedComment}
|
||||
hiddenScrollbars
|
||||
topShadow
|
||||
ref={scrollableRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Wrapper $hasComments={hasComments}>
|
||||
{hasComments ? (
|
||||
threads.map((thread) => (
|
||||
<CommentThread
|
||||
key={thread.id}
|
||||
comment={thread}
|
||||
document={document}
|
||||
recessed={!!focusedComment && focusedComment.id !== thread.id}
|
||||
focused={focusedComment?.id === thread.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoComments align="center" justify="center" auto>
|
||||
<PositionedEmpty>
|
||||
{viewingResolved
|
||||
? t("No resolved comments")
|
||||
: t("No comments yet")}
|
||||
</PositionedEmpty>
|
||||
</NoComments>
|
||||
)}
|
||||
{showJumpToRecentBtn && (
|
||||
<Fade>
|
||||
<JumpToRecent onClick={scrollToBottom}>
|
||||
<Flex align="center">
|
||||
{t("New comments")}
|
||||
<ArrowDownIcon size={20} />
|
||||
</Flex>
|
||||
</JumpToRecent>
|
||||
</Fade>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Scrollable>
|
||||
<AnimatePresence initial={false}>
|
||||
{(!focusedComment || isMobile) && can.comment && !viewingResolved && (
|
||||
<NewCommentForm
|
||||
draft={draft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
documentId={document.id}
|
||||
placeholder={`${t("Add a comment")}…`}
|
||||
autoFocus={false}
|
||||
dir={document.dir}
|
||||
animatePresence
|
||||
standalone
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
if (!document || !isEditorInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
title={
|
||||
<Flex align="center" justify="space-between" gap={8} auto>
|
||||
<div style={isMobile ? { padding: "0 8px" } : undefined}>
|
||||
{t("Comments")}
|
||||
</div>
|
||||
<Flex align="center" justify="space-between" auto>
|
||||
<span>{t("Comments")}</span>
|
||||
<CommentSortMenu
|
||||
viewingResolved={viewingResolved}
|
||||
onChange={(val) => {
|
||||
@@ -204,7 +143,60 @@ function Comments() {
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
scrollable={false}
|
||||
>
|
||||
{content}
|
||||
<Scrollable
|
||||
id="comments"
|
||||
bottomShadow={!focusedComment}
|
||||
hiddenScrollbars
|
||||
topShadow
|
||||
ref={scrollableRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Wrapper $hasComments={hasComments}>
|
||||
{hasComments ? (
|
||||
threads.map((thread) => (
|
||||
<CommentThread
|
||||
key={thread.id}
|
||||
comment={thread}
|
||||
document={document}
|
||||
recessed={!!focusedComment && focusedComment.id !== thread.id}
|
||||
focused={focusedComment?.id === thread.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoComments align="center" justify="center" auto>
|
||||
<PositionedEmpty>
|
||||
{viewingResolved
|
||||
? t("No resolved comments")
|
||||
: t("No comments yet")}
|
||||
</PositionedEmpty>
|
||||
</NoComments>
|
||||
)}
|
||||
{showJumpToRecentBtn && (
|
||||
<Fade>
|
||||
<JumpToRecent onClick={scrollToBottom}>
|
||||
<Flex align="center">
|
||||
{t("New comments")}
|
||||
<ArrowDownIcon size={20} />
|
||||
</Flex>
|
||||
</JumpToRecent>
|
||||
</Fade>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Scrollable>
|
||||
<AnimatePresence initial={false}>
|
||||
{!focusedComment && can.comment && !viewingResolved && (
|
||||
<NewCommentForm
|
||||
draft={draft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
documentId={document.id}
|
||||
placeholder={`${t("Add a comment")}…`}
|
||||
autoFocus={false}
|
||||
dir={document.dir}
|
||||
animatePresence
|
||||
standalone
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +230,10 @@ const JumpToRecent = styled(ButtonSmall)`
|
||||
}
|
||||
`;
|
||||
|
||||
const ArrowDownIcon = styled(ArrowIcon)`
|
||||
transform: rotate(90deg);
|
||||
`;
|
||||
|
||||
const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>`
|
||||
padding: 12px;
|
||||
padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")};
|
||||
|
||||
@@ -9,15 +9,14 @@ import { TeamPreference } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import { openDocumentInsights } from "~/actions/definitions/documents";
|
||||
import DocumentMeta, { Separator } from "~/components/DocumentMeta";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Fade from "~/components/Fade";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = {
|
||||
/* The document to display meta data for */
|
||||
@@ -37,6 +36,9 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].userId;
|
||||
const viewsLoadedOnMount = useRef(totalViewers > 0);
|
||||
const can = usePolicy(document);
|
||||
const actionContext = useActionContext({
|
||||
activeDocumentId: document.id,
|
||||
});
|
||||
|
||||
const Wrapper = viewsLoadedOnMount.current ? Fragment : Fade;
|
||||
|
||||
@@ -47,7 +49,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
<Meta document={document} revision={revision} to={to} replace {...rest}>
|
||||
{commentingEnabled && can.comment && (
|
||||
<>
|
||||
<Separator />
|
||||
•
|
||||
<CommentLink
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
@@ -67,8 +69,10 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
!document.isDraft &&
|
||||
!document.isTemplate ? (
|
||||
<Wrapper>
|
||||
<Separator />
|
||||
<InsightsButton action={openDocumentInsights}>
|
||||
•
|
||||
<InsightsButton
|
||||
onClick={() => openDocumentInsights.perform(actionContext)}
|
||||
>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? t("only you")
|
||||
@@ -87,7 +91,7 @@ const CommentLink = styled(Link)`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const InsightsButton = styled(NudeButton)`
|
||||
const InsightsButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
@@ -109,16 +113,6 @@ export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
line-height: 1.6;
|
||||
|
||||
${Separator} {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
cursor: var(--pointer);
|
||||
|
||||
@@ -23,7 +23,6 @@ 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"));
|
||||
|
||||
@@ -71,7 +70,6 @@ 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();
|
||||
@@ -251,7 +249,6 @@ 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,3 +1,4 @@
|
||||
import last from "lodash/last";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -32,7 +33,6 @@ import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||
import DocumentMeta from "./DocumentMeta";
|
||||
import DocumentTitle from "./DocumentTitle";
|
||||
import first from "lodash/first";
|
||||
|
||||
const extensions = withUIExtensions(withComments(richExtensions));
|
||||
|
||||
@@ -80,7 +80,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const can = usePolicy(document);
|
||||
const commentingEnabled = !!team?.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const iconColor = document.color ?? (first(colorPalette) as string);
|
||||
const iconColor = document.color ?? (last(colorPalette) as string);
|
||||
const childRef = React.useRef<HTMLDivElement>(null);
|
||||
const focusAtStart = React.useCallback(() => {
|
||||
if (ref.current) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import Tooltip from "~/components/Tooltip";
|
||||
import { publishDocument } from "~/actions/definitions/documents";
|
||||
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
|
||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
@@ -108,6 +109,10 @@ function DocumentHeader({
|
||||
}
|
||||
}, [ui, isShare]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
});
|
||||
|
||||
const can = usePolicy(document);
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const isTemplateEditable = can.update && isTemplate;
|
||||
@@ -129,7 +134,6 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
aria-label={t("Show contents")}
|
||||
onClick={handleToggle}
|
||||
icon={<TableOfContentsIcon />}
|
||||
borderOnHover
|
||||
@@ -274,6 +278,7 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
context={context}
|
||||
action={isTemplate ? navigateToTemplateSettings : undefined}
|
||||
onClick={isTemplate ? undefined : handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
@@ -302,7 +307,12 @@ function DocumentHeader({
|
||||
{revision && revision.createdAt !== document.updatedAt && (
|
||||
<Action>
|
||||
<Tooltip content={t("Restore version")} placement="bottom">
|
||||
<Button action={restoreRevision} neutral hideOnActionDisabled>
|
||||
<Button
|
||||
action={restoreRevision}
|
||||
context={context}
|
||||
neutral
|
||||
hideOnActionDisabled
|
||||
>
|
||||
{t("Restore")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -312,6 +322,7 @@ function DocumentHeader({
|
||||
<Action>
|
||||
<Button
|
||||
action={publishDocument}
|
||||
context={context}
|
||||
disabled={publishingIsDisabled}
|
||||
hideOnActionDisabled
|
||||
hideIcon
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
const DocumentEvents = [
|
||||
"documents.publish",
|
||||
@@ -38,7 +37,6 @@ function History() {
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
|
||||
const [eventsOffset, setEventsOffset] = React.useState(0);
|
||||
const isMobile = useMobile();
|
||||
|
||||
const fetchHistory = React.useCallback(async () => {
|
||||
if (!document) {
|
||||
@@ -127,10 +125,6 @@ function History() {
|
||||
}, [revisions, document, revisionEvents, nonRevisionEvents]);
|
||||
|
||||
const onCloseHistory = React.useCallback(() => {
|
||||
if (isMobile) {
|
||||
// Allow closing the history drawer on mobile to view revision content
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
history.push({
|
||||
pathname: documentPath(document),
|
||||
|
||||
@@ -23,11 +23,7 @@ function KeyboardShortcutsButton() {
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
|
||||
<Button
|
||||
onClick={handleOpenKeyboardShortcuts}
|
||||
$hidden={isEditingFocus}
|
||||
aria-label={t("Keyboard shortcuts")}
|
||||
>
|
||||
<Button onClick={handleOpenKeyboardShortcuts} $hidden={isEditingFocus}>
|
||||
<KeyboardIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -3,19 +3,15 @@ import { BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { depths, s, ellipsis } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
import RightSidebar from "~/components/Sidebar/Right";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||
/* The title of the sidebar */
|
||||
@@ -23,7 +19,7 @@ type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||
/* The content of the sidebar */
|
||||
children: React.ReactNode;
|
||||
/* Called when the sidebar is closed */
|
||||
onClose: () => void;
|
||||
onClose: React.MouseEventHandler;
|
||||
/* Whether the sidebar should be scrollable */
|
||||
scrollable?: boolean;
|
||||
};
|
||||
@@ -32,23 +28,8 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const content = scrollable ? (
|
||||
<Scrollable hiddenScrollbars topShadow>
|
||||
{children}
|
||||
</Scrollable>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
<Drawer onClose={onClose} defaultOpen>
|
||||
<DrawerContent>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<RightSidebar>
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Title>{title}</Title>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
@@ -60,11 +41,35 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Header>
|
||||
{content}
|
||||
</RightSidebar>
|
||||
{scrollable ? (
|
||||
<Scrollable hiddenScrollbars topShadow>
|
||||
{children}
|
||||
</Scrollable>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<Portal>
|
||||
<Backdrop onClick={onClose} />
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: default;
|
||||
z-index: ${depths.mobileSidebar - 1};
|
||||
background: ${s("backdrop")};
|
||||
`;
|
||||
|
||||
const ForwardIcon = styled(BackIcon)`
|
||||
transform: rotate(180deg);
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -6,10 +6,12 @@ import Empty from "~/components/Empty";
|
||||
import Heading from "~/components/Heading";
|
||||
import Scene from "~/components/Scene";
|
||||
import { navigateToHome } from "~/actions/definitions/navigation";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
const Error403 = () => {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene title={t("No access to this doc")}>
|
||||
@@ -22,7 +24,7 @@ const Error403 = () => {
|
||||
{t("Please request access from the document owner.")}
|
||||
</Empty>
|
||||
<Flex gap={8}>
|
||||
<Button action={navigateToHome} hideIcon>
|
||||
<Button action={navigateToHome} context={context} hideIcon>
|
||||
{t("Home")}
|
||||
</Button>
|
||||
<Button onClick={history.goBack} neutral>
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
navigateToHome,
|
||||
navigateToSearch,
|
||||
} from "~/actions/definitions/navigation";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
const Error404 = () => {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene title={t("Not found")}>
|
||||
@@ -23,10 +25,10 @@ const Error404 = () => {
|
||||
</Trans>
|
||||
</Empty>
|
||||
<Flex gap={8}>
|
||||
<Button action={navigateToHome} neutral hideIcon>
|
||||
<Button action={navigateToHome} context={context} neutral hideIcon>
|
||||
{t("Home")}
|
||||
</Button>
|
||||
<Button action={navigateToSearch} neutral>
|
||||
<Button action={navigateToSearch} context={context} neutral>
|
||||
{t("Search")}…
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -6,9 +6,11 @@ import Empty from "~/components/Empty";
|
||||
import Heading from "~/components/Heading";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import { navigateToHome } from "~/actions/definitions/navigation";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
const ErrorUnknown = () => {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
@@ -22,7 +24,7 @@ const ErrorUnknown = () => {
|
||||
</Trans>
|
||||
</Empty>
|
||||
<Flex gap={8}>
|
||||
<Button action={navigateToHome} neutral hideIcon>
|
||||
<Button action={navigateToHome} context={context} neutral hideIcon>
|
||||
{t("Home")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -33,7 +33,7 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
|
||||
"Setup your workspace by providing a name and details for admin login. You can change these later."
|
||||
)}
|
||||
</Content>
|
||||
<Inputs column gap={12}>
|
||||
<Flex column gap={12} style={{ width: "100%" }}>
|
||||
<Input
|
||||
name="teamName"
|
||||
type="text"
|
||||
@@ -57,7 +57,7 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
|
||||
required
|
||||
flex
|
||||
/>
|
||||
</Inputs>
|
||||
</Flex>
|
||||
<ButtonLarge type="submit" fullwidth>
|
||||
{t("Continue")} →
|
||||
</ButtonLarge>
|
||||
@@ -66,11 +66,6 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Inputs = styled(Flex)`
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled(Heading)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
@@ -11,6 +11,7 @@ import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import env from "~/env";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -24,6 +25,7 @@ function APIAndApps() {
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys, oauthAuthentications } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
@@ -38,6 +40,7 @@ function APIAndApps() {
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -19,6 +20,7 @@ function ApiKeys() {
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -32,6 +34,7 @@ function ApiKeys() {
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -161,7 +161,6 @@ 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,6 +9,7 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createOAuthClient } from "~/actions/definitions/oauthClients";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -19,6 +20,7 @@ function Applications() {
|
||||
const { t } = useTranslation();
|
||||
const { oauthClients } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -32,6 +34,7 @@ function Applications() {
|
||||
type="submit"
|
||||
value={`${t("New App")}…`}
|
||||
action={createOAuthClient}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -193,7 +193,6 @@ function Details() {
|
||||
)}
|
||||
>
|
||||
<ImageInput
|
||||
alt={t("Workspace logo")}
|
||||
onSuccess={handleAvatarChange}
|
||||
onError={handleAvatarError}
|
||||
model={team}
|
||||
|
||||
@@ -16,6 +16,7 @@ import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { inviteUser } from "~/actions/definitions/users";
|
||||
import env from "~/env";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
@@ -31,6 +32,7 @@ function Members() {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
const context = useActionContext();
|
||||
const { users } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const params = useQuery();
|
||||
@@ -126,6 +128,7 @@ function Members() {
|
||||
data-event-category="invite"
|
||||
data-event-action="peoplePage"
|
||||
action={inviteUser}
|
||||
context={context}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("Invite people")}…
|
||||
|
||||
@@ -72,7 +72,6 @@ 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("Group admin"),
|
||||
label: t("Manage"),
|
||||
value: GroupPermission.Admin,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -59,7 +59,7 @@ export function GroupsTable(props: Props) {
|
||||
<Title onClick={() => handleViewMembers(group)}>
|
||||
{group.name}
|
||||
</Title>
|
||||
<Text type="tertiary" size="small" weight="normal">
|
||||
<Text type="tertiary" size="small">
|
||||
<Trans
|
||||
defaults="{{ count }} member"
|
||||
values={{ count: group.memberCount }}
|
||||
@@ -97,30 +97,6 @@ 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,10 +10,9 @@ import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload";
|
||||
|
||||
type Props = ImageUploadProps & {
|
||||
model: IAvatar;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
|
||||
export default function ImageInput({ model, onSuccess, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -28,7 +27,6 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
|
||||
model={model}
|
||||
size={AvatarSize.Upload}
|
||||
variant={AvatarVariant.Square}
|
||||
alt={alt}
|
||||
/>
|
||||
<Flex auto align="center" justify="center" className="upload">
|
||||
<EditIcon />
|
||||
|
||||
@@ -222,8 +222,6 @@ function SharedScene() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasSidebar = !!share.tree?.children.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -240,10 +238,7 @@ function SharedScene() {
|
||||
<TeamContext.Provider value={team}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<DocumentContextProvider>
|
||||
<Layout
|
||||
title={pageTitle}
|
||||
sidebar={hasSidebar ? <Sidebar share={share} /> : null}
|
||||
>
|
||||
<Layout title={pageTitle} sidebar={<Sidebar share={share} />}>
|
||||
{model instanceof Document ? (
|
||||
<DocumentScene
|
||||
document={model}
|
||||
|
||||
@@ -8,19 +8,24 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import { permanentlyDeleteDocumentsInTrash } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function Trash() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
|
||||
const context = useActionContext();
|
||||
return (
|
||||
<Scene
|
||||
icon={<TrashIcon />}
|
||||
title={t("Trash")}
|
||||
actions={
|
||||
documents.deleted.length > 0 && (
|
||||
<Button neutral action={permanentlyDeleteDocumentsInTrash}>
|
||||
<Button
|
||||
neutral
|
||||
action={permanentlyDeleteDocumentsInTrash}
|
||||
context={context}
|
||||
>
|
||||
{t("Empty trash")}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -204,10 +204,10 @@ export default class DocumentsStore extends Store<Document> {
|
||||
}
|
||||
|
||||
get(id: string): Document | undefined {
|
||||
return id
|
||||
? (this.data.get(id) ??
|
||||
this.orderedData.find((doc) => id.endsWith(doc.urlId)))
|
||||
: undefined;
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((doc) => id.endsWith(doc.urlId))
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -167,9 +167,9 @@ export default class SharesStore extends Store<Share> {
|
||||
find(this.orderedData, (share) => share.documentId === documentId);
|
||||
|
||||
get(id: string): Share | undefined {
|
||||
return id
|
||||
? (this.data.get(id) ??
|
||||
this.orderedData.find((share) => id.endsWith(share.urlId)))
|
||||
: undefined;
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((share) => id.endsWith(share.urlId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user