Compare commits

..

20 Commits

Author SHA1 Message Date
codegen-sh[bot] b378af2294 Fix linting issues in OIDCStrategy 2025-08-19 00:15:29 +00:00
codegen-sh[bot] 5fec2681da Fix TypeScript errors in OIDCStrategy 2025-08-19 00:11:01 +00:00
codegen-sh[bot] 64f51ee40d Fix linting issues: replace any with unknown types and add missing React keys 2025-08-19 00:09:05 +00:00
codegen-sh[bot] fec0f193a8 Fix linting issues 2025-08-19 00:02:14 +00:00
codegen-sh[bot] b689ebd8ca fix: Update GitLabIssueStatusIcon to match style of other icon components 2025-08-18 23:49:32 +00:00
codegen-sh[bot] 8e74bb7d01 fix: add type guard for draft property in GitLabIssueStatusIcon 2025-08-18 23:20:56 +00:00
codegen-sh[bot] c02bc22cce Fix code formatting issues to pass CI checks 2025-08-18 23:16:18 +00:00
codegen-sh[bot] 7ea308a52c Fix linting issues in React Hook dependencies 2025-08-18 23:15:39 +00:00
codegen-sh[bot] e4d1a38367 fix: Fix TypeScript errors in OIDCStrategy
- Change Record<string, unknown> to any to match the Strategy interface
- Add null check for options parameter
2025-08-18 23:15:22 +00:00
codegen-sh[bot] 98d54da0de fix: Remove trailing whitespace in HoverPreviewIssue.tsx 2025-08-18 23:03:21 +00:00
codegen-sh[bot] 4b638ae346 fix: fix remaining linting errors 2025-08-18 22:38:22 +00:00
codegen-sh[bot] abb849e1f6 fix: replace any with unknown in types and add proper type definitions 2025-08-18 22:30:48 +00:00
codegen-sh[bot] 2ec65e3dfc fix: replace any with unknown in MutexLock 2025-08-18 22:27:16 +00:00
codegen-sh[bot] 4e493972e5 fix: fix remaining linting errors 2025-08-18 22:24:07 +00:00
codegen-sh[bot] 4a8b8d5fa7 fix: replace any types with unknown to fix linting errors 2025-08-18 22:19:11 +00:00
codegen-sh[bot] 391fc5fdee fix: replace any types in MultiplayerEditor and SlackUtils 2025-08-18 22:15:50 +00:00
codegen-sh[bot] cbcf7d6a8e fix: replace more any types with more specific types to fix linting errors 2025-08-18 22:15:05 +00:00
codegen-sh[bot] 94eb1aa07d fix: replace any types with more specific types to fix linting errors 2025-08-18 22:13:36 +00:00
codegen-sh[bot] ca66a6b2fa fix: Fix linting issues in GitLab integration and OIDC strategy
- Remove unused IntegrationService import in UploadGitLabProjectAvatarTask
- Replace 'any' type with proper types in gitlab.ts and OIDCStrategy.ts
2025-08-18 21:59:03 +00:00
codegen-sh[bot] 404a5991b3 feat: Add GitLab integration matching Linear integration patterns 2025-08-18 21:47:16 +00:00
458 changed files with 8074 additions and 10919 deletions
+4
View File
@@ -211,6 +211,10 @@ GITHUB_APP_PRIVATE_KEY=
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# The GitLab integration allows previewing issue and merge request links as rich mentions
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
-3
View File
@@ -26,6 +26,3 @@ updates:
aws:
patterns:
- "@aws-sdk/*"
radix-ui:
patterns:
- "@radix-ui/*"
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint --quiet
- run: yarn lint
types:
needs: build
+8 -4
View File
@@ -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",
+1 -1
View File
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
var path = require('path');
module.exports = {
'config': path.resolve('server/config', 'database.js'),
'config': path.resolve('server/config', 'database.json'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
}
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.87.4
Licensed Work: Outline 0.86.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-09
Change License: Apache License, Version 2.0
+11 -12
View File
@@ -7,13 +7,14 @@
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
@@ -50,14 +51,13 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
```shell
# To run all tests
@@ -68,14 +68,14 @@ make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly with jest:
frontend and backend tests directly.
```shell
# To run backend tests
yarn test:server
# To run a specific backend test in watch mode
yarn test path/to/file.test.ts --watch
# To run a specific backend test
yarn test:server myTestFile
# To run frontend tests
yarn test:app
@@ -86,15 +86,14 @@ yarn test:app
Sequelize is used to create and run migrations, for example:
```shell
yarn db:create-migration --name my-migration
yarn db:migrate
yarn db:rollback
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
Or, to run migrations on test database:
Or to run migrations on test database:
```shell
yarn db:migrate --env test
yarn sequelize db:migrate --env test
```
# Activity
+7
View File
@@ -13,6 +13,13 @@
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
"importNames": ["useMenuState"],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
]
+2 -2
View File
@@ -27,8 +27,8 @@ export const createApiKey = createAction({
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createActionV2({
name: ({ t, isMenu }) =>
isMenu
name: ({ t, isContextMenu }) =>
isContextMenu
? apiKey.isExpired
? t("Delete")
: `${t("Revoke")}`
+82 -31
View File
@@ -3,10 +3,12 @@ import {
CollectionIcon,
EditIcon,
ExportIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
StarredIcon,
SubscribeIcon,
TrashIcon,
@@ -29,7 +31,11 @@ import {
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import { searchPath } from "~/utils/routeHelpers";
import {
newDocumentPath,
newTemplatePath,
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
@@ -75,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 />,
@@ -100,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 />,
@@ -343,6 +350,40 @@ export const restoreCollection = createActionV2({
},
});
export const deleteCollection = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Delete collection"),
content: (
<CollectionDeleteDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const exportCollection = createActionV2({
name: ({ t }) => `${t("Export")}`,
analyticsName: "Export collection",
@@ -379,37 +420,47 @@ export const exportCollection = createActionV2({
},
});
export const deleteCollection = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
export const createDocument = createInternalLinkActionV2({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
});
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
export const createTemplate = createInternalLinkActionV2({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
stores.dialogs.openModal({
title: t("Delete collection"),
content: (
<CollectionDeleteDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
return {
pathname,
search,
state: { sidebarContext },
};
},
});
+21
View File
@@ -3,6 +3,7 @@ import { toast } from "sonner";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
@@ -49,6 +50,16 @@ export const resolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onResolve();
toast.success(t("Thread resolved"));
},
@@ -71,6 +82,16 @@ export const unresolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onUnresolve();
},
});
+108 -24
View File
@@ -43,12 +43,12 @@ import {
import { getEventFiles } from "@shared/utils/files";
import UserMembership from "~/models/UserMembership";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
import DocumentCopy from "~/components/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -121,12 +121,18 @@ export const editDocument = createInternalLinkActionV2({
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, policies } = stores;
const { auth, documents, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return !!can?.update && !!auth.user?.separateEditMode;
return (
!!can?.update && !!auth.user?.separateEditMode && !document?.template
);
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
@@ -140,7 +146,7 @@ export const editDocument = createInternalLinkActionV2({
},
});
export const createDocument = createActionV2({
export const createDocument = createAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: DocumentSection,
@@ -194,7 +200,12 @@ export const createDocumentFromTemplate = createInternalLinkActionV2({
? stores.documents.get(activeDocumentId)
: undefined;
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
return false;
}
@@ -319,7 +330,7 @@ export const publishDocument = createActionV2({
return;
}
if (document?.collectionId) {
if (document?.collectionId || document?.template) {
await document.save(undefined, {
publish: true,
});
@@ -373,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;
}
@@ -382,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;
}
@@ -419,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;
}
@@ -428,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;
}
@@ -560,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 />,
@@ -666,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 />,
@@ -816,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 />,
@@ -883,7 +897,7 @@ export const createTemplateFromDocument = createActionV2({
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document?.isActive) {
if (document?.isTemplate || !document?.isActive) {
return false;
}
return !!(
@@ -935,8 +949,46 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createActionV2({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createActionV2({
name: ({ t }) => t("Move"),
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
@@ -974,7 +1026,8 @@ export const moveDocument = createActionV2({
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
@@ -982,6 +1035,25 @@ export const moveDocument = createActionV2({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionV2WithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createActionV2({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
@@ -1040,7 +1112,10 @@ export const restoreDocument = createActionV2({
: undefined;
const can = stores.policies.abilities(document.id);
return !!collection?.isActive && !!(can.restore || can.unarchive);
return (
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
@@ -1077,7 +1152,10 @@ export const restoreDocumentToCollection = createActionV2WithChildren({
? stores.collections.get(document.collectionId)
: undefined;
return !collection?.isActive && !!(can.restore || can.unarchive);
return (
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
@@ -1261,7 +1339,12 @@ export const openDocumentInsights = createActionV2({
? stores.documents.get(activeDocumentId)
: undefined;
return !!activeDocumentId && can.listViews && !document?.isDeleted;
return (
!!activeDocumentId &&
can.listViews &&
!document?.isTemplate &&
!document?.isDeleted
);
},
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
@@ -1358,6 +1441,7 @@ export const rootDocumentActions = [
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
+2 -2
View File
@@ -131,8 +131,8 @@ export const navigateToTemplateSettings = createAction({
});
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isMenu }) =>
isMenu ? t("Notification settings") : t("Notifications"),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
section: NavigationSection,
iconInContextMenu: false,
+2 -1
View File
@@ -37,7 +37,8 @@ export const changeToSystemTheme = createActionV2({
});
export const changeTheme = createActionV2WithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: ({ stores }) =>
-103
View File
@@ -1,103 +0,0 @@
import { MoveIcon, PlusIcon, TrashIcon } from "outline-icons";
import { Trans } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
import { TemplateNew } from "~/components/Template/TemplateNew";
import { createActionV2 } from "~/actions";
import { ActiveTemplateSection, TemplateSection } from "../sections";
export const createTemplate = createActionV2({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: TemplateSection,
icon: <PlusIcon />,
keywords: "new create template",
visible: ({ currentTeamId, stores }) =>
!!stores.policies.abilities(currentTeamId!).createTemplate,
perform: ({ stores, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: "",
content: <TemplateNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const deleteTemplate = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete template",
section: ActiveTemplateSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ activeTemplateId, stores }) => {
if (!activeTemplateId) {
return false;
}
return !!stores.policies.abilities(activeTemplateId).delete;
},
perform: ({ activeTemplateId, stores, t }) => {
if (activeTemplateId) {
const template = stores.templates.get(activeTemplateId);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: t("template"),
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await template.delete();
toast.success(t("Template deleted"));
}}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
values={{
templateName: template.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
}
},
});
export const moveTemplate = createActionV2({
name: ({ t }) => t("Move"),
analyticsName: "Move template",
section: ActiveTemplateSection,
icon: <MoveIcon />,
visible: ({ activeTemplateId, stores }) => {
if (!activeTemplateId) {
return false;
}
return !!stores.policies.abilities(activeTemplateId).move;
},
perform: ({ activeTemplateId, stores, t }) => {
if (activeTemplateId) {
const template = stores.templates.get(activeTemplateId);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Move template"),
content: <TemplateMove template={template} />,
});
}
},
});
export const rootTemplateActions = [moveTemplate];
+2 -4
View File
@@ -6,18 +6,16 @@ import { rootNotificationActions } from "./definitions/notifications";
import { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootTemplateActions } from "./definitions/templates";
import { rootUserActions } from "./definitions/users";
export default [
...rootCollectionActions,
...rootDeveloperActions,
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootNotificationActions,
...rootRevisionActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
...rootTemplateActions,
...rootUserActions,
];
-7
View File
@@ -46,13 +46,6 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const TemplateSection = ({ t }: ActionContext) => t("Template");
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
const activeTemplate = stores.templates.active;
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
};
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
+17 -10
View File
@@ -3,8 +3,12 @@ import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction, performActionV2, resolve } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
import useActionContext from "~/hooks/useActionContext";
import {
Action,
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
} from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -13,6 +17,8 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
/** Context of action, must be provided with action */
context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
@@ -22,20 +28,22 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function _ActionButton(
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
const actionContext = useActionContext({
isButton: true,
});
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (!actionContext || !action) {
if (action && !context) {
throw new Error("Context must be provided with action");
}
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
const actionContext = { ...context, isButton: true };
if (
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
@@ -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
+2 -1
View File
@@ -6,6 +6,7 @@ import Flex from "~/components/Flex";
export const Action = styled(Flex)`
justify-content: center;
align-items: center;
padding: 0 0 0 12px;
height: 32px;
font-size: 15px;
flex-shrink: 0;
@@ -17,6 +18,7 @@ export const Action = styled(Flex)`
export const Separator = styled.div`
flex-shrink: 0;
margin-left: 12px;
width: 1px;
height: 28px;
background: ${s("divider")};
@@ -31,7 +33,6 @@ const Actions = styled(Flex)`
background: ${s("background")};
padding: 12px;
backdrop-filter: blur(20px);
gap: 12px;
@media print {
display: none;
+7 -4
View File
@@ -13,6 +13,7 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
@@ -108,10 +109,12 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
</Route>
)}
</AnimatePresence>
+1 -4
View File
@@ -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>
</>
+1 -1
View File
@@ -25,7 +25,7 @@ function Breadcrumb(
{ actions, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const actionContext = useActionContext({ isMenu: true });
const actionContext = useActionContext({ isContextMenu: true });
const visibleActions = useComputed(
() =>
+1 -2
View File
@@ -125,14 +125,13 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
{...rest}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
alt={t("Avatar of {{ name }}", { name: collaborator.name })}
onClick={
isObservable
? handleAvatarClick(
@@ -12,15 +12,15 @@ import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { templates } = useStores();
const { documents } = useStores();
useEffect(() => {
void templates.fetchAll();
}, [templates]);
void documents.fetchAllTemplates();
}, [documents]);
const actions = useMemo(
() =>
templates.orderedData.map((template) =>
documents.templatesAlphabetical.map((template) =>
createAction({
name: template.titleWithDefault,
analyticsName: "New document",
@@ -58,7 +58,7 @@ const useTemplatesAction = () => {
),
})
),
[templates.orderedData]
[documents.templatesAlphabetical]
);
const newFromTemplate = useMemo(
+3 -3
View File
@@ -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}
@@ -182,6 +181,7 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
}
const Content = styled.span`
background: ${s("background")};
color: ${s("text")};
-webkit-text-fill-color: ${s("text")};
outline: none;
+13
View File
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("sidebarText")};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export default MenuIconWrapper;
+217
View File
@@ -0,0 +1,217 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
href?: string;
target?: string;
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
disabled,
as,
hide,
icon,
...rest
}: Props,
ref: React.Ref<HTMLAnchorElement>
) => {
const content = React.useCallback(
(props) => {
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const handleClick = async (ev: React.MouseEvent) => {
hide?.();
if (onClick) {
preventDefault(ev);
await onClick(ev);
}
};
return (
<MenuAnchor
{...props}
$active={active}
as={onClick ? "button" : as}
onClick={handleClick}
onPointerDown={preventDefault}
onMouseDown={preventDefault}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
])}
>
{selected !== undefined && (
<SelectedWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
</MenuAnchor>
);
},
[active, as, hide, icon, onClick, ref, children, selected]
);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
>
{content}
</BaseMenuItem>
);
};
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
$active?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
display: flex;
margin: 0;
border: 0;
padding: 12px;
border-radius: 4px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 16px;
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) => props.disabled && "pointer-events: none;"}
${(props) =>
props.$active === undefined &&
!props.disabled &&
`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
}
}
`}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${props.theme.accentText};
}
`}
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`}
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
@@ -0,0 +1,70 @@
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = {
/** Sub-menu x */
x: number;
/** Sub-menu y */
y: number;
/** Sub-menu height */
h: number;
/** Sub-menu width */
w: number;
/** Mouse x */
mouseX: number;
/** Mouse y */
mouseY: number;
};
/**
* Component to cover the area between the mouse cursor and the sub-menu, to
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
x = 0,
y = 0,
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const [mouseX, mouseY] = useMousePosition();
const positions = { x, y, h, w, mouseX, mouseY };
return (
<div
style={{
position: "absolute",
top: 0,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
@@ -0,0 +1,20 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton className={className} {...props}>
<MoreIcon />
</NudeButton>
)}
</MenuButton>
);
}
+15
View File
@@ -0,0 +1,15 @@
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 6px 0;
`;
+264
View File
@@ -0,0 +1,264 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import {
Action,
ActionContext,
MenuSeparator,
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
`;
type SubMenuProps = MenuStateReturn & {
templateItems: TMenuItem[];
parentMenuState: Omit<MenuStateReturn, "items">;
title: React.ReactNode;
};
const SubMenu = React.forwardRef(function _Template(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState({
parentId: parentMenuState.baseId,
});
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isContextMenu: true,
});
const templateItems = actions
? actions.map((item) =>
item.type === "separator" || item.type === "heading"
? item
: actionToMenuItem(item, ctx)
)
: items || [];
const filteredTemplates = filterTemplateItems(templateItems);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) =>
item.type !== "separator" && item.type !== "heading" && !!item.icon
);
return (
<>
{filteredTemplates.map((item, index) => {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading" &&
showIcons !== false
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
if (item.type === "route") {
return (
<MenuItem
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "link") {
return (
<MenuItem
id={`${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={
typeof item.href === "string" ? undefined : item.href.target
}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "button") {
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={`${item.type}-${item.title}-${index}`}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
// Skip rendering empty submenus
return item.items.length > 0 ? (
<BaseMenuItem
key={`${item.type}-${item.title}-${index}`}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
) : null;
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
}
// This should never be reached for Reakit dropdown menu.
// Added for exhaustiveness check.
if (item.type === "group") {
return null;
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
</>
);
}
function Title({
title,
icon,
}: {
title: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
+317
View File
@@ -0,0 +1,317 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuStateReturn } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "~/styles/animations";
export type Placement =
| "auto-start"
| "auto"
| "auto-end"
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "bottom-end"
| "bottom"
| "bottom-start"
| "left-end"
| "left"
| "left-start";
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
onOpen?: () => void;
/** Called when the context menu is closed. */
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode;
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
parentMenuState,
...rest
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
const isSubMenu = !!parentMenuState;
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
onOpen?.();
if (!isSubMenu) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
onClose?.();
if (!isSubMenu) {
setIsMenuOpen(false);
}
}
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
isSubMenu,
t,
]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
}
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
</Menu>
</>
);
};
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = props;
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor =
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
if (scrollElement && !props.isSubMenu) {
enableBodyScroll(scrollElement);
}
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
maxHeight,
}
: undefined;
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={style}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu;
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${s("backdrop")};
z-index: ${depths.menu - 1};
`;
export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
}
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
top: auto !important;
right: 8px !important;
bottom: 16px !important;
left: 8px !important;
`};
`;
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px;
min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px;
max-height: 75vh;
font-weight: normal;
@media print {
display: none;
}
${breakpoint("tablet")`
animation: ${(props: BackgroundProps) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
max-height: 100vh;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
`;
+9 -2
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -11,7 +11,7 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, trashPath } from "~/utils/routeHelpers";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
@@ -67,6 +67,13 @@ function DocumentBreadcrumb(
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkActionV2({
name: collection?.name,
section: ActiveDocumentSection,
+1 -16
View File
@@ -2,25 +2,18 @@ import { action, computed, observable } from "mobx";
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import Document from "~/models/Document";
import Template from "~/models/Template";
import { Editor } from "~/editor";
class DocumentContext {
/** The current document */
document?: Document | Template;
document?: Document;
/** The editor instance for this document */
editor?: Editor;
/** The ID of the currently focused comment, or null if no comment is focused */
@observable
focusedCommentId: string | null = null;
/** Whether the editor has been initialized */
@observable
isEditorInitialized: boolean = false;
/** The headings in the document */
@observable
headings: Heading[] = [];
@@ -46,11 +39,6 @@ class DocumentContext {
this.isEditorInitialized = initialized;
};
@action
setFocusedCommentId = (commentId: string | null) => {
this.focusedCommentId = commentId;
};
@action
updateState = () => {
this.updateHeadings();
@@ -69,9 +57,6 @@ class DocumentContext {
}
private updateTasks() {
if (this.document instanceof Template) {
return;
}
const tasks = this.editor?.getTasks() ?? [];
const total = tasks.length ?? 0;
const completed = tasks.filter((t) => t.completed).length ?? 0;
@@ -5,13 +5,13 @@ import { toast } from "sonner";
import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
@@ -36,8 +36,13 @@ function DocumentCopy({ document, onSubmit }: Props) {
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees]);
}, [policies, collectionTrees, document.isTemplate]);
const copy = async () => {
if (!selectedPath) {
@@ -71,36 +76,34 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<OptionsContainer>
{document instanceof Document && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</>
)}
</OptionsContainer>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
@@ -110,7 +113,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
) : (
t("Select a location to copy")
)}
</Text>
</StyledText>
<Button disabled={!selectedPath} onClick={copy}>
{t("Copy")}
</Button>
@@ -17,6 +17,8 @@ import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
@@ -25,8 +27,6 @@ import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { ancestors, descendants, flattenTree } from "~/utils/tree";
import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import flatten from "lodash/flatten";
type Props = {
@@ -38,17 +38,9 @@ type Props = {
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
/** Whether to show child documents */
showDocuments?: boolean;
};
function DocumentExplorer({
onSubmit,
onSelect,
items,
showDocuments,
defaultValue,
}: Props) {
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -138,11 +130,10 @@ function DocumentExplorer({
}
const nodes = getNodes();
const baseDepth =
nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
) - 1;
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const scrollNodeIntoView = React.useCallback(
@@ -218,7 +209,7 @@ function DocumentExplorer({
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || showDocuments !== false;
nodes[node].children.length > 0 || nodes[node].type === "collection";
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -258,7 +249,7 @@ function DocumentExplorer({
path;
if (isCollection) {
const col = collections.get(node.id);
const col = collections.get(node.collectionId as string);
renderedIcon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
@@ -1,17 +0,0 @@
import styled from "styled-components";
import Flex from "../Flex";
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
flex-shrink: 0;
`;
@@ -1,90 +0,0 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { NavigationNode } from "@shared/types";
import Template from "~/models/Template";
import Button from "~/components/Button";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
template: Template;
};
function TemplateMove({ template }: Props) {
const { dialogs, collections, policies } = useStores();
const { t } = useTranslation();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(
() =>
flatten(
collections.orderedData.map((collection) => ({
...collection.asNavigationNode,
children: [],
}))
)
// Filter out collections that we don't have permission to create documents in.
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
),
[policies, collections.orderedData]
);
const move = async () => {
if (!selectedPath) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = selectedPath.collectionId as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the template, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={move}
onSelect={selectPath}
showDocuments={false}
/>
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
);
}
export default observer(TemplateMove);
-3
View File
@@ -1,3 +0,0 @@
import DocumentExplorer from "./DocumentExplorer";
export default DocumentExplorer;
@@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import { Node as SearchResult } from "./DocumentExplorerNode";
type Props = {
selected: boolean;
+74 -98
View File
@@ -25,10 +25,6 @@ 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 { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
import useStores from "~/hooks/useStores";
type Props = {
document: Document;
@@ -38,6 +34,7 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -53,7 +50,6 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const { userMemberships, groupMemberships } = useStores();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -72,6 +68,7 @@ function DocumentListItem(
showCollection,
showPublished,
showDraft = true,
showTemplate,
highlight,
context,
...rest
@@ -79,11 +76,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
groupMemberships.getByDocumentId(document.id)
);
const canStar = !document.isArchived && !document.isTemplate;
const sidebarContext = determineSidebarContext({
document,
@@ -91,96 +84,81 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const contextMenuAction = useDocumentMenuAction({ document });
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
<DocumentLink
ref={itemRef}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<ContextMenu
action={contextMenuAction}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
>
<DocumentLink
ref={itemRef}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
{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>
)}
{!document.isArchived && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
</Heading>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
</ContextMenu>
</ActionContextProvider>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
);
}
@@ -274,7 +252,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;
@@ -284,8 +262,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)`
+15 -18
View File
@@ -52,6 +52,7 @@ const DocumentMeta: React.FC<Props> = ({
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -141,6 +142,7 @@ const DocumentMeta: React.FC<Props> = ({
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -153,22 +155,26 @@ const DocumentMeta: React.FC<Props> = ({
}
return (
<Viewed>
<Separator />
<Modified highlight>{t("Never viewed")}</Modified>
&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
</Viewed>
);
}
return (
<Viewed>
<Separator />
{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</Viewed>
);
};
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}
@@ -186,17 +192,16 @@ const DocumentMeta: React.FC<Props> = ({
)}
{showParentDocuments && nestedDocumentsCount > 0 && (
<span>
<Separator />
{nestedDocumentsCount}{" "}
&nbsp; {nestedDocumentsCount}{" "}
{t("nested document", {
count: nestedDocumentsCount,
})}
</span>
)}
{timeSinceNow()}
{isTasks && (
&nbsp;{timeSinceNow()}
{canShowProgressBar && (
<>
<Separator />
&nbsp;&nbsp;
<DocumentTasks document={document} />
</>
)}
@@ -205,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;
`;
+3 -7
View File
@@ -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")};
+78 -49
View File
@@ -1,19 +1,22 @@
import deburr from "lodash/deburr";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Text from "~/components/Text";
import { useMenuState } from "~/hooks/useMenuState";
import Input, { NativeInput, Outline } from "./Input";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
interface TFilterOption extends PaginatedItem {
key: string;
label: string;
note?: string;
icon?: React.ReactNode;
}
@@ -31,17 +34,19 @@ type Props = {
const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
className,
onSelect,
showFilter,
fetchQuery,
fetchQueryOptions,
...rest
}: Props) => {
const { t } = useTranslation();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const [open, setOpen] = React.useState(false);
const menu = useMenuState({
modal: false,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
);
@@ -53,26 +58,32 @@ const FilterOptions = ({
const renderItem = React.useCallback(
(option) => (
<MenuButton
<MenuItem
key={option.key}
icon={option.icon}
label={option.label}
onClick={() => {
onSelect(option.key);
setOpen(false);
menu.hide();
}}
selected={selectedKeys.includes(option.key)}
/>
{...menu}
>
{option.icon}
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
),
[onSelect, selectedKeys]
[menu, onSelect, selectedKeys]
);
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
},
[]
);
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
};
const filteredOptions = React.useMemo(() => {
const normalizedQuery = deburr(query.toLowerCase());
@@ -110,13 +121,13 @@ const FilterOptions = ({
switch (ev.key) {
case "Escape":
setOpen(false);
menu.hide();
break;
case "Enter":
if (filteredOptions.length === 1) {
ev.preventDefault();
onSelect(filteredOptions[0].key);
setOpen(false);
menu.hide();
}
break;
case "ArrowDown":
@@ -127,7 +138,7 @@ const FilterOptions = ({
break;
}
},
[filteredOptions, onSelect]
[filteredOptions, menu, onSelect]
);
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
@@ -139,21 +150,21 @@ const FilterOptions = ({
}, []);
React.useEffect(() => {
if (open) {
if (menu.visible) {
searchInputRef.current?.focus();
} else {
setQuery("");
}
}, [open]);
}, [menu.visible]);
const showFilterInput = showFilter || options.length > 10;
const defaultLabel = rest.defaultLabel || t("Filter options");
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger>
<>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
@@ -161,31 +172,31 @@ const FilterOptions = ({
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
</MenuTrigger>
<MenuContent aria-label={defaultLabel} align="start">
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
/>
)}
</MenuContent>
</Menu>
</MenuProvider>
)}
</ContextMenu>
</>
);
};
@@ -231,6 +242,24 @@ const SearchInput = styled(Input)`
}
`;
const Note = styled(Text)`
display: block;
margin: 2px 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 500;
color: ${s("textTertiary")};
`;
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
&:hover ${Note} {
color: ${(props) => props.theme.white50};
}
`;
export const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
-1
View File
@@ -125,7 +125,6 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
gap: 12px;
${breakpoint("tablet")`
position: unset;
@@ -30,10 +30,15 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
) {
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
let service;
if (urlObj.hostname === "github.com") {
service = IntegrationService.GitHub;
} else if (urlObj.hostname === "gitlab.com") {
service = IntegrationService.GitLab;
} else {
service = IntegrationService.Linear;
}
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
-16
View File
@@ -1,16 +0,0 @@
import { ArrowIcon as ArrowRightIcon } from "outline-icons";
import styled from "styled-components";
export { ArrowIcon as ArrowRightIcon } from "outline-icons";
export const ArrowUpIcon = styled(ArrowRightIcon)`
transform: rotate(-90deg);
`;
export const ArrowDownIcon = styled(ArrowRightIcon)`
transform: rotate(90deg);
`;
export const ArrowLeftIcon = styled(ArrowRightIcon)`
transform: rotate(180deg);
`;
+3 -2
View File
@@ -6,6 +6,7 @@ import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import NudeButton from "./NudeButton";
@@ -218,9 +219,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
);
const renderOption = React.useCallback(
(option: Option, idx: number) => {
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator key={`separator-${idx}`} />;
return <Separator />;
}
const isSelected = option === selectedOption;
+30 -27
View File
@@ -12,6 +12,7 @@ import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
type Props = {
@@ -37,39 +38,41 @@ const Layout = React.forwardRef(function Layout_(
});
return (
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<MenuProvider>
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<SkipNavLink />
<SkipNavLink />
{ui.progressBarVisible && <LoadingIndicatorBar />}
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
{sidebar}
<Container auto>
<MenuProvider>{sidebar}</MenuProvider>
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
{sidebarRight}
{sidebarRight}
</Container>
</Container>
</Container>
</MenuProvider>
);
});
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<any>> {
export interface LazyComponent<T extends React.ComponentType<unknown>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
export function createLazyComponent<T extends React.ComponentType<unknown>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
-823
View File
@@ -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);
-93
View File
@@ -1,93 +0,0 @@
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 { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
ariaLabel: string;
/** Callback when menu is opened */
onOpen?: () => void;
/** Callback when menu is closed */
onClose?: () => void;
};
export const ContextMenu = observer(
({ action, children, ariaLabel, onOpen, onClose }: Props) => {
const isMobile = useMobile();
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (open) {
onOpen?.();
} else {
onClose?.();
}
},
[onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
const handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile) {
return <>{children}</>;
}
const content = toMenuItems(menuItems);
return (
<MenuProvider variant="context">
<Menu onOpenChange={handleOpenChange}>
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
<MenuContent
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
</MenuContent>
</Menu>
</MenuProvider>
);
}
);
+33 -25
View File
@@ -8,24 +8,30 @@ import {
DrawerTitle,
DrawerTrigger,
} from "~/components/primitives/Drawer";
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import {
DropdownMenu as DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuContent,
} from "~/components/primitives/DropdownMenu";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import {
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
MenuItem,
MenuItemWithChildren,
} from "~/types";
import { toMenuItems, toMobileMenuItems } from "./transformer";
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
import { observer } from "mobx-react";
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 +52,7 @@ export const DropdownMenu = observer(
(
{
action,
context,
children,
align = "start",
ariaLabel,
@@ -59,10 +66,13 @@ export const DropdownMenu = observer(
const [open, setOpen] = React.useState(false);
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext = useActionContext({
isMenu: true,
});
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
@@ -116,26 +126,24 @@ export const DropdownMenu = observer(
);
}
const content = toMenuItems(menuItems);
const content = toDropdownMenuItems(menuItems);
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={handleOpenChange}>
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</MenuTrigger>
<MenuContent
align={align}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
{append}
</MenuContent>
</Menu>
</MenuProvider>
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
{append}
</DropdownMenuContent>
</DropdownMenuRoot>
);
}
)
+49 -39
View File
@@ -1,18 +1,28 @@
import { CheckmarkIcon } from "outline-icons";
import {
DropdownMenuButton,
DropdownMenuExternalLink,
DropdownMenuGroup,
DropdownMenuInternalLink,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
} from "~/components/primitives/DropdownMenu";
import {
MenuButton,
MenuIconWrapper,
MenuInternalLink,
MenuExternalLink,
MenuLabel,
MenuSeparator,
SubMenu,
SubMenuTrigger,
SubMenuContent,
MenuGroup,
} from "~/components/primitives/Menu";
import * as Components from "~/components/primitives/components/Menu";
MenuDisclosure,
SelectedIconWrapper,
} from "~/components/primitives/components/Menu";
import { MenuItem } from "~/types";
export function toMenuItems(items: MenuItem[]) {
export function toDropdownMenuItems(items: MenuItem[]) {
const filteredItems = filterMenuItems(items);
if (!filteredItems.length) {
@@ -29,15 +39,15 @@ export function toMenuItems(items: MenuItem[]) {
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<Components.MenuIconWrapper aria-hidden>
<MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</Components.MenuIconWrapper>
</MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<MenuButton
<DropdownMenuButton
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -51,7 +61,7 @@ export function toMenuItems(items: MenuItem[]) {
case "route":
return (
<MenuInternalLink
<DropdownMenuInternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -62,7 +72,7 @@ export function toMenuItems(items: MenuItem[]) {
case "link":
return (
<MenuExternalLink
<DropdownMenuExternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -75,33 +85,33 @@ export function toMenuItems(items: MenuItem[]) {
);
case "submenu": {
const submenuItems = toMenuItems(item.items);
const submenuItems = toDropdownMenuItems(item.items);
if (!submenuItems?.length) {
return null;
}
return (
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
<DropdownSubMenuTrigger
label={item.title as string}
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent>{submenuItems}</SubMenuContent>
</SubMenu>
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
</DropdownSubMenu>
);
}
case "group": {
const groupItems = toMenuItems(item.items);
const groupItems = toDropdownMenuItems(item.items);
if (!groupItems?.length) {
return null;
}
return (
<MenuGroup
<DropdownMenuGroup
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
items={groupItems}
@@ -110,7 +120,7 @@ export function toMenuItems(items: MenuItem[]) {
}
case "separator":
return <MenuSeparator key={`${item.type}-${index}`} />;
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
@@ -139,15 +149,15 @@ export function toMobileMenuItems(
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<Components.MenuIconWrapper aria-hidden>
<MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</Components.MenuIconWrapper>
</MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<Components.MenuButton
<MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
$dangerous={item.dangerous}
@@ -157,31 +167,31 @@ export function toMobileMenuItems(
}}
>
{icon}
<Components.MenuLabel>{item.title}</Components.MenuLabel>
<MenuLabel>{item.title}</MenuLabel>
{item.selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
<SelectedIconWrapper aria-hidden>
{item.selected ? <CheckmarkIcon /> : null}
</Components.SelectedIconWrapper>
</SelectedIconWrapper>
)}
</Components.MenuButton>
</MenuButton>
);
case "route":
return (
<Components.MenuInternalLink
<MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
to={item.to}
disabled={item.disabled}
onClick={closeMenu}
>
{icon}
<Components.MenuLabel>{item.title}</Components.MenuLabel>
</Components.MenuInternalLink>
<MenuLabel>{item.title}</MenuLabel>
</MenuInternalLink>
);
case "link":
return (
<Components.MenuExternalLink
<MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
@@ -191,8 +201,8 @@ export function toMobileMenuItems(
onClick={closeMenu}
>
{icon}
<Components.MenuLabel>{item.title}</Components.MenuLabel>
</Components.MenuExternalLink>
<MenuLabel>{item.title}</MenuLabel>
</MenuExternalLink>
);
case "submenu": {
@@ -207,7 +217,7 @@ export function toMobileMenuItems(
}
return (
<Components.MenuButton
<MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
onClick={() => {
@@ -215,9 +225,9 @@ export function toMobileMenuItems(
}}
>
{icon}
<Components.MenuLabel>{item.title}</Components.MenuLabel>
<Components.MenuDisclosure />
</Components.MenuButton>
<MenuLabel>{item.title}</MenuLabel>
<MenuDisclosure />
</MenuButton>
);
}
@@ -234,14 +244,14 @@ export function toMobileMenuItems(
return (
<div key={`${item.type}-${item.title}-${index}`}>
<Components.MenuHeader>{item.title}</Components.MenuHeader>
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
{groupItems}
</div>
);
}
case "separator":
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
return <MenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
@@ -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={{
+3
View File
@@ -15,6 +15,7 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
@@ -26,6 +27,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showParentDocuments,
showCollection,
showPublished,
showTemplate,
showDraft,
...rest
}: Props) {
@@ -47,6 +49,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
/>
)}
+66
View File
@@ -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 unknown;
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,
});
});
});
+6 -5
View File
@@ -34,11 +34,11 @@ interface Props<T extends PaginatedItem>
* @param options Pagination and other query options
*/
fetch?: (
options: Record<string, any> | undefined
options: Record<string, unknown> | undefined
) => Promise<unknown[] | undefined> | undefined;
/** Additional options to pass to the fetch function */
options?: Record<string, any>;
options?: Record<string, unknown>;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@@ -77,7 +77,9 @@ interface Props<T extends PaginatedItem>
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
renderHeading?: (
name: React.ReactElement<unknown> | string
) => React.ReactNode;
/**
* Handler for escape key press
@@ -206,7 +208,7 @@ const PaginatedList = <T extends PaginatedItem>({
if (fetch) {
void fetchResults();
}
}, [fetch]);
}, [fetch, fetchResults]);
// Handle updates to fetch or options
React.useEffect(() => {
@@ -255,7 +257,6 @@ const PaginatedList = <T extends PaginatedItem>({
<React.Fragment>
{heading}
<ArrowKeyNavigation
role={rest.role}
aria-label={rest["aria-label"]}
onEscape={onEscape}
className={className}
-4
View File
@@ -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>
);
-5
View File
@@ -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();
+9 -5
View File
@@ -7,6 +7,7 @@ import { depths, s } from "@shared/styles";
import ErrorBoundary from "~/components/ErrorBoundary";
import Flex from "~/components/Flex";
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { sidebarAppearDuration } from "~/styles/animations";
@@ -19,6 +20,7 @@ function Right({ children, border, className }: Props) {
const theme = useTheme();
const { ui } = useStores();
const [isResizing, setResizing] = React.useState(false);
const isMobile = useMobile();
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -98,11 +100,13 @@ function Right({ children, border, className }: Props) {
<Sidebar {...animationProps} $border={border} className={className}>
<Position style={style} column>
<ErrorBoundary>{children}</ErrorBoundary>
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
{!isMobile && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
)}
</Position>
</Sidebar>
);
-3
View File
@@ -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={() => {
-3
View File
@@ -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();
+5 -9
View File
@@ -7,6 +7,7 @@ import { depths, s } from "@shared/styles";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMenuContext from "~/hooks/useMenuContext";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
@@ -20,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;
@@ -35,15 +35,15 @@ 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();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobile = useMobile();
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -237,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 }}
@@ -245,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>
+4 -6
View File
@@ -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
View File
@@ -10,7 +10,7 @@ import {
unstarCollection,
} from "~/actions/definitions/collections";
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useActionContext from "~/hooks/useActionContext";
import NudeButton from "./NudeButton";
type Props = {
@@ -27,6 +27,10 @@ type Props = {
function Star({ size, document, collection, color, ...rest }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const context = useActionContext({
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
});
const target = document || collection;
@@ -35,43 +39,37 @@ function Star({ size, document, collection, color, ...rest }: Props) {
}
return (
<ActionContextProvider
value={{
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
<NudeButton
context={context}
hideOnActionDisabled
tooltip={{
content: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
}}
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
>
<NudeButton
hideOnActionDisabled
tooltip={{
content: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
}}
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
>
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
size={size}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
</NudeButton>
</ActionContextProvider>
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
size={size}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
</NudeButton>
);
}
+1 -13
View File
@@ -7,20 +7,8 @@ const TeamLogo = styled(Avatar).attrs({
variant: AvatarVariant.Square,
})`
border-radius: 4px;
box-shadow: inset 0 0 0 1px ${s("divider")};
border: 0;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 4px;
box-shadow: inset 0 0 0 1px ${s("divider")};
z-index: -1;
}
`;
export default TeamLogo;
-33
View File
@@ -1,33 +0,0 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
import Template from "~/models/Template";
type Props = {
template: Template;
onSubmit: () => void;
};
export const TemplateEdit = observer(function TemplateEdit_({
template,
onSubmit,
}: Props) {
const { templates } = useStores();
const handleSubmit = useCallback(async () => {
try {
await template?.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
-103
View File
@@ -1,103 +0,0 @@
import { observer } from "mobx-react";
import { InputIcon, ShapesIcon } from "outline-icons";
import React, { useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { ProsemirrorData } from "@shared/types";
import Template from "~/models/Template";
import Editor from "~/scenes/Document/components/Editor";
import { DocumentContextProvider } from "~/components/DocumentContext";
import LoadingIndicator from "~/components/LoadingIndicator";
import Notice from "~/components/Notice";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
export const TemplateForm = observer(function TemplateForm_({
handleSubmit,
template,
}: {
handleSubmit: (template: Template) => void;
template: Template;
}) {
const { dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(template);
const dataRef = useRef(template.data);
const ref = useRef(null);
const [isUploading, handleStartUpload, handleStopUpload] = useBoolean();
const readOnly = !can.update && !template.isNew;
const handleChangeTitle = (title: string) => {
template.title = title;
};
const handleChangeIcon = (icon: string, color: string) => {
template.icon = icon;
template.color = color;
};
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
dataRef.current = value(false);
};
const handleSave = (options: { autosave?: boolean }) => {
if (options.autosave) {
return;
}
template.data = dataRef.current;
handleSubmit(template);
};
const handleCancel = () => {
dialogs.closeAllModals();
};
if (!template) {
return null;
}
return (
<DocumentContextProvider>
<React.Suspense fallback={null}>
{isUploading && <LoadingIndicator />}
<Notice
icon={<ShapesIcon />}
description={
<Trans>
Highlight some text and use the <PlaceholderIcon /> control to add
placeholders that can be filled out when creating new documents
</Trans>
}
>
{t("Youre editing a template")}
</Notice>
<Editor
id={template.id}
ref={ref}
isDraft={false}
document={template}
value={readOnly ? template.data : undefined}
defaultValue={template.data}
onFileUploadStart={handleStartUpload}
onFileUploadStop={handleStopUpload}
onChangeTitle={handleChangeTitle}
onChangeIcon={handleChangeIcon}
onSave={handleSave}
onCancel={handleCancel}
onChange={handleChange}
readOnly={readOnly}
canUpdate={can.update}
autoFocus={template.createdAt === template.updatedAt}
template
/>
</React.Suspense>
</DocumentContextProvider>
);
});
const PlaceholderIcon = styled(InputIcon)`
position: relative;
top: 6px;
margin-top: -6px;
`;
-30
View File
@@ -1,30 +0,0 @@
import { observer } from "mobx-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import Template from "~/models/Template";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
type Props = {
onSubmit?: () => void;
};
export const TemplateNew = observer(function TemplateNew_({ onSubmit }: Props) {
const { templates } = useStores();
const [template] = useState(new Template({ title: "" }, templates));
const handleSubmit = useCallback(async () => {
try {
await template.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
@@ -88,7 +88,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
options={options}
value={defaultCollectionId ?? "workspace"}
onChange={handleSelection}
label={t("Visibility")}
label={t("Location")}
/>
);
};
+13 -4
View File
@@ -6,8 +6,9 @@ import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Switch from "~/components/Switch";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import { documentPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
@@ -21,6 +22,7 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
const document = documents.get(documentId);
invariant(document, "Document must exist");
const [publish, setPublish] = React.useState(true);
const [collectionId, setCollectionId] = React.useState(
document.collectionId ?? null
);
@@ -28,13 +30,13 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
collectionId,
publish: true,
publish,
});
if (template) {
history.push(settingsPath("templates"));
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId]);
}, [t, document, history, collectionId, publish]);
return (
<ConfirmationDialog
@@ -58,6 +60,13 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
defaultCollectionId={collectionId}
onSelect={setCollectionId}
/>
<Switch
name="publish"
label={t("Published")}
note={t("Enable other members to use the template immediately")}
checked={publish}
onChange={setPublish}
/>
</Flex>
</ConfirmationDialog>
);
+1 -1
View File
@@ -9,7 +9,7 @@ function Toasts() {
return (
<StyledToaster
theme={ui.resolvedTheme as any}
theme={ui.resolvedTheme as unknown}
closeButton
toastOptions={{
duration: 5000,
+6 -6
View File
@@ -18,8 +18,6 @@ Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerHandle = DrawerPrimitive.Handle;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
@@ -58,9 +56,11 @@ const DrawerTitle = React.forwardRef<
const { hidden, children, ...rest } = props;
const title = (
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
{children}
</Text>
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
);
return (
@@ -100,4 +100,4 @@ const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerHandle, DrawerContent, DrawerTitle };
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
+291
View File
@@ -0,0 +1,291 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { LocationDescriptor } from "history";
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import { fadeAndScaleIn } from "~/styles/animations";
import {
MenuButton,
MenuDisclosure,
MenuExternalLink,
MenuHeader,
MenuInternalLink,
MenuLabel,
MenuSeparator,
MenuSubTrigger,
SelectedIconWrapper,
} from "./components/Menu";
import { CheckmarkIcon } from "outline-icons";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownSubMenu = DropdownMenuPrimitive.Sub;
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Trigger ref={ref} {...rest} asChild>
{children}
</DropdownMenuPrimitive.Trigger>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
{...rest}
sideOffset={4}
collisionPadding={6}
asChild
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
type DropdownSubMenuTriggerProps = BaseDropdownItemProps &
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>;
const DropdownSubMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
DropdownSubMenuTriggerProps
>((props, ref) => {
const { label, icon, disabled, ...rest } = props;
return (
<DropdownMenuPrimitive.SubTrigger ref={ref} {...rest} asChild>
<MenuSubTrigger disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
<MenuDisclosure />
</MenuSubTrigger>
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownSubMenuTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownSubMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
{...rest}
collisionPadding={6}
asChild
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DropdownMenuPrimitive.SubContent>
</DropdownMenuPrimitive.Portal>
);
});
DropdownSubMenuContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
type DropdownMenuGroupProps = {
label: string;
items: React.ReactNode[];
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
"children" | "asChild"
>;
const DropdownMenuGroup = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Group>,
DropdownMenuGroupProps
>((props, ref) => {
const { label, items, ...rest } = props;
return (
<DropdownMenuPrimitive.Group ref={ref} {...rest}>
<DropdownMenuLabel>{label}</DropdownMenuLabel>
{items}
</DropdownMenuPrimitive.Group>
);
});
DropdownMenuGroup.displayName = DropdownMenuPrimitive.Group.displayName;
type BaseDropdownItemProps = {
label: string;
icon?: React.ReactElement;
disabled?: boolean;
};
type DropdownMenuButtonProps = BaseDropdownItemProps & {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
tooltip?: React.ReactChild;
selected?: boolean;
dangerous?: boolean;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const DropdownMenuButton = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuButtonProps
>((props, ref) => {
const {
label,
icon,
tooltip,
disabled,
selected,
dangerous,
onClick,
...rest
} = props;
const button = (
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuButton disabled={disabled} $dangerous={dangerous} onClick={onClick}>
{icon}
<MenuLabel>{label}</MenuLabel>
{selected !== undefined && (
<SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : null}
</SelectedIconWrapper>
)}
</MenuButton>
</DropdownMenuPrimitive.Item>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
<div>{button}</div>
</Tooltip>
) : (
<>{button}</>
);
});
DropdownMenuButton.displayName = "DropdownMenuButton";
type DropdownMenuInternalLinkProps = BaseDropdownItemProps & {
to: LocationDescriptor;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const DropdownMenuInternalLink = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuInternalLinkProps
>((props, ref) => {
const { label, icon, disabled, to, ...rest } = props;
return (
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuInternalLink to={to} disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
</MenuInternalLink>
</DropdownMenuPrimitive.Item>
);
});
DropdownMenuInternalLink.displayName = "DropdownMenuInternalLink";
type DropdownMenuExternalLinkProps = BaseDropdownItemProps & {
href: string;
target?: string;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const DropdownMenuExternalLink = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuExternalLinkProps
>((props, ref) => {
const { label, icon, disabled, href, target, ...rest } = props;
return (
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuExternalLink href={href} target={target} disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
</MenuExternalLink>
</DropdownMenuPrimitive.Item>
);
});
DropdownMenuExternalLink.displayName = "DropdownMenuExternalLink";
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>((props, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} {...props} asChild>
<MenuSeparator />
</DropdownMenuPrimitive.Separator>
));
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.Label ref={ref} {...props} asChild>
<MenuHeader>{children}</MenuHeader>
</DropdownMenuPrimitive.Label>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
/** Styled components */
const StyledScrollable = styled(Scrollable)`
z-index: ${depths.menu};
min-width: 180px;
max-width: 276px;
min-height: 44px;
max-height: min(85vh, var(--radix-dropdown-menu-content-available-height));
font-weight: normal;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
outline: none;
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms ease-out;
}
@media print {
display: none;
}
`;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuButton,
DropdownMenuInternalLink,
DropdownMenuExternalLink,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownSubMenu,
DropdownSubMenuTrigger,
DropdownSubMenuContent,
};
-19
View File
@@ -1,19 +0,0 @@
import { CSRF } from "@shared/constants";
import { useCsrfToken } from "~/hooks/useCsrfToken";
/**
* Form component that automatically includes a CSRF token as a hidden input field.
*/
export const Form = ({
children,
...props
}: React.FormHTMLAttributes<HTMLFormElement>) => {
const token = useCsrfToken();
return (
<form {...props}>
{token && <input type="hidden" name={CSRF.fieldName} value={token} />}
{children}
</form>
);
};
+1 -4
View File
@@ -3,6 +3,7 @@ import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Props as ButtonProps } from "~/components/Button";
import Separator from "~/components/ContextMenu/Separator";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
@@ -98,10 +99,6 @@ const InputSelectSeparator = React.forwardRef<
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
const Separator = styled.hr`
margin: 6px 0;
`;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
@@ -1,23 +0,0 @@
import { createContext, useContext, useMemo } from "react";
type MenuVariant = "dropdown" | "context";
const MenuContext = createContext<{
variant: MenuVariant;
}>({
variant: "dropdown",
});
export function MenuProvider({
variant,
children,
}: {
variant: MenuVariant;
children: React.ReactNode;
}) {
const ctx = useMemo(() => ({ variant }), [variant]);
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
}
export const useMenuContext = () => useContext(MenuContext);
-435
View File
@@ -1,435 +0,0 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as Components from "../components/Menu";
import { LocationDescriptor } from "history";
import * as React from "react";
import Tooltip from "~/components/Tooltip";
import { CheckmarkIcon } from "outline-icons";
import { useMenuContext } from "./MenuContext";
type MenuProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Root
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
const Menu = ({ children, ...rest }: MenuProps) => {
const { variant } = useMenuContext();
const Root =
variant === "dropdown"
? DropdownMenuPrimitive.Root
: ContextMenuPrimitive.Root;
return <Root {...rest}>{children}</Root>;
};
type SubMenuProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Sub
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Sub>;
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
const { variant } = useMenuContext();
const Sub =
variant === "dropdown"
? DropdownMenuPrimitive.Sub
: ContextMenuPrimitive.Sub;
return <Sub {...rest}>{children}</Sub>;
};
type TriggerProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Trigger
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Trigger>;
const MenuTrigger = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Trigger>
| React.ElementRef<typeof ContextMenuPrimitive.Trigger>,
TriggerProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Trigger =
variant === "dropdown"
? DropdownMenuPrimitive.Trigger
: ContextMenuPrimitive.Trigger;
return (
<Trigger ref={ref} {...rest} asChild>
{children}
</Trigger>
);
});
MenuTrigger.displayName = "MenuTrigger";
type ContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
const MenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
ContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Portal =
variant === "dropdown"
? DropdownMenuPrimitive.Portal
: ContextMenuPrimitive.Portal;
const Content =
variant === "dropdown"
? DropdownMenuPrimitive.Content
: ContextMenuPrimitive.Content;
const offsetProp =
variant === "dropdown" ? { sideOffset: 4 } : { alignOffset: 4 };
const contentProps = {
maxHeightVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-available-height"
: "--radix-context-menu-content-available-height",
transformOriginVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-transform-origin"
: "--radix-context-menu-content-transform-origin",
};
return (
<Portal>
<Content ref={ref} {...offsetProp} {...rest} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
</Content>
</Portal>
);
});
MenuContent.displayName = "MenuContent";
type SubMenuTriggerProps = BaseItemProps &
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger>;
const SubMenuTrigger = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
SubMenuTriggerProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, ...rest } = props;
const Trigger =
variant === "dropdown"
? DropdownMenuPrimitive.SubTrigger
: ContextMenuPrimitive.SubTrigger;
return (
<Trigger ref={ref} {...rest} asChild>
<Components.MenuSubTrigger disabled={disabled}>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
<Components.MenuDisclosure />
</Components.MenuSubTrigger>
</Trigger>
);
});
SubMenuTrigger.displayName = "SubMenuTrigger";
type SubMenuContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubContent
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>;
const SubMenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
SubMenuContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Portal =
variant === "dropdown"
? DropdownMenuPrimitive.Portal
: ContextMenuPrimitive.Portal;
const Content =
variant === "dropdown"
? DropdownMenuPrimitive.SubContent
: ContextMenuPrimitive.SubContent;
const contentProps = {
maxHeightVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-available-height"
: "--radix-context-menu-content-available-height",
transformOriginVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-transform-origin"
: "--radix-context-menu-content-transform-origin",
};
return (
<Portal>
<Content ref={ref} {...rest} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
</Content>
</Portal>
);
});
SubMenuContent.displayName = "SubMenuContent";
type MenuGroupProps = {
label: string;
items: React.ReactNode[];
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
"children" | "asChild"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Group>,
"children" | "asChild"
>;
const MenuGroup = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
MenuGroupProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, items, ...rest } = props;
const Group =
variant === "dropdown"
? DropdownMenuPrimitive.Group
: ContextMenuPrimitive.Group;
return (
<Group ref={ref} {...rest}>
<MenuLabel>{label}</MenuLabel>
{items}
</Group>
);
});
MenuGroup.displayName = "MenuGroup";
type BaseItemProps = {
label: string;
icon?: React.ReactElement;
disabled?: boolean;
};
type MenuButtonProps = BaseItemProps & {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
tooltip?: React.ReactChild;
selected?: boolean;
dangerous?: boolean;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const MenuButton = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuButtonProps
>((props, ref) => {
const { variant } = useMenuContext();
const {
label,
icon,
tooltip,
disabled,
selected,
dangerous,
onClick,
...rest
} = props;
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
const button = (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuButton
disabled={disabled}
$dangerous={dangerous}
onClick={onClick}
>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
{selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : null}
</Components.SelectedIconWrapper>
)}
</Components.MenuButton>
</Item>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
<div>{button}</div>
</Tooltip>
) : (
<>{button}</>
);
});
MenuButton.displayName = "MenuButton";
type MenuInternalLinkProps = BaseItemProps & {
to: LocationDescriptor;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const MenuInternalLink = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuInternalLinkProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, to, ...rest } = props;
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
return (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuInternalLink to={to} disabled={disabled}>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
</Components.MenuInternalLink>
</Item>
);
});
MenuInternalLink.displayName = "MenuInternalLink";
type MenuExternalLinkProps = BaseItemProps & {
href: string;
target?: string;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const MenuExternalLink = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuExternalLinkProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, href, target, ...rest } = props;
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
return (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuExternalLink
href={href}
target={target}
disabled={disabled}
>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
</Components.MenuExternalLink>
</Item>
);
});
MenuExternalLink.displayName = "MenuExternalLink";
type MenuSeparatorProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Separator
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>;
const MenuSeparator = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
MenuSeparatorProps
>((props, ref) => {
const { variant } = useMenuContext();
const Separator =
variant === "dropdown"
? DropdownMenuPrimitive.Separator
: ContextMenuPrimitive.Separator;
return (
<Separator ref={ref} {...props} asChild>
<Components.MenuSeparator />
</Separator>
);
});
MenuSeparator.displayName = "MenuSeparator";
type MenuLabelProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Label
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label>;
const MenuLabel = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Label>
| React.ElementRef<typeof ContextMenuPrimitive.Label>,
MenuLabelProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Label =
variant === "dropdown"
? DropdownMenuPrimitive.Label
: ContextMenuPrimitive.Label;
return (
<Label ref={ref} {...rest} asChild>
<Components.MenuHeader>{children}</Components.MenuHeader>
</Label>
);
});
MenuLabel.displayName = "MenuLabel";
export {
Menu,
MenuTrigger,
MenuContent,
MenuButton,
MenuInternalLink,
MenuExternalLink,
MenuSeparator,
MenuGroup,
MenuLabel,
SubMenu,
SubMenuTrigger,
SubMenuContent,
};
+1 -1
View File
@@ -90,7 +90,7 @@ type StyledContentProps = {
const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
z-index: ${depths.modal};
max-height: min(85vh, var(--radix-popover-content-available-height));
max-height: var(--radix-popover-content-available-height);
transform-origin: var(--radix-popover-content-transform-origin);
background: ${s("menuBackground")};
+2 -51
View File
@@ -3,13 +3,10 @@ import { ellipsis } from "polished";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import { fadeAndScaleIn } from "~/styles/animations";
import { s } from "@shared/styles";
type BaseMenuItemProps = {
disabled?: boolean;
$active?: boolean;
$dangerous?: boolean;
};
@@ -45,24 +42,6 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
outline: 0; // Disable default outline on Firefox
}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.$dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
`}
${(props) =>
!props.disabled &&
`
@@ -77,7 +56,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
@@ -156,31 +135,3 @@ export const SelectedIconWrapper = styled.span`
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export const MenuContent = styled(Scrollable)<{
maxHeightVar: string;
transformOriginVar: string;
}>`
z-index: ${depths.menu};
min-width: 180px;
max-width: 276px;
min-height: 44px;
max-height: ${({ maxHeightVar }) => `min(85vh, var(${maxHeightVar}))`};
font-weight: normal;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
outline: none;
transform-origin: ${({ transformOriginVar }) => `var(${transformOriginVar})`};
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms ease-out;
}
@media print {
display: none;
}
`;
+3 -14
View File
@@ -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>
+12 -40
View File
@@ -17,7 +17,6 @@ import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
type Props = {
align?: "start" | "end" | "center";
active?: boolean;
children: React.ReactNode;
width?: number;
@@ -36,18 +35,16 @@ const defaultPosition = {
function usePosition({
menuRef,
active,
align = "center",
}: {
menuRef: React.RefObject<HTMLDivElement>;
active?: boolean;
align?: Props["align"];
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const menuHeight = menuRef.current?.offsetHeight ?? 0;
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
if (!active || !menuRef.current) {
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
}
@@ -97,7 +94,7 @@ function usePosition({
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
}
}
@@ -183,11 +180,7 @@ function usePosition({
),
Math.max(
Math.max(offsetParent.x, margin),
align === "center"
? centerOfSelection - menuWidth / 2
: align === "start"
? selectionBounds.left
: selectionBounds.right
centerOfSelection - menuWidth / 2
)
);
const top = Math.max(
@@ -223,7 +216,6 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
let position = usePosition({
menuRef,
active: props.active,
align: props.align,
});
if (isSelectingText) {
@@ -285,7 +277,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
left: `${position.left}px`,
}}
>
<Background align={props.align}>{props.children}</Background>
{props.children}
</Wrapper>
</Portal>
);
@@ -310,7 +302,7 @@ const arrow = (props: WrapperProps) =>
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -3px;
bottom: -2px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
}
@@ -343,42 +335,22 @@ const MobileWrapper = styled.div`
}
`;
const Background = styled.div<{ align: Props["align"] }>`
position: relative;
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 4px;
height: 36px;
padding: 6px;
${(props) =>
props.align === "start" &&
`
position: absolute;
left: 0;
bottom: 0;
`}
${(props) =>
props.align === "end" &&
`
position: absolute;
right: 0;
bottom: 0;
`}
`;
const Wrapper = styled.div<WrapperProps>`
will-change: opacity, transform;
padding: 6px;
position: absolute;
z-index: ${depths.editorToolbar};
opacity: 0;
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 4px;
transform: scale(0.95);
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
height: 36px;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
+24 -18
View File
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
}, [query, documents.add])
);
useEffect(() => {
@@ -79,6 +79,22 @@ const LinkEditor: React.FC<Props> = ({
}
}, [trimmedQuery, request]);
const save = React.useCallback(
(href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
},
[onSelectLink, from, to]
);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
@@ -107,20 +123,7 @@ const LinkEditor: React.FC<Props> = ({
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
};
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
@@ -195,7 +198,7 @@ const LinkEditor: React.FC<Props> = ({
}
};
const handleRemoveLink = () => {
const handleRemoveLink = React.useCallback(() => {
discardRef.current = true;
const { state, dispatch } = view;
@@ -203,9 +206,12 @@ const LinkEditor: React.FC<Props> = ({
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
if (onRemoveLink) {
onRemoveLink();
}
view.focus();
};
}, [view, mark, from, to, onRemoveLink]);
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
+5 -13
View File
@@ -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}
+10 -1
View File
@@ -184,7 +184,16 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
collections,
]);
const handleSelect = useCallback(
async (item: MentionItem) => {
+2 -6
View File
@@ -87,7 +87,7 @@ function useIsActive(state: EditorState) {
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
const nodes = (fragment as unknown).content;
return some(nodes, (n) => n.content.size);
}
@@ -199,11 +199,9 @@ export default function SelectionToolbar(props: Props) {
const isNoticeSelection = isInNotice(state);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelection) {
items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
@@ -222,9 +220,8 @@ export default function SelectionToolbar(props: Props) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
align = "end";
} else {
items = getFormattingMenuItems(state, isTemplate, dictionary);
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
}
// Some extensions may be disabled, remove corresponding items
@@ -254,7 +251,6 @@ export default function SelectionToolbar(props: Props) {
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
+2 -4
View File
@@ -14,13 +14,13 @@ import { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Header from "~/components/ContextMenu/Header";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import Input from "./Input";
import { MenuHeader } from "~/components/primitives/components/Menu";
type TopAnchor = {
top: number;
@@ -647,9 +647,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<MenuHeader key={currentHeading}>
{currentHeading}
</MenuHeader>
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
onPointerMove={handlePointerMove}
+9 -14
View File
@@ -2,8 +2,8 @@ import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import MenuItem from "~/components/ContextMenu/MenuItem";
import { usePortalContext } from "~/components/Portal";
import { MenuButton, MenuLabel } from "~/components/primitives/components/Menu";
export type Props = {
/** Whether the item is selected */
@@ -53,22 +53,17 @@ function SuggestionsMenuItem({
);
return (
<MenuButton
<MenuItem
ref={ref}
disabled={disabled}
onClick={onClick}
active={selected}
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
$active={selected}
icon={icon}
>
{icon}
<MenuLabel>
{title}
{subtitle && (
<Subtitle $active={selected}>&middot; {subtitle}</Subtitle>
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
</MenuButton>
{title}
{subtitle && <Subtitle $active={selected}>&middot; {subtitle}</Subtitle>}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuItem>
);
}
+51 -72
View File
@@ -1,9 +1,12 @@
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { useMenuState } from "reakit";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Toolbar from "@radix-ui/react-toolbar";
import { MenuItem } from "@shared/editor/types";
import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { TooltipProvider } from "~/components/TooltipContext";
import { MenuItem as TMenuItem } from "~/types";
import { useEditor } from "./EditorContext";
@@ -11,12 +14,6 @@ import { MediaDimension } from "./MediaDimension";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
import Tooltip from "./Tooltip";
import { toMenuItems } from "~/components/Menu/transformer";
import { MenuContent } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { Menu, MenuTrigger } from "~/components/primitives/Menu";
import { useTranslation } from "react-i18next";
import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
@@ -26,8 +23,8 @@ type Props = {
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const menu = useMenuState();
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { state } = view;
@@ -63,30 +60,20 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
: [];
}, [item.children, commands, state]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
return (
<EventBoundary>
<MenuProvider variant="dropdown">
<Menu>
<MenuTrigger>
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
{toMenuItems(items)}
</MenuContent>
</Menu>
</MenuProvider>
</EventBoundary>
<>
<MenuButton {...menu}>
{(buttonProps) => (
<ToolbarButton {...buttonProps} hovering={menu.visible}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</MenuButton>
<ContextMenu aria-label={item.label} {...menu}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
@@ -107,47 +94,39 @@ function ToolbarMenu(props: Props) {
return (
<TooltipProvider>
<Toolbar.Root asChild>
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
active={isActive && !item.label}
item={item}
/>
) : (
<Toolbar.Button asChild>
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</Toolbar.Button>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</Toolbar.Root>
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} />
) : (
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</TooltipProvider>
);
}

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