mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
263 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8cb10248e | |||
| 762a0c78c7 | |||
| 1b88189f9c | |||
| ace351035a | |||
| 3222a77cc3 | |||
| d8b0e731ef | |||
| 80c1e5a10b | |||
| 5b89882e5e | |||
| f85ad1a7e1 | |||
| 38a3e651a7 | |||
| e06ff9d7d2 | |||
| 89cfc76d8f | |||
| c22ba4fa0c | |||
| 8dabc7f3cf | |||
| bd7f0cdc12 | |||
| 9a849418b1 | |||
| 0ab3a962d9 | |||
| 2c7ec179fa | |||
| 526833ec6e | |||
| d8f39e3b67 | |||
| 0565616b02 | |||
| dd9b28e898 | |||
| f5ef2f2b30 | |||
| 1f251d4829 | |||
| 749bf49ebe | |||
| 371665b35c | |||
| d20779e7ea | |||
| 1d86a4aee4 | |||
| 19e5888216 | |||
| a4039ccc81 | |||
| f0a7ee6df8 | |||
| c428d551b8 | |||
| 904f1a9726 | |||
| 8dc4f8b422 | |||
| 1ceb476a04 | |||
| 5439afa5c8 | |||
| 8619b219e7 | |||
| 5a14944d0c | |||
| 93509564e0 | |||
| aacad48585 | |||
| 8dbbcb6dce | |||
| 597da9f1ee | |||
| eab57a7144 | |||
| 20f928332e | |||
| 12847d3d79 | |||
| b298456126 | |||
| 4e773d7cb0 | |||
| 0dfab5b245 | |||
| a40ab4867d | |||
| 3270e05abc | |||
| 36f5ce26e2 | |||
| 10303220a5 | |||
| 1c52a3e7da | |||
| 8a7501d0ad | |||
| 8048b7e530 | |||
| d54167dbdf | |||
| 6b98da54bd | |||
| 03e322f1ad | |||
| e876b2131e | |||
| 1e62c09dd0 | |||
| 0f23b63e35 | |||
| 658be50dcd | |||
| 9288ac87e0 | |||
| 6aa7f34b01 | |||
| 7999cf0b98 | |||
| 4ebb0e1790 | |||
| 0ce992e87a | |||
| 3c3d18637e | |||
| 6708f5dbc2 | |||
| 38920cd1fe | |||
| 053133f2b7 | |||
| 505e41661a | |||
| 3c4ed666ce | |||
| 25e222bb22 | |||
| 7c84c67077 | |||
| d3fd0fc537 | |||
| 7f88ab55fb | |||
| 9038db525e | |||
| 9c7a53102b | |||
| 7dc00b7d31 | |||
| 112731a52b | |||
| c68c6af8f4 | |||
| 957ce69d2e | |||
| ff6a20ef38 | |||
| 6a69990833 | |||
| 0f45778d79 | |||
| 3886c179c5 | |||
| ccde98ce82 | |||
| 86a106e8e9 | |||
| 3fe5f907db | |||
| 496b89c7f8 | |||
| 46dd13fc7f | |||
| ac29295dd2 | |||
| 0e8fde3bb1 | |||
| cad670f19c | |||
| 00ef17b913 | |||
| 05381ff101 | |||
| 519fd024f9 | |||
| 7be893f9a3 | |||
| 52448714d9 | |||
| 6e92313f73 | |||
| dfd969084b | |||
| 758d2b62f5 | |||
| b90ff98cef | |||
| 23642fbd85 | |||
| 3fa429977a | |||
| 8ddebb920e | |||
| 7ff6f1defb | |||
| f2016bb1ca | |||
| ba5e4dddbc | |||
| bb8f73cb8d | |||
| 4aeea4f73c | |||
| 2e0bc66ad1 | |||
| c4d861e0ae | |||
| f02520444e | |||
| 6695ae1f3e | |||
| 924db0a3fd | |||
| c9fe7b3d5c | |||
| 1937043aed | |||
| 957648a588 | |||
| 5c01909909 | |||
| 84d6ed01e3 | |||
| c758f0d93a | |||
| c54194f97a | |||
| a860cfc9ec | |||
| 08d58f7a6d | |||
| 45a19d52cf | |||
| de69a4e671 | |||
| 7824f6b363 | |||
| f6709520fa | |||
| 66b0341cfa | |||
| 057d57e21a | |||
| 13c00c4663 | |||
| eb584ed6b6 | |||
| 40c81a5e30 | |||
| 5e976fe732 | |||
| fe9daa0a75 | |||
| 08227ce4da | |||
| 4f6ee1a00b | |||
| 797c28a12e | |||
| 129e872578 | |||
| b4053f344f | |||
| ffe7cda26b | |||
| 38880f8335 | |||
| 1caca05876 | |||
| 0722b42613 | |||
| 5d749efd84 | |||
| 0363481a6a | |||
| c8fbdc35fb | |||
| c382e1233b | |||
| 3a875d4466 | |||
| 66f9113975 | |||
| a52391842f | |||
| 20e84c8e1d | |||
| 1488341f66 | |||
| a06174b627 | |||
| 22556b2121 | |||
| 7252701e9b | |||
| 5fd6ef646a | |||
| 0e9f34bd6a | |||
| 23177578b2 | |||
| 40bbfc78cd | |||
| dc9aad99e9 | |||
| ea9e9675fb | |||
| db42af7fe1 | |||
| eb59aed5b7 | |||
| 8209f56e56 | |||
| a097676e9c | |||
| 2da35f2504 | |||
| b17f3c7f6c | |||
| 30d1900f41 | |||
| 44eaf97ea4 | |||
| d9e15d2441 | |||
| 7b2cfbde5b | |||
| d2f54502f3 | |||
| 7cd00f3465 | |||
| e1598c08d8 | |||
| 0b85cbd586 | |||
| 369b309527 | |||
| f48a34fbdc | |||
| 6398829106 | |||
| 22ffda7078 | |||
| 7c6c833d91 | |||
| 9b4af148a4 | |||
| a80dc8f103 | |||
| 72c2664478 | |||
| 9aa666e708 | |||
| 9c38ce71dc | |||
| bb128318da | |||
| 51dd516679 | |||
| 49f918e7f5 | |||
| 06d0932e0d | |||
| 03493ea3dc | |||
| 446a0e1071 | |||
| af6eb6b6ec | |||
| 08fb38148e | |||
| 7e3f71d67e | |||
| 3e1daf4ab8 | |||
| 0354548f22 | |||
| ee2054f333 | |||
| 2c3650ec4f | |||
| 9858d160d5 | |||
| f065db5415 | |||
| a4784ca2d2 | |||
| e1e82ef4ac | |||
| c853917502 | |||
| 4aa4868a54 | |||
| 7b6293637c | |||
| 626b3a79b1 | |||
| 30fff9b070 | |||
| 9a7d6c5fc8 | |||
| dbfe9eb0e4 | |||
| 70b007d534 | |||
| 5ea3d57352 | |||
| f1e8c0bcc2 | |||
| bdb97d63d8 | |||
| c6177bc4d8 | |||
| 8324859de9 | |||
| 2957ce6c55 | |||
| 529b7e45de | |||
| fcd40a93a4 | |||
| 00fb4d1af7 | |||
| 838a1e7428 | |||
| ebe0e5bc3a | |||
| 126e876f0c | |||
| e9ed1ba5d1 | |||
| f6d46a07ec | |||
| ef0f8301bc | |||
| abd7abcc18 | |||
| 4b146de583 | |||
| b73bd2a621 | |||
| db179a8086 | |||
| 6d6a42b805 | |||
| 74ce4052c4 | |||
| 3118721b21 | |||
| 07514cb692 | |||
| b9c065f0ad | |||
| d16bf03e47 | |||
| 747a833a4d | |||
| d49b223126 | |||
| 3df2d362b6 | |||
| 86811a5556 | |||
| 017660337c | |||
| ed03b9d548 | |||
| f423171a6d | |||
| 1cc4d879dc | |||
| f009375fbc | |||
| f0ba8c819f | |||
| 06e005cab9 | |||
| f12e865e5c | |||
| 24f377d945 | |||
| 0e21552b34 | |||
| a9493ecd54 | |||
| 06938561a6 | |||
| 7b9e1b1c57 | |||
| 60eb3f8503 | |||
| cc14f18fd2 | |||
| 8b18d5e93f | |||
| 0780fe2347 | |||
| 0230e1c5a5 | |||
| 5584089441 | |||
| bffd11b593 | |||
| 77ad224709 |
+8
-1
@@ -1,4 +1,3 @@
|
||||
__mocks__
|
||||
.git
|
||||
.vscode
|
||||
.github
|
||||
@@ -8,11 +7,19 @@ __mocks__
|
||||
.eslint*
|
||||
.oxlintrc*
|
||||
.log
|
||||
*.md
|
||||
Makefile
|
||||
Procfile
|
||||
app.json
|
||||
crowdin.yml
|
||||
lint-staged.config.mjs
|
||||
build
|
||||
docker-compose.yml
|
||||
node_modules
|
||||
.yarn
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.test.js
|
||||
**/*.test.jsx
|
||||
**/__tests__
|
||||
**/__mocks__
|
||||
|
||||
+16
-2
@@ -119,6 +119,11 @@ SSL_CERT=
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# When behind a reverse proxy, the header to use for the client IP.
|
||||
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
@@ -203,7 +208,7 @@ RATE_LIMITER_DURATION_WINDOW=60
|
||||
# ––––––––––– INTEGRATIONS –––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# The GitHub integration allows previewing issue and pull request links
|
||||
# GitHub integration allows previewing issue and pull request links
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
@@ -212,7 +217,12 @@ GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# The Linear integration allows previewing issue links as rich mentions
|
||||
# The GitLab integration allows previewing issue and merge request links
|
||||
# DOCS:
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
|
||||
# Linear integration allows previewing issue links as rich mentions
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
@@ -223,6 +233,10 @@ SLACK_VERIFICATION_TOKEN=your_token
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
|
||||
# Figma integration allows previewing design files as rich mentions
|
||||
FIGMA_CLIENT_ID=
|
||||
FIGMA_CLIENT_SECRET=
|
||||
|
||||
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
|
||||
# and do not forget to whitelist your domain name in the app settings
|
||||
DROPBOX_APP_KEY=
|
||||
|
||||
@@ -18,6 +18,9 @@ GITHUB_CLIENT_ID=123;
|
||||
GITHUB_CLIENT_SECRET=123;
|
||||
GITHUB_APP_NAME=outline-test;
|
||||
|
||||
GITLAB_CLIENT_ID=123
|
||||
GITLAB_CLIENT_SECRET=123
|
||||
|
||||
OIDC_CLIENT_ID=client-id
|
||||
OIDC_CLIENT_SECRET=client-secret
|
||||
OIDC_AUTH_URI=http://localhost/authorize
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
|
||||
bundle-size:
|
||||
needs: [setup, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -20,4 +20,5 @@ data/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
.yarn/releases
|
||||
!.yarn/sdks
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.2.0
|
||||
Licensed Work: Outline 1.5.0
|
||||
The Licensed Work is (c) 2026 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: 2030-01-06
|
||||
Change Date: 2030-02-15
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./public/logos/outline-logo-dark.png" height="29">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./public/logos/outline-logo-light.png" height="29">
|
||||
<img src="./public/logos/outline-logo-light.png" height="29" alt="Outline" />
|
||||
</picture>
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
|
||||
@@ -21,7 +21,23 @@
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
"postdeploy": "yarn sequelize db:migrate",
|
||||
"pr-predeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"environments": {
|
||||
"review": {
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-redis:mini"
|
||||
},
|
||||
{
|
||||
"plan": "heroku-postgresql:essential-0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": {
|
||||
@@ -43,8 +59,12 @@
|
||||
"required": true
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
|
||||
"required": true
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to. For review apps, this is auto-generated.",
|
||||
"required": false
|
||||
},
|
||||
"HEROKU_APP_NAME": {
|
||||
"description": "Automatically set by Heroku for review apps",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
AlphabeticalReverseSortIcon,
|
||||
AlphabeticalSortIcon,
|
||||
SortAlphabeticalReverseIcon,
|
||||
SortAlphabeticalIcon,
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
ManualSortIcon,
|
||||
SortManualIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
UnsubscribeIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
createActionWithChildren,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
@@ -96,11 +96,11 @@ export const editCollection = createAction({
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export const editCollection = createAction({
|
||||
content: (
|
||||
<CollectionEdit
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
collectionId={activeCollectionId}
|
||||
collectionId={collection.id}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -122,14 +122,10 @@ export const editCollectionPermissions = createAction({
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -152,15 +148,16 @@ export const importDocument = createAction({
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const { documents } = stores;
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypesString;
|
||||
@@ -168,19 +165,17 @@ export const importDocument = createAction({
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
const document = await documents.import(file, null, collection.id, {
|
||||
publish: true,
|
||||
});
|
||||
history.push(document.path);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,37 +186,36 @@ export const importDocument = createAction({
|
||||
export const sortCollection = createActionWithChildren({
|
||||
name: ({ t }) => t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
!!stores.policies.abilities(activeCollectionId).update,
|
||||
icon: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
icon: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
const sortAlphabetical = collection?.sort.field === "title";
|
||||
const sortDir = collection?.sort.direction;
|
||||
|
||||
return sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<AlphabeticalSortIcon />
|
||||
<SortAlphabeticalIcon />
|
||||
) : (
|
||||
<AlphabeticalReverseSortIcon />
|
||||
<SortAlphabeticalReverseIcon />
|
||||
)
|
||||
) : (
|
||||
<ManualSortIcon />
|
||||
<SortManualIcon />
|
||||
);
|
||||
},
|
||||
children: [
|
||||
createAction({
|
||||
name: ({ t }) => t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "asc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
@@ -233,15 +227,15 @@ export const sortCollection = createActionWithChildren({
|
||||
createAction({
|
||||
name: ({ t }) => t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "desc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
@@ -253,12 +247,12 @@ export const sortCollection = createActionWithChildren({
|
||||
createAction({
|
||||
name: ({ t }) => t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return collection?.sort.field !== "title";
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "index",
|
||||
@@ -275,22 +269,19 @@ export const searchInCollection = createInternalLinkAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection?.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
return stores.policies.abilities(collection.id).readDocument;
|
||||
},
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
|
||||
const [pathname, search] = searchPath({
|
||||
collectionId: activeCollectionId,
|
||||
collectionId: collection?.id,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
@@ -307,23 +298,22 @@ export const starCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
!collection.isStarred && stores.policies.abilities(collection.id).star
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
perform: async ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
await collection?.star();
|
||||
await collection.star();
|
||||
setPersistedState(getHeaderExpandedKey("starred"), true);
|
||||
},
|
||||
});
|
||||
@@ -334,22 +324,18 @@ export const unstarCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
perform: async ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
await collection?.unstar();
|
||||
},
|
||||
});
|
||||
@@ -359,28 +345,25 @@ export const subscribeCollection = createAction({
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isActive &&
|
||||
!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).subscribe
|
||||
!!collection.isActive &&
|
||||
!collection.isSubscribed &&
|
||||
stores.policies.abilities(collection.id).subscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.subscribe();
|
||||
|
||||
await collection.subscribe();
|
||||
toast.success(t("Subscribed to document notifications"));
|
||||
},
|
||||
});
|
||||
@@ -390,28 +373,25 @@ export const unsubscribeCollection = createAction({
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isActive &&
|
||||
!!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
||||
!!collection.isActive &&
|
||||
!!collection.isSubscribed &&
|
||||
stores.policies.abilities(collection.id).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
|
||||
if (!activeCollectionId || !currentUserId) {
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.unsubscribe();
|
||||
|
||||
await collection.unsubscribe();
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
});
|
||||
@@ -421,23 +401,15 @@ export const archiveCollection = createAction({
|
||||
analyticsName: "Archive collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).archive;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
const { dialogs, collections } = stores;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = collections.get(activeCollectionId);
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.openModal({
|
||||
stores.dialogs.openModal({
|
||||
title: t("Archive collection"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
@@ -462,17 +434,10 @@ export const restoreCollection = createAction({
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).restore;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -488,18 +453,10 @@ export const deleteCollection = createAction({
|
||||
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);
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
|
||||
perform: ({ getActiveModel, t, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -521,18 +478,10 @@ export const exportCollection = createAction({
|
||||
analyticsName: "Export collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ExportIcon />,
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (!currentTeamId || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!stores.policies.abilities(activeCollectionId).export;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.export),
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -555,13 +504,13 @@ export const createDocument = createInternalLinkAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "new create document",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
const [pathname, search] = newDocumentPath(collection?.id).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
@@ -577,19 +526,13 @@ export const createTemplate = createInternalLinkAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createTemplate
|
||||
),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
to: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return newTemplatePath(collection?.id);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -38,15 +38,15 @@ import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { Week } from "@shared/utils/time";
|
||||
import type UserMembership from "~/models/UserMembership";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
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/DocumentCopy";
|
||||
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
|
||||
import { DocumentDownload } from "~/components/DocumentDownload";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
newNestedDocumentPath,
|
||||
newSiblingDocumentPath,
|
||||
searchPath,
|
||||
documentPath,
|
||||
urlify,
|
||||
@@ -77,9 +78,15 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type { Action, ActionGroup, ActionSeparator } from "~/types";
|
||||
import type {
|
||||
Action,
|
||||
ActionContext,
|
||||
ActionGroup,
|
||||
ActionSeparator,
|
||||
} from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import env from "~/env";
|
||||
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
@@ -131,18 +138,13 @@ export const editDocument = createInternalLinkAction({
|
||||
keywords: "edit",
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { auth, documents, policies } = stores;
|
||||
const { auth, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!can?.update && !!auth.user?.separateEditMode && !document?.template
|
||||
);
|
||||
return !!can?.update && !!auth.user?.separateEditMode;
|
||||
},
|
||||
to: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -199,59 +201,41 @@ export const createDraftDocument = createInternalLinkAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({
|
||||
currentTeamId,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
stores,
|
||||
}) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
/**
|
||||
* Finds the index of a document among its siblings in the collection tree.
|
||||
*
|
||||
* @param stores - the root stores.
|
||||
* @param document - the document to find the index of.
|
||||
* @returns the index of the document among its siblings, or -1 if not found.
|
||||
*/
|
||||
function findDocumentSiblingIndex(
|
||||
stores: ActionContext["stores"],
|
||||
document: {
|
||||
id: string;
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string;
|
||||
}
|
||||
): number {
|
||||
if (!document.collectionId) {
|
||||
return -1;
|
||||
}
|
||||
const collection = stores.collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
!currentTeamId ||
|
||||
!document?.isTemplate ||
|
||||
!!document?.isDraft ||
|
||||
!!document?.isDeleted
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const siblings = document.parentDocumentId
|
||||
? collection.getChildrenForDocument(document.parentDocumentId)
|
||||
: collection.sortedDocuments;
|
||||
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId, {
|
||||
templateId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
|
||||
}
|
||||
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
name: ({ t }) => t("Nested document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
keywords: "create nested",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
@@ -269,6 +253,93 @@ export const createNestedDocument = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
const createDocumentBefore = createInternalLinkAction({
|
||||
name: ({ t }) => t("Before"),
|
||||
analyticsName: "New document before",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "create before",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!currentTeamId || !activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const index = findDocumentSiblingIndex(stores, document);
|
||||
const [pathname, search] = newSiblingDocumentPath({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
index: Math.max(0, index),
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const createDocumentAfter = createInternalLinkAction({
|
||||
name: ({ t }) => t("After"),
|
||||
analyticsName: "New document after",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "create after",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!currentTeamId || !activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const index = findDocumentSiblingIndex(stores, document);
|
||||
const [pathname, search] = newSiblingDocumentPath({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
index: index + 1,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createNewDocument = createActionWithChildren({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
@@ -345,7 +416,7 @@ export const publishDocument = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (document?.collectionId || document?.template) {
|
||||
if (document?.collectionId) {
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
@@ -549,7 +620,7 @@ export const downloadDocument = createAction({
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Downloas as Markdown"),
|
||||
name: ({ t }) => t("Download as Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
@@ -630,7 +701,7 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
if (document) {
|
||||
const res = await client.post("/documents.export", {
|
||||
id: document.id,
|
||||
signedUrls: 3600 * 24 * 30, // 30 days
|
||||
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
|
||||
});
|
||||
copy(res.data);
|
||||
toast.success(t("Markdown copied to clipboard"));
|
||||
@@ -869,7 +940,7 @@ export const printDocument = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: () => {
|
||||
queueMicrotask(window.print);
|
||||
setTimeout(window.print, 0);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -890,7 +961,7 @@ export const importDocument = createAction({
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
|
||||
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
@@ -899,6 +970,7 @@ export const importDocument = createAction({
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
@@ -912,6 +984,8 @@ export const importDocument = createAction({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -929,12 +1003,12 @@ export const createTemplateFromDocument = createAction({
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document?.isTemplate || !document?.isActive) {
|
||||
if (!document?.isActive) {
|
||||
return false;
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).updateDocument
|
||||
stores.policies.abilities(activeCollectionId).createTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
@@ -981,46 +1055,8 @@ export const searchDocumentsForQuery = (query: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
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 = createAction({
|
||||
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");
|
||||
},
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
@@ -1058,8 +1094,7 @@ export const moveDocument = createAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
// Don't show the button if this is a non-workspace template.
|
||||
if (!document || (document.template && !document.isWorkspaceTemplate)) {
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
@@ -1067,25 +1102,6 @@ export const moveDocument = createAction({
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
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 = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
@@ -1144,10 +1160,7 @@ export const restoreDocument = createAction({
|
||||
: undefined;
|
||||
const can = stores.policies.abilities(document.id);
|
||||
|
||||
return (
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
return !!collection?.isActive && !!(can.restore || can.unarchive);
|
||||
},
|
||||
perform: async ({ t, stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1184,10 +1197,7 @@ export const restoreDocumentToCollection = createActionWithChildren({
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
return !collection?.isActive && !!(can.restore || can.unarchive);
|
||||
},
|
||||
children: ({ t, activeDocumentId, stores }) => {
|
||||
const { collections, documents, policies } = stores;
|
||||
@@ -1329,7 +1339,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments();
|
||||
stores.ui.set({ rightSidebar: "comments" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1364,6 +1374,7 @@ export const openDocumentInsights = createAction({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: ActiveDocumentSection,
|
||||
shortcut: [`Meta+Shift+I`],
|
||||
icon: <GraphIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1371,12 +1382,7 @@ export const openDocumentInsights = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.listViews &&
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
return !!activeDocumentId && can.listViews && !document?.isDeleted;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1455,6 +1461,7 @@ export const rootDocumentActions = [
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNewDocument,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
@@ -1476,7 +1483,6 @@ export const rootDocumentActions = [
|
||||
searchInDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
CaseSensitiveIcon,
|
||||
CollectionIcon,
|
||||
CopyIcon,
|
||||
MoveIcon,
|
||||
NewDocumentIcon,
|
||||
PlusIcon,
|
||||
PrintIcon,
|
||||
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 {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
settingsPath,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
|
||||
export const createTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: TemplateSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!stores.policies.abilities(currentTeamId!).createTemplate,
|
||||
to: newTemplatePath(),
|
||||
});
|
||||
|
||||
export const deleteTemplate = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.delete),
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: t("template"),
|
||||
}),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await template.delete();
|
||||
history.push(settingsPath("templates"));
|
||||
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 moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: ActiveTemplateSection,
|
||||
icon: ({ stores }) => {
|
||||
const { team } = stores.auth;
|
||||
return <TeamLogo model={team} size={AvatarSize.Small} />;
|
||||
},
|
||||
visible: ({ getActiveModel }) => {
|
||||
const template = getActiveModel(Template);
|
||||
return !!template?.collectionId;
|
||||
},
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await template.save({ collectionId: null });
|
||||
toast.success(t("Template moved"));
|
||||
stores.dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn't move the template, try again?"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplateToCollection = createAction({
|
||||
name: ({ t }) => t("Move to collection"),
|
||||
analyticsName: "Move template to collection",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CollectionIcon />,
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move template"),
|
||||
content: <TemplateMove template={template} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.move),
|
||||
children: [moveTemplateToWorkspace, moveTemplateToCollection],
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document from template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, getActiveModel, stores }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template || !currentTeamId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (template.collectionId) {
|
||||
return !!stores.policies.abilities(template.collectionId).createDocument;
|
||||
}
|
||||
return !!stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return "";
|
||||
}
|
||||
const collectionId = template?.collectionId ?? activeCollectionId;
|
||||
|
||||
const [pathname, search] = newDocumentPath(collectionId, {
|
||||
templateId: template.id,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateLink = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy template link",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
copy(urlify(template.path));
|
||||
toast.success(t("Link copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateAsPlainText = createAction({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
analyticsName: "Copy template as text",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CaseSensitiveIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toPlainText(template));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [copyTemplateLink, copyTemplateAsPlainText],
|
||||
});
|
||||
|
||||
export const printTemplate = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
|
||||
analyticsName: "Print template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
|
||||
perform: () => {
|
||||
setTimeout(window.print, 0);
|
||||
},
|
||||
});
|
||||
|
||||
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
|
||||
@@ -24,6 +24,15 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
|
||||
ActiveDocumentSection.priority = 0.9;
|
||||
|
||||
export const TemplateSection = ({ t }: ActionContext) => t("Template");
|
||||
|
||||
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
|
||||
const activeTemplate = stores.templates.active;
|
||||
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
|
||||
};
|
||||
|
||||
ActiveTemplateSection.priority = 0.9;
|
||||
|
||||
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
|
||||
|
||||
RecentSection.priority = 1;
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
matchPath,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { RightSidebarProvider } from "~/components/RightSidebarContext";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
@@ -23,20 +16,13 @@ import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments/Comments")
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
);
|
||||
@@ -47,9 +33,7 @@ type Props = {
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const layoutRef = React.useRef<HTMLDivElement>(null);
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
@@ -91,49 +75,20 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
</Fade>
|
||||
);
|
||||
|
||||
const showHistory =
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
}) && can.listRevisions;
|
||||
const showComments =
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded &&
|
||||
!!team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentContextProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout
|
||||
title={team.name}
|
||||
sidebar={sidebar}
|
||||
sidebarRight={sidebarRight}
|
||||
ref={layoutRef}
|
||||
>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
@@ -109,4 +110,4 @@ const Image = styled.img<{ size: number }>`
|
||||
height: ${(props) => props.size}px;
|
||||
`;
|
||||
|
||||
export default Avatar;
|
||||
export default observer(Avatar);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GoToIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -121,4 +122,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
|
||||
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
|
||||
|
||||
@@ -23,12 +23,9 @@ const Container = styled.div<Props>`
|
||||
type ContentProps = { $maxWidth?: string };
|
||||
|
||||
const Content = styled.div<ContentProps>`
|
||||
max-width: ${(props) => props.$maxWidth ?? "46em"};
|
||||
max-width: ${(props: ContentProps) =>
|
||||
props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({
|
||||
|
||||
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as RadixCollapsible from "@radix-ui/react-collapsible";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
interface CollapsibleProps {
|
||||
/** The label displayed on the trigger button. */
|
||||
label: React.ReactNode;
|
||||
/** The content to show/hide inside the collapsible panel. */
|
||||
children: React.ReactNode;
|
||||
/** Whether the collapsible is open by default. */
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state. */
|
||||
open?: boolean;
|
||||
/** Callback fired when the open state changes. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Additional class name for the root element. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An accessible collapsible section built on Radix UI Collapsible.
|
||||
* Renders a trigger button with a disclosure chevron and animated content panel.
|
||||
*
|
||||
* @param props - component props.
|
||||
* @returns the collapsible component.
|
||||
*/
|
||||
export function Collapsible({
|
||||
label,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
return (
|
||||
<RadixCollapsible.Root
|
||||
defaultOpen={defaultOpen}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
className={className}
|
||||
>
|
||||
<StyledTrigger>
|
||||
<StyledExpandedIcon aria-hidden="true" />
|
||||
{label}
|
||||
</StyledTrigger>
|
||||
<StyledContent>{children}</StyledContent>
|
||||
</RadixCollapsible.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease-out;
|
||||
margin-left: -4px;
|
||||
`;
|
||||
|
||||
const StyledTrigger = styled(RadixCollapsible.Trigger)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0 8px 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14pxte
|
||||
|
||||
&:hover {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
${StyledExpandedIcon} {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(RadixCollapsible.Content)`
|
||||
overflow: hidden;
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: slideDown 200ms ease-out;
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
animation: slideUp 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -13,6 +13,7 @@ import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import { Collapsible } from "~/components/Collapsible";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
@@ -144,7 +145,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
<HStack>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("Name")}
|
||||
label={t("Name")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
maxLength: CollectionValidation.maxNameLength,
|
||||
@@ -189,38 +190,44 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
|
||||
<Collapsible label={t("Advanced options")}>
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t("Allow commenting on documents within this collection.")}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t(
|
||||
"Allow commenting on documents within this collection."
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
|
||||
@@ -11,15 +11,15 @@ import useStores from "~/hooks/useStores";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useTemplatesAction = () => {
|
||||
const { documents } = useStores();
|
||||
const { templates } = useStores();
|
||||
|
||||
useEffect(() => {
|
||||
void documents.fetchAllTemplates();
|
||||
}, [documents]);
|
||||
void templates.fetchAll();
|
||||
}, [templates]);
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
templates.alphabetical.map((template) =>
|
||||
createInternalLinkAction({
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
|
||||
},
|
||||
})
|
||||
),
|
||||
[documents.templatesAlphabetical]
|
||||
[templates.alphabetical]
|
||||
);
|
||||
|
||||
const newFromTemplate = useMemo(
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import {
|
||||
CollectionIcon as CollectionIconComponent,
|
||||
HomeIcon,
|
||||
PrivateCollectionIcon,
|
||||
} from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -12,74 +19,112 @@ type DefaultCollectionInputSelectProps = {
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
const DefaultCollectionInputSelect = observer(
|
||||
({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections, ui } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("Collections could not be loaded, please reload the app")
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("Collections could not be loaded, please reload the app")
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
const options: Option[] = React.useMemo(
|
||||
() =>
|
||||
collections.nonPrivate.reduce(
|
||||
(acc, collection) => [
|
||||
if (fetching) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDark = ui.resolvedTheme === "dark";
|
||||
|
||||
// Eagerly resolve collection icon properties within this observer context
|
||||
// to avoid MobX warnings when Radix Select clones elements for the trigger.
|
||||
const options: Option[] = collections.nonPrivate.reduce(
|
||||
(acc, collection) => {
|
||||
const collectionIcon = collection.icon;
|
||||
const rawColor = collection.color ?? colorPalette[0];
|
||||
|
||||
let icon: React.ReactElement;
|
||||
if (!collectionIcon || collectionIcon === "collection") {
|
||||
const color =
|
||||
isDark && rawColor !== "currentColor"
|
||||
? getLuminance(rawColor) > 0.09
|
||||
? rawColor
|
||||
: "currentColor"
|
||||
: rawColor;
|
||||
const Component = collection.isPrivate
|
||||
? PrivateCollectionIcon
|
||||
: CollectionIconComponent;
|
||||
icon = <Component color={color} />;
|
||||
} else {
|
||||
let color = rawColor;
|
||||
if (color !== "currentColor") {
|
||||
if (isDark) {
|
||||
color = getLuminance(color) > 0.09 ? color : "currentColor";
|
||||
} else {
|
||||
color = getLuminance(color) < 0.9 ? color : "currentColor";
|
||||
}
|
||||
}
|
||||
icon = (
|
||||
<Icon
|
||||
value={collectionIcon}
|
||||
color={color}
|
||||
initial={collection.initial}
|
||||
forceColor
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
type: "item",
|
||||
type: "item" as const,
|
||||
label: collection.name,
|
||||
value: collection.id,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
icon,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
] satisfies Option[]
|
||||
),
|
||||
[collections.nonPrivate, t]
|
||||
);
|
||||
];
|
||||
},
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
] satisfies Option[]
|
||||
);
|
||||
|
||||
if (fetching) {
|
||||
return null;
|
||||
return (
|
||||
<InputSelect
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
export default DefaultCollectionInputSelect;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import { ArchiveIcon, GoToIcon, 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, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import { archivePath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
@@ -67,13 +67,6 @@ function DocumentBreadcrumb(
|
||||
visible: document.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
name: t("Templates"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: document.template,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
`;
|
||||
+31
-38
@@ -5,13 +5,13 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
@@ -37,13 +37,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
: true
|
||||
);
|
||||
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.isTemplate]);
|
||||
}, [policies, collectionTrees]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
@@ -80,34 +75,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
{!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>
|
||||
)}
|
||||
<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}>
|
||||
<StyledText type="secondary">
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Copy to <em>{{ location }}</em>"
|
||||
@@ -117,7 +110,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
) : (
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</StyledText>
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || copying} onClick={copy}>
|
||||
{copying ? `${t("Copying")}…` : t("Copy")}
|
||||
</Button>
|
||||
+19
-6
@@ -19,8 +19,8 @@ import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import DocumentExplorerNode from "./DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
@@ -38,9 +38,17 @@ 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, defaultValue }: Props) {
|
||||
function DocumentExplorer({
|
||||
onSubmit,
|
||||
onSelect,
|
||||
items,
|
||||
defaultValue,
|
||||
showDocuments,
|
||||
}: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -141,7 +149,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
|
||||
const normalizedBaseDepth =
|
||||
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -216,7 +225,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || nodes[node].type === "collection";
|
||||
nodes[node].children.length > 0 || showDocuments !== false;
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -402,7 +411,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
<ListSearch
|
||||
ref={inputSearchRef}
|
||||
onChange={handleSearch}
|
||||
placeholder={`${t("Search collections & documents")}…`}
|
||||
placeholder={
|
||||
showDocuments
|
||||
? `${t("Search collections & documents")}…`
|
||||
: `${t("Search collections")}…`
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
<ListContainer>
|
||||
+1
@@ -54,6 +54,7 @@ function DocumentExplorerNode(
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
<Spacer width={width}>
|
||||
{hasChildren && (
|
||||
+2
-1
@@ -4,7 +4,7 @@ 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 { Node as SearchResult } from "./DocumentExplorerNode";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
@@ -54,6 +54,7 @@ function DocumentExplorerSearchResult({
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
{icon}
|
||||
<Flex>
|
||||
@@ -2,16 +2,14 @@ import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -44,21 +42,8 @@ function DocumentMove({ document }: Props) {
|
||||
: true
|
||||
);
|
||||
|
||||
// If the document we're moving is a template, only show collections as
|
||||
// move targets.
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [
|
||||
policies,
|
||||
collectionTrees,
|
||||
document.id,
|
||||
document.parentDocumentId,
|
||||
document.isTemplate,
|
||||
]);
|
||||
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
@@ -92,7 +77,7 @@ function DocumentMove({ document }: Props) {
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Move to <em>{{ location }}</em>"
|
||||
@@ -106,7 +91,7 @@ function DocumentMove({ document }: Props) {
|
||||
) : (
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</StyledText>
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || moving} onClick={move}>
|
||||
{moving ? `${t("Moving")}…` : t("Move")}
|
||||
</Button>
|
||||
@@ -115,23 +100,4 @@ function DocumentMove({ document }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`;
|
||||
|
||||
export const StyledText = styled(Text)`
|
||||
${ellipsis()}
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
export default observer(DocumentMove);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Template from "~/models/Template";
|
||||
import Button from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
};
|
||||
|
||||
function TemplateMove({ template }: Props) {
|
||||
const { dialogs, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
collectionTrees
|
||||
.map((node) => ({ ...node, children: [] }))
|
||||
.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
),
|
||||
[policies, collectionTrees]
|
||||
);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const collectionId = (selectedPath.collectionId ??
|
||||
selectedPath.id) as string;
|
||||
await template.save({ collectionId });
|
||||
|
||||
toast.success(t("Template moved"));
|
||||
|
||||
dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t 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);
|
||||
@@ -0,0 +1,3 @@
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
export default DocumentExplorer;
|
||||
@@ -22,6 +22,7 @@ import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
@@ -39,7 +40,6 @@ type Props = {
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
@@ -59,6 +59,7 @@ function DocumentListItem(
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMobile = useMobile();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
@@ -75,7 +76,6 @@ function DocumentListItem(
|
||||
showCollection,
|
||||
showPublished,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
@@ -83,7 +83,7 @@ function DocumentListItem(
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
const canStar = !document.isArchived;
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
@@ -101,11 +101,10 @@ function DocumentListItem(
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
activeModels: [
|
||||
document,
|
||||
...(!isShared && document.collection ? [document.collection] : []),
|
||||
],
|
||||
}}
|
||||
>
|
||||
<ContextMenu
|
||||
@@ -121,6 +120,9 @@ function DocumentListItem(
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
search: highlight
|
||||
? `?q=${encodeURIComponent(highlight)}`
|
||||
: undefined,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
@@ -159,10 +161,7 @@ function DocumentListItem(
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && <StarButton document={document} />}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
{canStar && !isMobile && <StarButton document={document} />}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
|
||||
@@ -52,7 +52,6 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
isDraft,
|
||||
lastViewedAt,
|
||||
isTasks,
|
||||
isTemplate,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
@@ -142,7 +141,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getChildrenForDocument(document.id).length
|
||||
: 0;
|
||||
const canShowProgressBar = isTasks && !isTemplate;
|
||||
const canShowProgressBar = isTasks;
|
||||
|
||||
const timeSinceNow = () => {
|
||||
if (isDraft || !showLastViewed) {
|
||||
@@ -170,7 +169,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{to ? (
|
||||
<Link to={to} replace={replace}>
|
||||
{content}
|
||||
@@ -219,8 +218,8 @@ const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
const Container = styled(Flex)<{ $rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -266,7 +266,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<>
|
||||
{paragraphs ? (
|
||||
<EditorContainer
|
||||
rtl={props.dir === "rtl"}
|
||||
$rtl={props.dir === "rtl"}
|
||||
grow={props.grow}
|
||||
style={props.style}
|
||||
editorStyle={props.editorStyle}
|
||||
|
||||
@@ -26,8 +26,10 @@ type Props = {
|
||||
className?: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
showFilter?: boolean;
|
||||
showIcons?: boolean;
|
||||
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
|
||||
fetchQueryOptions?: Record<string, string>;
|
||||
disclosure?: boolean;
|
||||
};
|
||||
|
||||
const FilterOptions = ({
|
||||
@@ -36,8 +38,10 @@ const FilterOptions = ({
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
showIcons = true,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
disclosure = true,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -58,7 +62,7 @@ const FilterOptions = ({
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={
|
||||
option.icon ? (
|
||||
option.icon && showIcons ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined
|
||||
}
|
||||
@@ -70,7 +74,7 @@ const FilterOptions = ({
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
),
|
||||
[onSelect, selectedKeys]
|
||||
[onSelect, showIcons, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
@@ -181,8 +185,8 @@ const FilterOptions = ({
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
disclosure
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
@@ -28,9 +30,11 @@ 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;
|
||||
urlObj.hostname === "linear.app"
|
||||
? IntegrationService.Linear
|
||||
: urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.GitLab;
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -58,7 +62,18 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
<Description>{description}</Description>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
|
||||
<Flex wrap>
|
||||
{labels.map((label, index) => (
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
@@ -48,7 +50,18 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
<Description>{description}</Description>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+9
-29
@@ -1,5 +1,4 @@
|
||||
import * as React from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -13,37 +12,23 @@ type Props = {
|
||||
onSelect: (color: string) => void;
|
||||
};
|
||||
|
||||
const ColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
const IconColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
const [selectedColor, setSelectedColor] = React.useState(activeColor);
|
||||
const isBuiltInColor = colorPalette.includes(selectedColor);
|
||||
const color = isBuiltInColor ? undefined : selectedColor;
|
||||
|
||||
const debouncedOnSelect = React.useMemo(
|
||||
() =>
|
||||
debounce((color: string) => {
|
||||
onSelect(color);
|
||||
}, 250),
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
debouncedOnSelect.cancel();
|
||||
},
|
||||
[debouncedOnSelect]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedColor(activeColor);
|
||||
}, [activeColor]);
|
||||
|
||||
const handleSelect = (color: string) => {
|
||||
setSelectedColor(color);
|
||||
debouncedOnSelect(color);
|
||||
onSelect(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
|
||||
<Container justify="space-between" align="center" auto>
|
||||
<PresetColors activeColor={selectedColor} onClick={handleSelect} />
|
||||
<Divider />
|
||||
<SwatchButton
|
||||
color={color}
|
||||
@@ -51,7 +36,7 @@ const ColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
onChange={handleSelect}
|
||||
pickerInModal
|
||||
/>
|
||||
</BuiltinColors>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,18 +46,14 @@ const Divider = styled.div`
|
||||
background-color: ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const BuiltinColors = ({
|
||||
const PresetColors = ({
|
||||
activeColor,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
activeColor: string;
|
||||
onClick: (color: string) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<Container className={className} justify="space-between" align="center" auto>
|
||||
<>
|
||||
{colorPalette.map((color) => (
|
||||
<ColorButton
|
||||
key={color}
|
||||
@@ -81,8 +62,7 @@ const BuiltinColors = ({
|
||||
onClick={() => onClick(color)}
|
||||
/>
|
||||
))}
|
||||
{children}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
const Container = styled(Flex)`
|
||||
@@ -91,4 +71,4 @@ const Container = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
export default ColorPicker;
|
||||
export default IconColorPicker;
|
||||
@@ -6,7 +6,7 @@ import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import { DisplayCategory } from "../utils";
|
||||
import ColorPicker from "./ColorPicker";
|
||||
import IconColorPicker from "./IconColorPicker";
|
||||
import type { DataNode } from "./GridTemplate";
|
||||
import GridTemplate from "./GridTemplate";
|
||||
import { useIconState } from "../useIconState";
|
||||
@@ -122,7 +122,7 @@ const IconPanel = ({
|
||||
onChange={handleFilter}
|
||||
/>
|
||||
</InputSearchContainer>
|
||||
<ColorPicker
|
||||
<IconColorPicker
|
||||
width={panelWidth}
|
||||
activeColor={color}
|
||||
onSelect={onColorChange}
|
||||
|
||||
@@ -13,17 +13,35 @@ export default function CircleIcon({
|
||||
retainColor,
|
||||
...rest
|
||||
}: Props) {
|
||||
const isGradient = color === "rainbow";
|
||||
const fillValue = isGradient ? "url(#circleIconGradient)" : color;
|
||||
|
||||
return (
|
||||
<svg
|
||||
fill={color}
|
||||
fill={fillValue}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
style={retainColor ? { fill: color } : undefined}
|
||||
style={retainColor ? { fill: fillValue } : undefined}
|
||||
{...rest}
|
||||
>
|
||||
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
|
||||
{isGradient && (
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="circleIconGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="#ff5858" />
|
||||
<stop offset="50%" stopColor="#fbcc34" />
|
||||
<stop offset="100%" stopColor="#00c6ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
import CircleIcon from "./CircleIcon";
|
||||
|
||||
export const DottedCircleIcon = styled(CircleIcon)`
|
||||
circle {
|
||||
stroke: ${(props) => props.theme.textSecondary};
|
||||
stroke-dasharray: 2, 2;
|
||||
}
|
||||
`;
|
||||
@@ -95,7 +95,7 @@ const IconWrapper = styled.span`
|
||||
export const Outline = styled(Flex)<{
|
||||
margin?: string | number;
|
||||
hasError?: boolean;
|
||||
focused?: boolean;
|
||||
$focused?: boolean;
|
||||
}>`
|
||||
flex: 1;
|
||||
margin: ${(props) =>
|
||||
@@ -106,7 +106,7 @@ export const Outline = styled(Flex)<{
|
||||
border-color: ${(props) =>
|
||||
props.hasError
|
||||
? props.theme.danger
|
||||
: props.focused
|
||||
: props.$focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: 4px;
|
||||
@@ -224,7 +224,7 @@ function Input(
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={focused} margin={margin}>
|
||||
<Outline $focused={focused} margin={margin}>
|
||||
{prefix}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -7,6 +8,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import { useRightSidebarContent } from "~/components/RightSidebarContext";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
@@ -19,16 +21,15 @@ type Props = {
|
||||
title?: string;
|
||||
/** Left sidebar content. */
|
||||
sidebar?: React.ReactNode;
|
||||
/** Right sidebar content. */
|
||||
sidebarRight?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Layout = React.forwardRef(function Layout_(
|
||||
{ title, children, sidebar, sidebarRight }: Props,
|
||||
{ title, children, sidebar }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
const sidebarRight = useRightSidebarContent();
|
||||
|
||||
return (
|
||||
<Container column auto ref={ref}>
|
||||
@@ -61,7 +62,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{sidebarRight}
|
||||
<AnimatePresence initial={false}>{sidebarRight}</AnimatePresence>
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -97,6 +97,8 @@ type Props = {
|
||||
onUpdate: (activeImage: LightboxImage | null) => void;
|
||||
/** Callback triggered when Lightbox closes */
|
||||
onClose: () => void;
|
||||
/** Whether the editor is read only */
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
const ZoomPanPinchContext = createContext({ isImagePanning: false });
|
||||
@@ -216,7 +218,7 @@ function usePanning() {
|
||||
};
|
||||
}
|
||||
|
||||
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
|
||||
function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
const isIdle = useIdle(3 * Second.ms);
|
||||
const { t } = useTranslation();
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
@@ -571,8 +573,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
|
||||
};
|
||||
|
||||
const svgDataURLToBlob = (dataURL: string) => {
|
||||
// Match the SVG data URL format
|
||||
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
|
||||
// Match the SVG data URL format (with or without charset)
|
||||
const match = dataURL.match(
|
||||
/^data:image\/svg\+xml(?:;charset=utf-8)?,(.*)$/i
|
||||
);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
@@ -769,7 +773,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
{activeImage.source === ImageSource.DiagramsNet &&
|
||||
!Desktop.isElectron() && (
|
||||
!Desktop.isElectron() &&
|
||||
!readOnly && (
|
||||
<Tooltip content={t("Edit diagram")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import type { ActionVariant, ActionWithChildren } from "~/types";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
@@ -61,11 +62,6 @@ export const ContextMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -80,7 +76,7 @@ export const ContextMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type {
|
||||
ActionVariant,
|
||||
ActionWithChildren,
|
||||
@@ -98,11 +99,6 @@ export const DropdownMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
@@ -129,7 +125,7 @@ export const DropdownMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading" &&
|
||||
item.type !== "group" &&
|
||||
item.type !== "custom" &&
|
||||
!!item.icon
|
||||
);
|
||||
|
||||
@@ -84,6 +85,12 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preventCloseHandler = (ev: Event) => {
|
||||
if (item.preventCloseCondition && item.preventCloseCondition()) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
@@ -91,7 +98,10 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<SubMenuContent ref={parentRef}>
|
||||
<SubMenuContent
|
||||
ref={parentRef}
|
||||
onFocusOutside={preventCloseHandler}
|
||||
>
|
||||
<MouseSafeArea parentRef={parentRef} />
|
||||
{submenuItems}
|
||||
</SubMenuContent>
|
||||
@@ -118,6 +128,9 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "separator":
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
case "custom":
|
||||
return <div key={`${item.type}-${index}`}>{item.content}</div>;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -140,6 +153,7 @@ export function toMobileMenuItems(
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading" &&
|
||||
item.type !== "group" &&
|
||||
item.type !== "custom" &&
|
||||
!!item.icon
|
||||
);
|
||||
|
||||
@@ -249,6 +263,9 @@ export function toMobileMenuItems(
|
||||
case "separator":
|
||||
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
case "custom":
|
||||
return <div key={`${item.type}-${index}`}>{item.content}</div>;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -93,9 +94,11 @@ const Modal: React.FC<Props> = ({
|
||||
</DesktopContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Wrapper>
|
||||
|
||||
@@ -39,7 +39,7 @@ const Container = styled(Text)`
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
margin: 1em 0 0;
|
||||
margin: 1em 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { NotificationBadgeType, UserPreference } from "@shared/types";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
/**
|
||||
* Component that keeps the app icon notification badge in sync with unread
|
||||
* notification count. Renders nothing visible — mount near the app root so it
|
||||
* stays alive as long as the user is authenticated.
|
||||
*/
|
||||
function NotificationBadge() {
|
||||
const { notifications } = useStores();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const badgeType = user.getPreference(UserPreference.NotificationBadge);
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
React.useEffect(() => {
|
||||
// Desktop app badge
|
||||
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
|
||||
if (badgeType === NotificationBadgeType.Disabled || unreadCount === 0) {
|
||||
void Desktop.bridge.setNotificationCount(0);
|
||||
} else if (badgeType === NotificationBadgeType.Count) {
|
||||
void Desktop.bridge.setNotificationCount(unreadCount);
|
||||
} else {
|
||||
void Desktop.bridge.setNotificationCount("・");
|
||||
}
|
||||
}
|
||||
|
||||
// PWA badge
|
||||
if ("setAppBadge" in navigator) {
|
||||
if (unreadCount > 0 && badgeType !== NotificationBadgeType.Disabled) {
|
||||
void navigator.setAppBadge(
|
||||
badgeType === NotificationBadgeType.Count ? unreadCount : undefined
|
||||
);
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
}
|
||||
}, [unreadCount, badgeType]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(NotificationBadge);
|
||||
@@ -103,6 +103,7 @@ const StyledLink = styled(Link)`
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
pointer-events: none;
|
||||
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
@@ -8,7 +8,6 @@ import Notification, { type NotificationFilter } from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Empty from "../Empty";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
import Flex from "../Flex";
|
||||
@@ -21,6 +20,55 @@ import Tooltip from "../Tooltip";
|
||||
import NotificationListItem from "./NotificationListItem";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
|
||||
/**
|
||||
* Hook that returns filtered notifications in a stable order. The order is
|
||||
* snapshotted on first call (when the popover mounts) so that toggling
|
||||
* read/unread does not cause items to jump positions. Notifications that
|
||||
* arrive after the snapshot are prepended at the top.
|
||||
*
|
||||
* @param active - the current list of active notifications.
|
||||
* @param filter - the selected notification filter category.
|
||||
* @returns filtered notifications in snapshot order.
|
||||
*/
|
||||
function useStableOrderedNotifications(
|
||||
active: Notification[],
|
||||
filter: NotificationFilter
|
||||
) {
|
||||
const orderSnapshotRef = React.useRef<string[] | null>(null);
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (orderSnapshotRef.current === null) {
|
||||
orderSnapshotRef.current = active.map((n) => n.id);
|
||||
}
|
||||
|
||||
const filtered =
|
||||
filter === "all"
|
||||
? active
|
||||
: active.filter((notification) =>
|
||||
Notification.filterCategories[filter].includes(notification.event)
|
||||
);
|
||||
|
||||
const snapshot = orderSnapshotRef.current;
|
||||
const orderMap = new Map(snapshot.map((id, index) => [id, index]));
|
||||
const inSnapshot: Notification[] = [];
|
||||
const newItems: Notification[] = [];
|
||||
|
||||
for (const notification of filtered) {
|
||||
if (orderMap.has(notification.id)) {
|
||||
inSnapshot.push(notification);
|
||||
} else {
|
||||
newItems.push(notification);
|
||||
}
|
||||
}
|
||||
|
||||
inSnapshot.sort(
|
||||
(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)
|
||||
);
|
||||
|
||||
return [...newItems, ...inSnapshot];
|
||||
}, [active, filter]);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
@@ -50,36 +98,12 @@ function Notifications(
|
||||
[t]
|
||||
);
|
||||
|
||||
const filteredNotifications = React.useMemo(() => {
|
||||
if (filter === "all") {
|
||||
return notifications.active;
|
||||
}
|
||||
const filteredNotifications = useStableOrderedNotifications(
|
||||
notifications.active,
|
||||
filter
|
||||
);
|
||||
|
||||
const eventTypes = Notification.filterCategories[filter];
|
||||
return notifications.active.filter((notification) =>
|
||||
eventTypes.includes(notification.event)
|
||||
);
|
||||
}, [notifications.active, filter]);
|
||||
|
||||
// Update the notification count in the dock icon, if possible.
|
||||
React.useEffect(() => {
|
||||
// Account for old versions of the desktop app that don't have the
|
||||
// setNotificationCount method on the bridge.
|
||||
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
|
||||
void Desktop.bridge.setNotificationCount(
|
||||
notifications.approximateUnreadCount
|
||||
);
|
||||
}
|
||||
|
||||
// PWA badging
|
||||
if ("setAppBadge" in navigator) {
|
||||
if (notifications.approximateUnreadCount) {
|
||||
void navigator.setAppBadge(notifications.approximateUnreadCount);
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
}
|
||||
}, [notifications.approximateUnreadCount]);
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@@ -105,7 +129,7 @@ function Notifications(
|
||||
short
|
||||
nude
|
||||
/>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
{unreadCount > 0 && (
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button
|
||||
action={markNotificationsAsRead}
|
||||
|
||||
@@ -49,7 +49,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
showParentDocuments={showParentDocuments}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showTemplate={showTemplate}
|
||||
showDraft={showDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -125,7 +125,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeDocumentId: document.id }}>
|
||||
<ActionContextProvider value={{ activeModels: [document] }}>
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
ariaLabel={t("Revision options")}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
|
||||
type SetSidebarFn = (content: React.ReactNode) => void;
|
||||
|
||||
const RightSidebarSetterContext = React.createContext<SetSidebarFn | null>(
|
||||
null
|
||||
);
|
||||
const RightSidebarContentContext = React.createContext<React.ReactNode>(null);
|
||||
|
||||
/**
|
||||
* Provider that holds right sidebar content state. Wrap at the layout level
|
||||
* so that scenes can set sidebar content via the setter hook.
|
||||
*/
|
||||
export function RightSidebarProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [content, setContent] = React.useState<React.ReactNode>(null);
|
||||
|
||||
return (
|
||||
<RightSidebarSetterContext.Provider value={setContent}>
|
||||
<RightSidebarContentContext.Provider value={content}>
|
||||
{children}
|
||||
</RightSidebarContentContext.Provider>
|
||||
</RightSidebarSetterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable setter function to set the right sidebar content.
|
||||
* Used by scenes (e.g. Document) to populate the sidebar.
|
||||
*/
|
||||
export function useSetRightSidebar(): SetSidebarFn {
|
||||
const setter = React.useContext(RightSidebarSetterContext);
|
||||
if (!setter) {
|
||||
throw new Error(
|
||||
"useSetRightSidebar must be used within a RightSidebarProvider"
|
||||
);
|
||||
}
|
||||
return setter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current right sidebar content. Used by Layout to render
|
||||
* the sidebar.
|
||||
*/
|
||||
export function useRightSidebarContent(): React.ReactNode {
|
||||
return React.useContext(RightSidebarContentContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Context indicating whether the Right sidebar wrapper is already rendered
|
||||
* by an ancestor. When true, SidebarLayout skips rendering its own Right
|
||||
* wrapper to avoid duplicate animated containers.
|
||||
*/
|
||||
export const RightSidebarWrappedContext = React.createContext(false);
|
||||
@@ -53,6 +53,7 @@ function DocumentListItem(
|
||||
pathname: shareId
|
||||
? sharedModelPath(shareId, document.url)
|
||||
: document.url,
|
||||
search: highlight ? `?q=${encodeURIComponent(highlight)}` : undefined,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type { SearchResult } from "~/types";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
@@ -28,73 +29,112 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const focusRef = React.useRef<HTMLElement | null>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
>();
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
>(searchResults);
|
||||
|
||||
// Cache search results by query string to avoid redundant API calls
|
||||
const cacheRef = React.useRef(new Map<string, SearchResult[]>());
|
||||
const queryRef = React.useRef(query);
|
||||
queryRef.current = query;
|
||||
|
||||
// When the query changes, restore cached results (including empty) or keep
|
||||
// previous results visible until new results arrive to avoid layout shift
|
||||
React.useEffect(() => {
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
setOpen(true);
|
||||
if (!query) {
|
||||
setSearchResults(undefined);
|
||||
return;
|
||||
}
|
||||
}, [searchResults, query]);
|
||||
|
||||
// Clear search results when the query changes to prevent stale results
|
||||
React.useEffect(() => {
|
||||
setSearchResults(undefined);
|
||||
const cached = cacheRef.current.get(query);
|
||||
if (cached !== undefined) {
|
||||
setSearchResults(cached);
|
||||
if (cached.length) {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query: searchQuery, ...options }) => {
|
||||
if (searchQuery?.length > 0) {
|
||||
const response = await documents.search({
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.length) {
|
||||
setSearchResults((state) => [...(state ?? []), ...response]);
|
||||
}
|
||||
|
||||
return response;
|
||||
async ({
|
||||
query: searchQuery,
|
||||
offset = 0,
|
||||
...options
|
||||
}: Record<string, any>) => {
|
||||
if (!searchQuery?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
// Return cached results for first-page lookups
|
||||
if (offset === 0 && cacheRef.current.has(searchQuery)) {
|
||||
return cacheRef.current.get(searchQuery)!;
|
||||
}
|
||||
|
||||
// Force offset to 0 for new queries — PaginatedList's reset() sets
|
||||
// offset via setState but fetchResults still uses the stale value
|
||||
// from its closure
|
||||
if (!cacheRef.current.has(searchQuery)) {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const response = await documents.search({
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
offset,
|
||||
...options,
|
||||
});
|
||||
|
||||
// Build complete result set in cache: replace for new queries, append
|
||||
// for pagination of an existing query
|
||||
const existing = cacheRef.current.get(searchQuery);
|
||||
cacheRef.current.set(
|
||||
searchQuery,
|
||||
existing ? [...existing, ...response] : response
|
||||
);
|
||||
|
||||
// Only update state if this query is still current to prevent stale
|
||||
// results from overwriting newer results after a race condition
|
||||
if (queryRef.current === searchQuery) {
|
||||
setSearchResults(cacheRef.current.get(searchQuery)!);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[documents, shareId]
|
||||
);
|
||||
|
||||
const handleSearchInputChange = React.useMemo(
|
||||
const debouncedSetQuery = React.useMemo(
|
||||
() =>
|
||||
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
const trimmedValue = value.trim();
|
||||
setQuery(trimmedValue);
|
||||
setOpen(!!trimmedValue);
|
||||
}, 300),
|
||||
[cachedQuery]
|
||||
debounce((value: string) => {
|
||||
setQuery(value);
|
||||
setOpen(!!value);
|
||||
}, 250),
|
||||
[]
|
||||
);
|
||||
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
const handleSearchInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debouncedSetQuery(event.target.value.trim());
|
||||
},
|
||||
[debouncedSetQuery]
|
||||
);
|
||||
|
||||
React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
|
||||
|
||||
const handleEscapeList = React.useCallback(
|
||||
() => searchInputRef?.current?.focus(),
|
||||
[searchInputRef]
|
||||
() => searchInputRef.current?.focus(),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchInputFocus = React.useCallback(() => {
|
||||
focusRef.current = searchInputRef.current;
|
||||
}, [searchInputRef]);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -106,6 +146,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (searchResults) {
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||
@@ -116,12 +157,12 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (atEnd) {
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
if (open || atEnd) {
|
||||
ev.preventDefault();
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
@@ -131,21 +172,17 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.currentTarget.value) {
|
||||
if (ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
[open, searchResults]
|
||||
@@ -153,11 +190,12 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
|
||||
const handleSearchItemClick = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = "";
|
||||
focusRef.current = document.getElementById(bodyContentId);
|
||||
}
|
||||
}, [searchInputRef]);
|
||||
}, []);
|
||||
|
||||
useKeyDown("/", (ev) => {
|
||||
if (
|
||||
@@ -193,7 +231,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
align="start"
|
||||
shrink
|
||||
onEscapeKeyDown={handleEscapeList}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onOpenAutoFocus={preventDefault}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target === searchInputRef.current) {
|
||||
@@ -203,8 +241,13 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
options={{
|
||||
query,
|
||||
snippetMinWords: 10,
|
||||
snippetMaxWords: 11,
|
||||
limit: 10,
|
||||
}}
|
||||
items={searchResults}
|
||||
fetch={performSearch}
|
||||
onEscape={handleEscapeList}
|
||||
empty={
|
||||
@@ -218,7 +261,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ref={index === 0 ? firstSearchItem : undefined}
|
||||
document={item.document}
|
||||
context={item.context}
|
||||
highlight={cachedQuery}
|
||||
highlight={query}
|
||||
onClick={handleSearchItemClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -20,11 +20,12 @@ import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Permission } from "~/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { Separator } from "../components";
|
||||
import { Separator, GroupMembersPopover } from "../components";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import { Placeholder } from "../components/Placeholder";
|
||||
import { PublicAccess } from "./PublicAccess";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
|
||||
type Props = {
|
||||
/** Collection to which team members are supposed to be invited */
|
||||
@@ -174,9 +175,15 @@ export const AccessControlList = observer(
|
||||
/>
|
||||
}
|
||||
title={membership.group.name}
|
||||
subtitle={t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})}
|
||||
subtitle={
|
||||
<GroupMembersPopover group={membership.group}>
|
||||
<StyledButtonLink>
|
||||
{t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})}
|
||||
</StyledButtonLink>
|
||||
</GroupMembersPopover>
|
||||
}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
@@ -285,6 +292,13 @@ export const AccessControlList = observer(
|
||||
}
|
||||
);
|
||||
|
||||
const StyledButtonLink = styled(ButtonLink)`
|
||||
color: ${s("textTertiary")};
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
@@ -119,7 +119,27 @@ export const AccessControlList = observer(
|
||||
maxHeight: maxHeight ? maxHeight - publicAccessHeight : undefined,
|
||||
}}
|
||||
>
|
||||
{collection && canCollection.readDocument ? (
|
||||
{document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={<Avatar model={document.createdBy} />}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
{t("Can edit")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
{showLoading ? (
|
||||
<Placeholder />
|
||||
) : (
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : collection && canCollection.readDocument ? (
|
||||
<>
|
||||
{collection.permission ? (
|
||||
<ListItem
|
||||
@@ -162,26 +182,6 @@ export const AccessControlList = observer(
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={<Avatar model={document.createdBy} />}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
{t("Can edit")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
{showLoading ? (
|
||||
<Placeholder />
|
||||
) : (
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showLoading ? (
|
||||
|
||||
@@ -18,7 +18,9 @@ import type { Permission } from "~/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import { GroupMembersPopover } from "../components";
|
||||
import DocumentMemberListItem from "./DocumentMemberListItem";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
|
||||
type Props = {
|
||||
/** Document to which team members are supposed to be invited */
|
||||
@@ -87,7 +89,11 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
const members = React.useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
document.members,
|
||||
Array.from(
|
||||
new Map(
|
||||
document.members.map((memberUser) => [memberUser.id, memberUser])
|
||||
).values()
|
||||
),
|
||||
(memberUser) =>
|
||||
(invitedInSession.includes(memberUser.id) ? "_" : "") +
|
||||
memberUser.name.toLocaleLowerCase(),
|
||||
@@ -122,12 +128,19 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupMemberships
|
||||
.inDocument(document.id)
|
||||
{Array.from(
|
||||
new Map(
|
||||
groupMemberships
|
||||
.inDocument(document.id)
|
||||
.map((membership) => [membership.group.id, membership])
|
||||
).values()
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
).localeCompare(
|
||||
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
|
||||
)
|
||||
)
|
||||
.map((membership) => {
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
@@ -153,9 +166,13 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
</MaybeLink>
|
||||
</Trans>
|
||||
) : (
|
||||
t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})
|
||||
<GroupMembersPopover group={membership.group}>
|
||||
<StyledButtonLink>
|
||||
{t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})}
|
||||
</StyledButtonLink>
|
||||
</GroupMembersPopover>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
@@ -206,6 +223,13 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const StyledButtonLink = styled(ButtonLink)`
|
||||
color: ${s("textTertiary")};
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledLink = styled(Link)`
|
||||
color: ${s("textTertiary")};
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -144,9 +144,10 @@ function PublicAccess(
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
}, [t]);
|
||||
|
||||
const shareUrl = sharedParent?.url
|
||||
? `${sharedParent.url}${document.url}`
|
||||
: (share?.url ?? "");
|
||||
const shareUrl =
|
||||
sharedParent?.url && !document.isDraft
|
||||
? `${sharedParent.url}${document.url}`
|
||||
: (share?.url ?? "");
|
||||
|
||||
const copyButton = (
|
||||
<Tooltip content={t("Copy public link")} placement="top">
|
||||
@@ -290,7 +291,7 @@ function PublicAccess(
|
||||
</>
|
||||
)}
|
||||
|
||||
{sharedParent?.published ? (
|
||||
{sharedParent?.published && !document.isDraft ? (
|
||||
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import type Group from "~/models/Group";
|
||||
import type GroupUser from "~/models/GroupUser";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ListItem } from "./ListItem";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
/** The group to display members for */
|
||||
group: Group;
|
||||
/** The trigger element that opens the popover */
|
||||
children: React.ReactElement;
|
||||
};
|
||||
|
||||
export const GroupMembersPopover = observer(({ group, children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { groupUsers } = useStores();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const members = React.useMemo(
|
||||
() => groupUsers.inGroup(group.id),
|
||||
[groupUsers.orderedData, group.id]
|
||||
);
|
||||
|
||||
const fetchOptions = React.useMemo(
|
||||
() => ({
|
||||
id: group.id,
|
||||
}),
|
||||
[group.id]
|
||||
);
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(groupUser: GroupUser) => (
|
||||
<ListItem
|
||||
key={groupUser.id}
|
||||
image={<Avatar model={groupUser.user} size={AvatarSize.Medium} />}
|
||||
title={groupUser.user.name}
|
||||
subtitle={groupUser.user.email}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
width={320}
|
||||
scrollable
|
||||
shrink
|
||||
>
|
||||
<Container>
|
||||
<Flex style={{ marginBottom: 8 }} column>
|
||||
<Text size="medium" weight="bold">
|
||||
{group.name}
|
||||
</Text>
|
||||
<Text size="small" type="tertiary">
|
||||
{t(`{{ count }} members`, { count: group.memberCount })}
|
||||
</Text>
|
||||
</Flex>
|
||||
{open && (
|
||||
<PaginatedList<GroupUser>
|
||||
items={members}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={fetchOptions}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 12px 24px;
|
||||
`;
|
||||
@@ -166,8 +166,9 @@ export const Suggestions = observer(
|
||||
}
|
||||
|
||||
const isEmpty = suggestions.length === 0;
|
||||
const pendingIdSet = new Set(pendingIds);
|
||||
const suggestionsWithPending = suggestions.filter(
|
||||
(u) => !pendingIds.includes(u.id)
|
||||
(u) => !pendingIdSet.has(u.id)
|
||||
);
|
||||
|
||||
if (users.isFetching && isEmpty && neverRenderedList.current) {
|
||||
@@ -192,8 +193,8 @@ export const Suggestions = observer(
|
||||
...pending.map((suggestion) => (
|
||||
<PendingListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion)}
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion)}
|
||||
onClick={() => removePendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
@@ -211,12 +212,14 @@ export const Suggestions = observer(
|
||||
/>
|
||||
)),
|
||||
pending.length > 0 &&
|
||||
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
|
||||
(suggestionsWithPending.length > 0 || isEmpty) && (
|
||||
<Separator key="separator" />
|
||||
),
|
||||
...suggestionsWithPending.map((suggestion) => (
|
||||
<ListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion as User)}
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion as User)}
|
||||
onClick={() => addPendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
@@ -229,7 +232,9 @@ export const Suggestions = observer(
|
||||
/>
|
||||
)),
|
||||
isEmpty && (
|
||||
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
|
||||
<Empty key="empty" style={{ marginTop: 22 }}>
|
||||
{t("No matches")}
|
||||
</Empty>
|
||||
),
|
||||
]}
|
||||
</ArrowKeyNavigation>
|
||||
|
||||
@@ -7,6 +7,8 @@ import Input, { NativeInput } from "~/components/Input";
|
||||
import { InfoIcon } from "outline-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export { GroupMembersPopover } from "./GroupMembersPopover";
|
||||
|
||||
// TODO: Temp until Button/NudeButton styles are normalized
|
||||
export const Wrapper = styled.div`
|
||||
${NudeButton}:${hover},
|
||||
|
||||
@@ -8,19 +8,23 @@ import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useWindowScrollbarWidth from "~/hooks/useWindowScrollbarWidth";
|
||||
import { sidebarAppearDuration } from "~/styles/animations";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
border?: boolean;
|
||||
/** When true, skip the entrance animation and render at full width immediately. */
|
||||
skipInitialAnimation?: boolean;
|
||||
}
|
||||
|
||||
function Right({ children, border, className }: Props) {
|
||||
function Right({ children, border, className, skipInitialAnimation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const windowScrollbarWidth = useWindowScrollbarWidth();
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -67,16 +71,20 @@ function Right({ children, border, className }: Props) {
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${ui.sidebarRightWidth}px`,
|
||||
width: windowScrollbarWidth
|
||||
? `${ui.sidebarRightWidth - windowScrollbarWidth}px`
|
||||
: `${ui.sidebarRightWidth}px`,
|
||||
}),
|
||||
[ui.sidebarRightWidth]
|
||||
[ui.sidebarRightWidth, windowScrollbarWidth]
|
||||
);
|
||||
|
||||
const animationProps = {
|
||||
initial: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
initial: skipInitialAnimation
|
||||
? false
|
||||
: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
animate: {
|
||||
transition: isResizing
|
||||
? { duration: 0 }
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SidebarIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import type Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -21,7 +17,6 @@ import Section from "./components/Section";
|
||||
import { SharedCollectionLink } from "./components/SharedCollectionLink";
|
||||
import { SharedDocumentLink } from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import { useEffect } from "react";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
@@ -52,7 +47,7 @@ function SharedSidebar({ share }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSidebar $hoverTransition={!teamAvailable} canCollapse={false}>
|
||||
<Sidebar canCollapse={false}>
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
@@ -72,11 +67,6 @@ function SharedSidebar({ share }: Props) {
|
||||
<SearchWrapper>
|
||||
<StyledSearchPopover shareId={shareId} />
|
||||
</SearchWrapper>
|
||||
{!teamAvailable && (
|
||||
<ToggleWrapper>
|
||||
<ToggleSidebar />
|
||||
</ToggleWrapper>
|
||||
)}
|
||||
</TopSection>
|
||||
<Section>
|
||||
{share.collectionId ? (
|
||||
@@ -99,31 +89,10 @@ function SharedSidebar({ share }: Props) {
|
||||
)}
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
</StyledSidebar>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const ToggleSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
|
||||
}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ScrollContainer = styled(Scrollable)`
|
||||
padding-bottom: 16px;
|
||||
`;
|
||||
@@ -143,33 +112,4 @@ const StyledSearchPopover = styled(SearchPopover)`
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
const ToggleWrapper = styled.div`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
transition:
|
||||
opacity 100ms ease-out,
|
||||
transform 100ms ease-out;
|
||||
`;
|
||||
|
||||
const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${({ $hoverTransition }) =>
|
||||
$hoverTransition &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:${hover} {
|
||||
${StyledSearchPopover} {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
${ToggleWrapper} {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(SharedSidebar);
|
||||
|
||||
@@ -15,6 +15,7 @@ import EditableTitle from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -122,10 +123,11 @@ const CollectionLink: React.FC<Props> = ({
|
||||
|
||||
const contextMenuAction = useCollectionMenuAction({
|
||||
collectionId: collection.id,
|
||||
onRename: handleRename,
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
<Relative ref={mergeRefs([parentRef, dropRef])}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
@@ -141,7 +143,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
showActions={menuOpen}
|
||||
$showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
@@ -157,6 +159,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
ellipsis={!isEditing}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
@@ -164,17 +167,18 @@ const CollectionLink: React.FC<Props> = ({
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
{can.createDocument && (
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<Tooltip content={t("New doc")} delay={500}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
@@ -197,6 +201,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
<SidebarLink
|
||||
depth={2}
|
||||
isActive={() => true}
|
||||
ellipsis={false}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
|
||||
@@ -40,6 +40,10 @@ import type UserMembership from "~/models/UserMembership";
|
||||
import type GroupMembership from "~/models/GroupMembership";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -106,8 +110,7 @@ function InnerDocumentLink(
|
||||
membership?.pathToDocument(activeDocument.id);
|
||||
|
||||
return !!(
|
||||
pathToDocument?.map((entry) => entry.id).includes(node.id) ||
|
||||
isActiveDocument
|
||||
pathToDocument?.some((entry) => entry.id === node.id) || isActiveDocument
|
||||
);
|
||||
}, [
|
||||
hasChildDocuments,
|
||||
@@ -120,6 +123,13 @@ function InnerDocumentLink(
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
|
||||
|
||||
// Context-based recursive expand/collapse for descendant DocumentLinks
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Subscribe to recursive expand/collapse events from an ancestor
|
||||
useSidebarDisclosure(setExpanded, setCollapsed);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded();
|
||||
@@ -133,13 +143,18 @@ function InnerDocumentLink(
|
||||
}
|
||||
}, [setCollapsed, expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(() => {
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
setExpanded();
|
||||
}
|
||||
}, [setCollapsed, setExpanded, expanded]);
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLElement>) => {
|
||||
const willExpand = !expanded;
|
||||
if (willExpand) {
|
||||
setExpanded();
|
||||
} else {
|
||||
setCollapsed();
|
||||
}
|
||||
onDisclosureClick(willExpand, ev.altKey);
|
||||
},
|
||||
[setCollapsed, setExpanded, expanded, onDisclosureClick]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void prefetchDocument?.(node.id);
|
||||
@@ -337,7 +352,10 @@ function InnerDocumentLink(
|
||||
]
|
||||
);
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
|
||||
const contextMenuAction = useDocumentMenuAction({
|
||||
documentId: node.id,
|
||||
onRename: handleRename,
|
||||
});
|
||||
|
||||
const labelElement = React.useMemo(
|
||||
() => (
|
||||
@@ -398,7 +416,7 @@ function InnerDocumentLink(
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: node.id,
|
||||
activeModels: document ? [document] : [],
|
||||
}}
|
||||
>
|
||||
<Relative ref={parentRef}>
|
||||
@@ -428,11 +446,12 @@ function InnerDocumentLink(
|
||||
to={toPath}
|
||||
icon={iconElement}
|
||||
label={labelElement}
|
||||
ellipsis={!isEditing}
|
||||
isActive={isActiveCheck}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
$showActions={menuOpen}
|
||||
scrollIntoViewIfNeeded={sidebarContext === "collections"}
|
||||
isDraft={isDraft}
|
||||
ref={ref}
|
||||
@@ -449,6 +468,7 @@ function InnerDocumentLink(
|
||||
<SidebarLink
|
||||
isActive={() => true}
|
||||
depth={depth + 1}
|
||||
ellipsis={false}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
@@ -463,22 +483,24 @@ function InnerDocumentLink(
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import useStores from "~/hooks/useStores";
|
||||
import type { DragObject } from "../hooks/useDragAndDrop";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
|
||||
@@ -36,6 +39,10 @@ function DraggableCollectionLink({
|
||||
);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
|
||||
// Context-based recursive expand/collapse for descendant DocumentLinks
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Drop to reorder collection
|
||||
const [
|
||||
{ isCollectionDropping, isDraggingAnyCollection },
|
||||
@@ -91,15 +98,22 @@ function DraggableCollectionLink({
|
||||
locationSidebarContext,
|
||||
]);
|
||||
|
||||
const handleDisclosureClick = useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
const handleDisclosureClick = useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => {
|
||||
const willExpand = !e;
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
return willExpand;
|
||||
});
|
||||
},
|
||||
[onDisclosureClick]
|
||||
);
|
||||
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Draggable
|
||||
key={collection.id}
|
||||
ref={dragToReorderCollection}
|
||||
@@ -121,7 +135,7 @@ function DraggableCollectionLink({
|
||||
/>
|
||||
)}
|
||||
</Relative>
|
||||
</>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -21,10 +24,20 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => {
|
||||
const willExpand = !e;
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
return willExpand;
|
||||
});
|
||||
},
|
||||
[onDisclosureClick]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (locationSidebarContext === sidebarContext) {
|
||||
@@ -42,15 +55,17 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
depth={0}
|
||||
/>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
membership={membership}
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
membership={membership}
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</SidebarContext.Provider>
|
||||
</Relative>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,10 @@ import type Document from "~/models/Document";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { descendants } from "@shared/utils/tree";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -62,6 +66,14 @@ function DocumentLink(
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
const handleExpand = React.useCallback(() => setExpanded(true), []);
|
||||
const handleCollapse = React.useCallback(() => setExpanded(false), []);
|
||||
|
||||
useSidebarDisclosure(handleExpand, handleCollapse);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded(showChildren);
|
||||
@@ -72,9 +84,12 @@ function DocumentLink(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
const willExpand = !expanded;
|
||||
setExpanded(willExpand);
|
||||
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
|
||||
onDisclosureClick(willExpand, !!altKey);
|
||||
},
|
||||
[expanded]
|
||||
[expanded, onDisclosureClick]
|
||||
);
|
||||
|
||||
// since we don't have access to the collection sort here, we just put any
|
||||
@@ -133,22 +148,24 @@ function DocumentLink(
|
||||
ref={ref}
|
||||
isActive={() => !!isActiveDocument}
|
||||
/>
|
||||
{expanded &&
|
||||
nodeChildren.map((childNode, index) => (
|
||||
<SharedDocumentLink
|
||||
shareId={shareId}
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
{expanded &&
|
||||
nodeChildren.map((childNode, index) => (
|
||||
<SharedDocumentLink
|
||||
shareId={shareId}
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -48,6 +52,12 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
isActiveDocumentInPath && locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Subscribe to recursive expand/collapse events from an ancestor (e.g. GroupLink)
|
||||
useSidebarDisclosure(setExpanded, setCollapsed);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocumentInPath && locationSidebarContext === sidebarContext) {
|
||||
setExpanded();
|
||||
@@ -76,13 +86,15 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
const willExpand = !expanded;
|
||||
if (willExpand) {
|
||||
setExpanded();
|
||||
} else {
|
||||
setCollapsed();
|
||||
}
|
||||
onDisclosureClick(willExpand, ev.altKey);
|
||||
},
|
||||
[expanded, setExpanded, setCollapsed]
|
||||
[expanded, setExpanded, setCollapsed, onDisclosureClick]
|
||||
);
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -158,7 +170,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
NotificationEventType.AddUserToDocument
|
||||
).length > 0
|
||||
}
|
||||
showActions={menuOpen}
|
||||
$showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
@@ -174,20 +186,22 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
</div>
|
||||
</Draggable>
|
||||
</Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
activeDocument={documents.active}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
activeDocument={documents.active}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { extraArea, hover, s } from "@shared/styles";
|
||||
@@ -18,44 +19,46 @@ export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
function SidebarButton_(
|
||||
{
|
||||
position = "top",
|
||||
showMoreMenu,
|
||||
image,
|
||||
title,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}: SidebarButtonProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<Container
|
||||
justify="space-between"
|
||||
align="center"
|
||||
shrink={false}
|
||||
$position={position}
|
||||
>
|
||||
<Button
|
||||
{...rest}
|
||||
onClick={onClick}
|
||||
const SidebarButton = observer(
|
||||
React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
function SidebarButton_(
|
||||
{
|
||||
position = "top",
|
||||
showMoreMenu,
|
||||
image,
|
||||
title,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}: SidebarButtonProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<Container
|
||||
justify="space-between"
|
||||
align="center"
|
||||
shrink={false}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Content>
|
||||
{image}
|
||||
{title && <Title>{title}</Title>}
|
||||
</Content>
|
||||
{showMoreMenu && <StyledMoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
<Button
|
||||
{...rest}
|
||||
onClick={onClick}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Content>
|
||||
{image}
|
||||
{title && <Title>{title}</Title>}
|
||||
</Content>
|
||||
{showMoreMenu && <StyledMoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const StyledMoreIcon = styled(MoreIcon)`
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Represents a recursive expand/collapse event broadcast through context.
|
||||
*/
|
||||
export interface SidebarDisclosureEvent {
|
||||
/** Whether descendants should expand or collapse. */
|
||||
action: "expand" | "collapse";
|
||||
/**
|
||||
* Monotonically increasing counter used to detect new events.
|
||||
* Each increment represents a distinct user interaction.
|
||||
*/
|
||||
generation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for broadcasting recursive expand/collapse events from a parent
|
||||
* (e.g. a collection or document disclosure toggle with alt-click) to all
|
||||
* descendant DocumentLinks in the sidebar tree.
|
||||
*
|
||||
* The nearest provider determines the scope — only descendants within that
|
||||
* provider react to the event. Each DocumentLink should both consume and
|
||||
* provide this context so that alt-click at any level only affects its subtree.
|
||||
*/
|
||||
const SidebarDisclosureContext = createContext<SidebarDisclosureEvent | null>(
|
||||
null
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook that subscribes to recursive expand/collapse events from an ancestor
|
||||
* provider. When a new event is detected, the appropriate callback is invoked.
|
||||
*
|
||||
* Newly mounted components will also react to the current event, which enables
|
||||
* cascading: expanding a parent reveals children, which mount and see the
|
||||
* expand event, then expand themselves to reveal grandchildren, and so on.
|
||||
*
|
||||
* @param onExpand - called when a recursive expand event is received.
|
||||
* @param onCollapse - called when a recursive collapse event is received.
|
||||
*/
|
||||
export function useSidebarDisclosure(
|
||||
onExpand: () => void,
|
||||
onCollapse: () => void
|
||||
): void {
|
||||
const event = useContext(SidebarDisclosureContext);
|
||||
const lastHandledGeneration = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!event || event.generation === lastHandledGeneration.current) {
|
||||
return;
|
||||
}
|
||||
lastHandledGeneration.current = event.generation;
|
||||
|
||||
if (event.action === "expand") {
|
||||
onExpand();
|
||||
} else {
|
||||
onCollapse();
|
||||
}
|
||||
}, [event, onExpand, onCollapse]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the producing side of the disclosure context. Returns the current
|
||||
* event value (to pass to a Provider) and a single callback to handle
|
||||
* alt-click expand/collapse broadcasts.
|
||||
*
|
||||
* This hook also reads the parent context and automatically forwards any
|
||||
* incoming disclosure events so that the cascade propagates through the
|
||||
* entire tree — even when intermediate nodes each create their own provider.
|
||||
*
|
||||
* @returns object with `event` to spread onto the Provider's value and
|
||||
* `onDisclosureClick` to call from disclosure click handlers.
|
||||
*/
|
||||
export function useSidebarDisclosureState() {
|
||||
const parentEvent = useContext(SidebarDisclosureContext);
|
||||
const [event, setEvent] = useState<SidebarDisclosureEvent | null>(null);
|
||||
const lastForwardedParentGeneration = useRef(-1);
|
||||
|
||||
// Forward parent disclosure events into our own provider value so that
|
||||
// grandchildren (and beyond) see the event even though each level creates
|
||||
// its own independent provider.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!parentEvent ||
|
||||
parentEvent.generation === lastForwardedParentGeneration.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastForwardedParentGeneration.current = parentEvent.generation;
|
||||
setEvent((prev) => ({
|
||||
action: parentEvent.action,
|
||||
generation: (prev?.generation ?? 0) + 1,
|
||||
}));
|
||||
}, [parentEvent]);
|
||||
|
||||
/**
|
||||
* Call from a disclosure click handler after toggling expand/collapse state.
|
||||
* When alt is held, broadcasts a recursive expand or collapse event to all
|
||||
* descendants. Otherwise, clears any stale event.
|
||||
*
|
||||
* @param willExpand - whether the node is expanding or collapsing.
|
||||
* @param altKey - whether the alt/option key was held during the click.
|
||||
*/
|
||||
const onDisclosureClick = useCallback(
|
||||
(willExpand: boolean, altKey: boolean) => {
|
||||
if (altKey) {
|
||||
setEvent((prev) => ({
|
||||
action: willExpand ? "expand" : "collapse",
|
||||
generation: (prev?.generation ?? 0) + 1,
|
||||
}));
|
||||
} else {
|
||||
setEvent(null);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { event, onDisclosureClick };
|
||||
}
|
||||
|
||||
export default SidebarDisclosureContext;
|
||||
@@ -40,7 +40,7 @@ type Props = Omit<NavLinkProps, "to"> & {
|
||||
/** Whether to show an unread badge indicator */
|
||||
unreadBadge?: boolean;
|
||||
/** Whether to show action buttons on hover */
|
||||
showActions?: boolean;
|
||||
$showActions?: boolean;
|
||||
/** Whether the link is disabled and non-interactive */
|
||||
disabled?: boolean;
|
||||
/** Whether the link is currently active */
|
||||
@@ -53,6 +53,8 @@ type Props = Omit<NavLinkProps, "to"> & {
|
||||
isDraft?: boolean;
|
||||
/** Nesting depth level for indentation (0-based) */
|
||||
depth?: number;
|
||||
/** Whether to truncate the label text (default: true, causes overflow: hidden) */
|
||||
ellipsis?: boolean;
|
||||
/** Whether to automatically scroll this link into view if needed */
|
||||
scrollIntoViewIfNeeded?: boolean;
|
||||
/** Optional context menu action to display */
|
||||
@@ -79,7 +81,7 @@ function SidebarLink(
|
||||
isActiveDrop,
|
||||
isDraft,
|
||||
menu,
|
||||
showActions,
|
||||
$showActions,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
@@ -89,6 +91,7 @@ function SidebarLink(
|
||||
disabled,
|
||||
unreadBadge,
|
||||
contextAction,
|
||||
ellipsis = true,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
@@ -139,7 +142,7 @@ function SidebarLink(
|
||||
ev.stopPropagation();
|
||||
onDisclosureClick?.(ev);
|
||||
},
|
||||
[onDisclosureClick]
|
||||
[onDisclosureClick, hasDisclosure]
|
||||
);
|
||||
|
||||
const DisclosureComponent = icon ? HiddenDisclosure : Disclosure;
|
||||
@@ -176,11 +179,11 @@ function SidebarLink(
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label $ellipsis={typeof label === "string"}>{label}</Label>
|
||||
<Label $ellipsis={ellipsis}>{label}</Label>
|
||||
{unreadBadge && <UnreadBadge style={unreadStyle} />}
|
||||
</Content>
|
||||
</ContextMenu>
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
{menu && <Actions $showActions={$showActions}>{menu}</Actions>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -199,11 +202,12 @@ const Content = styled.span`
|
||||
align-items: start;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
|
||||
const Actions = styled(EventBoundary)<{ $showActions?: boolean }>`
|
||||
display: inline-flex;
|
||||
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
|
||||
visibility: ${(props) => (props.$showActions ? "visible" : "hidden")};
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 4px;
|
||||
@@ -347,6 +351,7 @@ const Label = styled.div<{ $ellipsis: boolean }>`
|
||||
width: 100%;
|
||||
line-height: 24px;
|
||||
margin-left: 2px;
|
||||
min-width: 0;
|
||||
${(props) => props.$ellipsis && ellipsis()}
|
||||
|
||||
* {
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
@@ -63,7 +66,7 @@ type StarredCollectionLinkProps = {
|
||||
reorderStarProps: any;
|
||||
};
|
||||
|
||||
function StarredDocumentLink({
|
||||
const StarredDocumentLink = observer(function StarredDocumentLink({
|
||||
star,
|
||||
documentId,
|
||||
expanded,
|
||||
@@ -100,7 +103,7 @@ function StarredDocumentLink({
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeModels: [document],
|
||||
}}
|
||||
>
|
||||
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
@@ -121,7 +124,7 @@ function StarredDocumentLink({
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={label}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
$showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
@@ -156,9 +159,9 @@ function StarredDocumentLink({
|
||||
</SidebarContext.Provider>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function StarredCollectionLink({
|
||||
const StarredCollectionLink = observer(function StarredCollectionLink({
|
||||
star,
|
||||
collection,
|
||||
sidebarContext,
|
||||
@@ -185,7 +188,7 @@ function StarredCollectionLink({
|
||||
<Relative>{cursor}</Relative>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function StarredLink({ star }: Props) {
|
||||
const theme = useTheme();
|
||||
@@ -204,6 +207,9 @@ function StarredLink({ star }: Props) {
|
||||
sidebarContext === locationSidebarContext
|
||||
);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
star.documentId === ui.activeDocumentId &&
|
||||
@@ -235,15 +241,25 @@ function StarredLink({ star }: Props) {
|
||||
(ev?: React.MouseEvent<HTMLElement>) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
setExpanded((prevExpanded) => {
|
||||
const willExpand = !prevExpanded;
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
return willExpand;
|
||||
});
|
||||
},
|
||||
[]
|
||||
[onDisclosureClick]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(
|
||||
() => documentId && documents.prefetchDocument(documentId),
|
||||
[documents, documentId]
|
||||
);
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
if (documentId) {
|
||||
void documents.prefetchDocument(documentId);
|
||||
const document = documents.get(documentId);
|
||||
const documentCollection = document?.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
void documentCollection?.fetchDocuments();
|
||||
}
|
||||
}, [documents, documentId, collections]);
|
||||
|
||||
const getIndex = () => {
|
||||
const next = star?.next();
|
||||
@@ -278,39 +294,43 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
if (documentId) {
|
||||
return (
|
||||
<StarredDocumentLink
|
||||
star={star}
|
||||
documentId={documentId}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
handlePrefetch={handlePrefetch}
|
||||
icon={icon}
|
||||
label={label}
|
||||
menuOpen={menuOpen}
|
||||
handleMenuOpen={handleMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
/>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<StarredDocumentLink
|
||||
star={star}
|
||||
documentId={documentId}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
handlePrefetch={handlePrefetch}
|
||||
icon={icon}
|
||||
label={label}
|
||||
menuOpen={menuOpen}
|
||||
handleMenuOpen={handleMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
/>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<StarredCollectionLink
|
||||
star={star}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
displayChildDocuments={displayChildDocuments}
|
||||
reorderStarProps={reorderStarProps}
|
||||
/>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<StarredCollectionLink
|
||||
star={star}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
displayChildDocuments={displayChildDocuments}
|
||||
reorderStarProps={reorderStarProps}
|
||||
/>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,9 @@ function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document?.id,
|
||||
activeCollectionId: collection?.id,
|
||||
activeModels: [document, collection].filter(
|
||||
(m): m is Document | Collection => !!m
|
||||
),
|
||||
}}
|
||||
>
|
||||
<NudeButton
|
||||
|
||||
@@ -2,13 +2,17 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "./primitives/Drawer";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "./primitives/Popover";
|
||||
import Text from "./Text";
|
||||
import { ColorButton } from "./ColorButton";
|
||||
import ColorPicker from "@shared/components/ColorPicker";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
|
||||
/**
|
||||
* Props for the SwatchButton component.
|
||||
@@ -50,19 +54,11 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
|
||||
);
|
||||
|
||||
const pickerContent = (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<StyledColorPicker
|
||||
disableAlpha
|
||||
color={color}
|
||||
onChange={(c) => onChange(c.hex)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<StyledColorPicker
|
||||
alpha={false}
|
||||
activeColor={color}
|
||||
onSelect={(c) => onChange(c)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
@@ -70,7 +66,8 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
|
||||
<DrawerContent aria-label={t("Select a color")}>
|
||||
{pickerContent}
|
||||
<DrawerHandle />
|
||||
<EventBoundary>{pickerContent}</EventBoundary>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
@@ -96,10 +93,6 @@ const StyledContent = styled(PopoverContent)`
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const ColorPicker = lazyWithRetry(() =>
|
||||
import("react-color").then((mod) => ({ default: mod.ChromePicker }))
|
||||
);
|
||||
|
||||
const StyledColorPicker = styled(ColorPicker)`
|
||||
background: inherit !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
@@ -28,6 +28,7 @@ interface Props extends Omit<
|
||||
disabled?: boolean;
|
||||
/** Callback when the switch state changes */
|
||||
onChange?: (checked: boolean) => void;
|
||||
inForm?: boolean;
|
||||
}
|
||||
|
||||
function Switch(
|
||||
@@ -35,6 +36,7 @@ function Switch(
|
||||
width = 32,
|
||||
height = 18,
|
||||
labelPosition = "left",
|
||||
inForm = true,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
@@ -71,7 +73,7 @@ function Switch(
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Wrapper $inForm={inForm}>
|
||||
<Label
|
||||
disabled={disabled}
|
||||
htmlFor={props.id}
|
||||
@@ -100,8 +102,8 @@ function Switch(
|
||||
return component;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
const Wrapper = styled.div<{ $inForm?: boolean }>`
|
||||
padding-bottom: ${(props) => (props.$inForm ? 8 : 0)}px;
|
||||
${undraggableOnDesktop()}
|
||||
`;
|
||||
|
||||
|
||||
@@ -95,6 +95,13 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
/** Restrict shared layout animation to the X axis only. */
|
||||
const horizontalOnly = (transform: Record<string, string>, generated: string) =>
|
||||
generated.replace(
|
||||
/translate3d\(([^,]+),\s*[^,]+,\s*([^)]+)\)/,
|
||||
"translate3d($1, 0px, $2)"
|
||||
);
|
||||
|
||||
const Tab: React.FC<Props> = (props: Props) => {
|
||||
const { children, exact, exactQueryString } = props;
|
||||
const theme = useTheme();
|
||||
@@ -112,6 +119,7 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</TabButton>
|
||||
@@ -140,6 +148,7 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -26,6 +26,7 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { transparentize } from "polished";
|
||||
|
||||
const HEADER_HEIGHT = 40;
|
||||
|
||||
@@ -59,6 +60,7 @@ export type Props<TData> = {
|
||||
};
|
||||
rowHeight: number;
|
||||
stickyOffset?: number;
|
||||
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
|
||||
};
|
||||
|
||||
function Table<TData>({
|
||||
@@ -70,6 +72,7 @@ function Table<TData>({
|
||||
page,
|
||||
rowHeight,
|
||||
stickyOffset = 0,
|
||||
decorateRow,
|
||||
}: Props<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -206,7 +209,7 @@ function Table<TData>({
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as TRow<TData>;
|
||||
return (
|
||||
const baseRow = (
|
||||
<TR
|
||||
role="row"
|
||||
key={row.id}
|
||||
@@ -231,6 +234,14 @@ function Table<TData>({
|
||||
))}
|
||||
</TR>
|
||||
);
|
||||
|
||||
return decorateRow ? (
|
||||
<React.Fragment key={row.id}>
|
||||
{decorateRow(row.original, baseRow)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
baseRow
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
{showPlaceholder && (
|
||||
@@ -326,7 +337,8 @@ const THead = styled.div<{ $topPos: number }>`
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
border-bottom: 1px solid
|
||||
${(props) => transparentize(0.3, props.theme.divider)};
|
||||
background: ${s("background")};
|
||||
`;
|
||||
|
||||
@@ -340,12 +352,17 @@ const TR = styled.div<{ $columns: string }>`
|
||||
display: grid;
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
border-bottom: 1px solid
|
||||
${(props) => transparentize(0.3, props.theme.divider)};
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover ${NudeButton}[aria-haspopup="menu"] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const TH = styled.span`
|
||||
@@ -391,11 +408,17 @@ const TD = styled.span`
|
||||
|
||||
${NudeButton}[aria-haspopup="menu"] {
|
||||
vertical-align: middle;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
|
||||
&[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnimateSharedLayout } from "framer-motion";
|
||||
import { LayoutGroup } from "framer-motion";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -84,13 +84,13 @@ const Tabs: React.FC = ({ children }: Props) => {
|
||||
}, [width, updateShadows]);
|
||||
|
||||
return (
|
||||
<AnimateSharedLayout>
|
||||
<LayoutGroup>
|
||||
<Sticky>
|
||||
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
|
||||
{children}
|
||||
</Nav>
|
||||
</Sticky>
|
||||
</AnimateSharedLayout>
|
||||
</LayoutGroup>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { TemplateForm } from "./TemplateForm";
|
||||
import type Template from "~/models/Template";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const TemplateEdit = observer(function TemplateEdit_({
|
||||
template,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
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} />;
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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 type { ProsemirrorData } from "@shared/types";
|
||||
import type 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);
|
||||
template.data = dataRef.current;
|
||||
};
|
||||
|
||||
const handleSave = (options: { autosave?: boolean }) => {
|
||||
if (options.autosave) {
|
||||
return;
|
||||
}
|
||||
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("You’re 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;
|
||||
`;
|
||||
@@ -0,0 +1,36 @@
|
||||
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 = {
|
||||
collectionId?: string | null;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
export const TemplateNew = observer(function TemplateNew_({
|
||||
collectionId,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { templates } = useStores();
|
||||
const [template] = useState(
|
||||
new Template({ title: "", collectionId }, 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} />;
|
||||
});
|
||||
@@ -49,7 +49,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
|
||||
collections.orderedData.reduce<Option[]>((memo, collection) => {
|
||||
const canCollection = policies.abilities(collection.id);
|
||||
|
||||
if (canCollection.createDocument) {
|
||||
if (canCollection.createTemplate) {
|
||||
memo.push({
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
|
||||
@@ -8,7 +8,6 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Switch from "~/components/Switch";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import SelectLocation from "./SelectLocation";
|
||||
|
||||
type Props = {
|
||||
@@ -18,7 +17,7 @@ type Props = {
|
||||
function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const { documents, templates } = useStores();
|
||||
const document = documents.get(documentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
@@ -28,15 +27,17 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize({
|
||||
const template = await templates.templatize({
|
||||
id: documentId,
|
||||
collectionId,
|
||||
publish,
|
||||
});
|
||||
|
||||
if (template) {
|
||||
history.push(documentPath(template));
|
||||
history.push(template.path);
|
||||
toast.success(t("Template created, go ahead and customize it"));
|
||||
}
|
||||
}, [t, document, history, collectionId, publish]);
|
||||
}, [t, templates, documentId, history, collectionId, publish]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
|
||||
@@ -372,10 +372,7 @@ class WebsocketProvider extends Component<Props> {
|
||||
const group = groups.get(event.groupId!);
|
||||
|
||||
// Any existing child policies are now invalid
|
||||
if (
|
||||
currentUserId &&
|
||||
group?.users.map((u) => u.id).includes(currentUserId)
|
||||
) {
|
||||
if (currentUserId && group?.users.some((u) => u.id === currentUserId)) {
|
||||
const document = documents.get(event.documentId!);
|
||||
if (document) {
|
||||
document.childDocuments.forEach((childDocument) => {
|
||||
|
||||
@@ -40,7 +40,7 @@ const DrawerContent = React.forwardRef<
|
||||
transition: { bounce: 0, duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<StyledInnerContent ref={measureRef} {...rest}>
|
||||
<StyledInnerContent column ref={measureRef} {...rest}>
|
||||
{children}
|
||||
</StyledInnerContent>
|
||||
</StyledContent>
|
||||
@@ -58,9 +58,9 @@ const DrawerTitle = React.forwardRef<
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
<StyledText size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
{children}
|
||||
</Text>
|
||||
</StyledText>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -75,6 +75,10 @@ const DrawerTitle = React.forwardRef<
|
||||
});
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(m.div)`
|
||||
z-index: ${depths.menu};
|
||||
@@ -92,7 +96,7 @@ const StyledContent = styled(m.div)`
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
const StyledInnerContent = styled.div`
|
||||
const StyledInnerContent = styled(Flex)`
|
||||
padding: 6px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
@@ -129,7 +129,7 @@ const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
|
||||
`}
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
!props.disabled &&
|
||||
`
|
||||
&[data-highlighted],
|
||||
&[data-state="open"],
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
@@ -118,7 +119,7 @@ export const MenuLabel = styled.div`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
export const MenuHeader = styled.h3`
|
||||
|
||||
@@ -20,6 +20,7 @@ function BlockMenu(props: Props) {
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
disclosure={options.disclosure}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
import ColorPicker from "@shared/components/ColorPicker";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Props = {
|
||||
/** The currently active color */
|
||||
activeColor: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
function CellBackgroundColorPicker({ activeColor, command }: Props) {
|
||||
const { commands } = useEditor();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(color: string) => {
|
||||
if (commands[command]) {
|
||||
commands[command]({ color });
|
||||
}
|
||||
},
|
||||
[commands, command]
|
||||
);
|
||||
|
||||
return (
|
||||
<ColorPicker alpha activeColor={activeColor} onSelect={handleSelect} />
|
||||
);
|
||||
}
|
||||
|
||||
export default CellBackgroundColorPicker;
|
||||
@@ -84,6 +84,15 @@ export default class ComponentView {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure we don't reuse NodeViews for different nodes that have a distinct identity
|
||||
// This prevents attribute swapping during drag operations.
|
||||
if (
|
||||
this.node.attrs.id !== undefined &&
|
||||
node.attrs.id !== this.node.attrs.id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.node = node;
|
||||
this.decorations = decorations;
|
||||
this.applyDecorationClasses();
|
||||
@@ -137,7 +146,11 @@ export default class ComponentView {
|
||||
}
|
||||
|
||||
stopEvent(event: Event) {
|
||||
return event.type !== "mousedown" && !event.type.startsWith("drag");
|
||||
return (
|
||||
event.type !== "mousedown" &&
|
||||
!event.type.startsWith("drag") &&
|
||||
!event.type.startsWith("drop")
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
|
||||
import { search as emojiSearch } from "@shared/utils/emoji";
|
||||
@@ -38,7 +39,6 @@ const EmojiMenu = (props: Props) => {
|
||||
.map((item) => {
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
// @ts-expect-error emojiMartToGemoji key
|
||||
const id = emojiMartToGemoji[item.id] || item.id;
|
||||
const type = determineIconType(id);
|
||||
const value = type === IconType.Custom ? id : snakeCase(id);
|
||||
@@ -76,4 +76,4 @@ const EmojiMenu = (props: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiMenu;
|
||||
export default observer(EmojiMenu);
|
||||
|
||||
@@ -375,6 +375,10 @@ export default function FindAndReplace({
|
||||
minWidth={420}
|
||||
scrollable={false}
|
||||
onPointerDownOutside={() => setLocalOpen(false)}
|
||||
onFocusOutside={(event) => {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
style={{ marginRight: 16, marginTop: 60 }}
|
||||
>
|
||||
<Content column>
|
||||
|
||||
@@ -90,12 +90,19 @@ function usePosition({
|
||||
} as DOMRect);
|
||||
|
||||
// position at the top right of code blocks
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
const isCodeNodeSelection =
|
||||
selection instanceof NodeSelection && isCode(selection.node);
|
||||
const codeBlock = isCodeNodeSelection
|
||||
? { pos: selection.from, node: selection.node }
|
||||
: findParentNode(isCode)(view.state.selection);
|
||||
const noticeBlock = findParentNode(
|
||||
(node) => node.type.name === "container_notice"
|
||||
)(view.state.selection);
|
||||
|
||||
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
|
||||
if (
|
||||
(codeBlock || noticeBlock) &&
|
||||
(view.state.selection.empty || isCodeNodeSelection)
|
||||
) {
|
||||
const position = codeBlock
|
||||
? codeBlock.pos
|
||||
: noticeBlock
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from "react";
|
||||
import ColorPicker from "@shared/components/ColorPicker";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Props = {
|
||||
/** The currently active color */
|
||||
activeColor: string;
|
||||
};
|
||||
|
||||
function HighlightColorPicker({ activeColor }: Props) {
|
||||
const { commands } = useEditor();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(color: string) => {
|
||||
if (commands.highlight) {
|
||||
commands.highlight({ color });
|
||||
}
|
||||
},
|
||||
[commands]
|
||||
);
|
||||
|
||||
return (
|
||||
<ColorPicker alpha activeColor={activeColor} onSelect={handleSelect} />
|
||||
);
|
||||
}
|
||||
|
||||
export default HighlightColorPicker;
|
||||
@@ -33,6 +33,7 @@ type Props = {
|
||||
mark?: Mark;
|
||||
dictionary: Dictionary;
|
||||
view: EditorView;
|
||||
autoFocus?: boolean;
|
||||
onLinkAdd: () => void;
|
||||
onLinkUpdate: () => void;
|
||||
onLinkRemove: () => void;
|
||||
@@ -45,6 +46,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
mark,
|
||||
dictionary,
|
||||
view,
|
||||
autoFocus,
|
||||
onLinkAdd,
|
||||
onLinkUpdate,
|
||||
onLinkRemove,
|
||||
@@ -70,7 +72,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query });
|
||||
res.data.documents.map(documents.add);
|
||||
}, [query])
|
||||
}, [documents, query])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -201,7 +203,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<InputWrapper ref={wrapperRef}>
|
||||
<InputWrapper>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
@@ -209,7 +211,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleSearch}
|
||||
onFocus={handleSearch}
|
||||
autoFocus={getHref() === ""}
|
||||
autoFocus={autoFocus}
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
{actions.map((action, index) => {
|
||||
@@ -235,8 +237,8 @@ const LinkEditor: React.FC<Props> = ({
|
||||
<>
|
||||
{results.map((doc, index) => (
|
||||
<SuggestionsMenuItem
|
||||
onPointerDown={() => {
|
||||
!mark ? addLink(doc.url) : updateLink(doc.url);
|
||||
onClick={() => {
|
||||
!mark ? addLink(doc.path) : updateLink(doc.path);
|
||||
}}
|
||||
onPointerMove={() => setSelectedIndex(index)}
|
||||
selected={index === selectedIndex}
|
||||
@@ -274,7 +276,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
const InputWrapper = styled(Flex)`
|
||||
pointer-events: all;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
padding: 6px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ type Props = Omit<
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [items, setItems] = useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { auth, documents, users, collections, groups } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
@@ -76,7 +75,15 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (actorId && !loading) {
|
||||
const items: MentionItem[] = users
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [actorId, loading]);
|
||||
|
||||
// Computed in the render body so MobX observer can track store access
|
||||
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
|
||||
// runs outside the reactive context and triggered MobX warnings.
|
||||
const items: MentionItem[] = actorId
|
||||
? users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
@@ -122,7 +129,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
</Flex>
|
||||
),
|
||||
title: group.name,
|
||||
subtitle: t("{{ count }} members", { count: group.memberCount }),
|
||||
subtitle: t("{{ count }} members", {
|
||||
count: group.memberCount,
|
||||
}),
|
||||
section: GroupSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
@@ -151,14 +160,14 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: (
|
||||
subtitle: doc.collectionId ? (
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
),
|
||||
) : undefined,
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
@@ -218,22 +227,8 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
label: search,
|
||||
},
|
||||
} as MentionItem,
|
||||
]);
|
||||
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [
|
||||
t,
|
||||
actorId,
|
||||
loading,
|
||||
search,
|
||||
users,
|
||||
documents,
|
||||
maxResultsInSection,
|
||||
groups,
|
||||
collections,
|
||||
]);
|
||||
])
|
||||
: [];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import React, { useCallback } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
@@ -10,10 +10,12 @@ import { isUrl } from "@shared/utils/urls";
|
||||
import type Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { determineMentionType, isURLMentionable } from "~/utils/mention";
|
||||
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
|
||||
import SuggestionsMenu from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
@@ -23,13 +25,16 @@ type Props = Omit<
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
interface EmbedCheckState {
|
||||
loading: boolean;
|
||||
embeddable?: boolean;
|
||||
}
|
||||
|
||||
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const items = useItems({ pastedText, embeds });
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<SuggestionsMenuItem {...options} title={item.title} icon={item.icon} />
|
||||
),
|
||||
(item, _index, options) => <SuggestionsMenuItem {...options} {...item} />,
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -56,18 +61,47 @@ function useItems({
|
||||
const { t } = useTranslation();
|
||||
const { integrations } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const [embedCheck, setEmbedCheck] = useState<EmbedCheckState>({
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const embed = React.useMemo(() => {
|
||||
if (typeof pastedText === "string") {
|
||||
for (const e of embeds) {
|
||||
const matches = e.matcher(pastedText);
|
||||
if (matches) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
const singleUrl =
|
||||
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
|
||||
const matchedEmbed = singleUrl
|
||||
? getMatchingEmbed(embeds, singleUrl)?.embed
|
||||
: null;
|
||||
const embed = matchedEmbed?.disabled ? null : matchedEmbed;
|
||||
|
||||
// Check embeddability for single URL
|
||||
useEffect(() => {
|
||||
if (!singleUrl || !embed) {
|
||||
setEmbedCheck({ loading: false });
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}, [embeds, pastedText]);
|
||||
|
||||
let cancelled = false;
|
||||
setEmbedCheck({ loading: true });
|
||||
|
||||
client
|
||||
.post<{ embeddable: boolean; reason?: string }>("/urls.checkEmbed", {
|
||||
url: singleUrl,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setEmbedCheck({ loading: false, embeddable: res.embeddable });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// Optimistic on error - allow embedding attempt
|
||||
setEmbedCheck({ loading: false, embeddable: true });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [singleUrl, embed]);
|
||||
|
||||
// single item is pasted.
|
||||
if (typeof pastedText === "string") {
|
||||
@@ -108,14 +142,19 @@ function useItems({
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
subtitle:
|
||||
embedCheck.embeddable === false ? t("Not supported") : undefined,
|
||||
disabled: embedCheck.loading || !embedCheck.embeddable,
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
];
|
||||
}
|
||||
const linksToMentionType: Record<string, MentionType> = {};
|
||||
|
||||
// list is pasted.
|
||||
|
||||
// Check if the links can be converted to mentions.
|
||||
const linksToMentionType: Record<string, MentionType> = {};
|
||||
const convertibleToMentionList = pastedText.every((text) => {
|
||||
if (!isUrl(text)) {
|
||||
return false;
|
||||
@@ -128,7 +167,7 @@ function useItems({
|
||||
|
||||
const mentionType = integration
|
||||
? determineMentionType({ url, integration })
|
||||
: undefined;
|
||||
: MentionType.URL;
|
||||
|
||||
if (mentionType) {
|
||||
linksToMentionType[text] = mentionType;
|
||||
@@ -137,7 +176,7 @@ function useItems({
|
||||
return !!mentionType;
|
||||
});
|
||||
|
||||
// don't render the menu when it can't be converted to mention.
|
||||
// don't render the menu when it can't be converted to mentions.
|
||||
if (!convertibleToMentionList) {
|
||||
return;
|
||||
}
|
||||
@@ -151,6 +190,7 @@ function useItems({
|
||||
{
|
||||
name: "mention_list",
|
||||
title: t("Mention"),
|
||||
visible: !!convertibleToMentionList,
|
||||
icon: <EmailIcon />,
|
||||
attrs: { actorId: user?.id, ...linksToMentionType },
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user