mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
182 Commits
v1.2.0
...
fix/tooltips
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ddccc195a | |||
| 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 | |||
| 4d3f9bf0b4 | |||
| ec87cb0308 | |||
| 9fd54fd83a | |||
| 945d9908ae | |||
| fd9216541c | |||
| c0ac317329 | |||
| f3d1d4e0b2 | |||
| 094ff17f74 | |||
| d2c70ea5fa | |||
| 1675c42364 | |||
| 831f038e3c | |||
| 08c97389b3 | |||
| d7272b242c | |||
| 650c3b5ead | |||
| b343e70b84 | |||
| 7532c428bd | |||
| 536888b076 | |||
| 448ba9c0a6 | |||
| d5c7e0f748 | |||
| dfc2102450 | |||
| 2d1092a2ca | |||
| 50759d40e8 | |||
| bcee4893f4 | |||
| 23d4374cb0 | |||
| 7a7c0ff082 | |||
| 7663c2a643 | |||
| c468019204 | |||
| 5279a58753 | |||
| 221a7ce19e | |||
| ae59a8b25e | |||
| 6c3a0f7cb3 | |||
| cd84ca2fa6 | |||
| 8d5b9b6ac4 | |||
| d9aec40313 | |||
| 77a125d290 | |||
| b123762e86 | |||
| 68fd23580a | |||
| f6e25b0d32 | |||
| b06c18ecf6 | |||
| ca21b8a17d | |||
| 2116d9972f | |||
| 974c5f9f70 | |||
| eb0ac044a0 | |||
| dd9c2a0cd8 | |||
| ad975620b0 | |||
| f5a7904cbd | |||
| c0f276e23f | |||
| e00297a6c7 |
+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__
|
||||
|
||||
+11
-2
@@ -61,6 +61,11 @@ DATABASE_CONNECTION_POOL_MAX=
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/redis-LGM4BFXYp4
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# To enable horizontal scaling of the collaboration service you must provide a Redis URL, it may
|
||||
# be the same as above, or a different server.
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/horizontal-scaling-hkfU5Stao7
|
||||
REDIS_COLLABORATION_URL=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––– FILE STORAGE –––––––––––
|
||||
@@ -198,7 +203,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=
|
||||
@@ -207,7 +212,7 @@ GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# The Linear integration allows previewing issue links as rich mentions
|
||||
# Linear integration allows previewing issue links as rich mentions
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
@@ -218,6 +223,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=
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.2.0
|
||||
Licensed Work: Outline 1.4.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-01-27
|
||||
|
||||
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";
|
||||
@@ -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: ({ getActiveModel, stores }) => {
|
||||
const { documents } = stores;
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypesString;
|
||||
@@ -170,15 +167,10 @@ export const importDocument = createAction({
|
||||
const file = files[0];
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -191,37 +183,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 +224,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 +244,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 +266,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 +295,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 +321,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 +342,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 +370,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 +398,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 +431,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 +450,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 +475,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 +501,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,13 +523,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.createDocument
|
||||
),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
const [pathname, search] = newTemplatePath(collection?.id).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
|
||||
@@ -38,7 +38,9 @@ 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";
|
||||
@@ -46,6 +48,7 @@ import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import { DocumentDownload } from "~/components/DocumentDownload";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
@@ -60,7 +63,6 @@ import {
|
||||
DocumentSection,
|
||||
TrashSection,
|
||||
} from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
@@ -78,6 +80,7 @@ import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type { Action, ActionGroup, ActionSeparator } from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import env from "~/env";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
@@ -518,13 +521,40 @@ export const shareDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "export md markdown html",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Download document"),
|
||||
content: (
|
||||
<DocumentDownload
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Download as Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
@@ -533,70 +563,59 @@ export const downloadDocumentAsHTML = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download(ExportContentType.Html);
|
||||
await document?.download({
|
||||
contentType: ExportContentType.Markdown,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("Download as HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "xml html export",
|
||||
icon: <DownloadIcon />,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download({
|
||||
contentType: ExportContentType.Html,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
name: ({ t }) => t("Download as PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "export",
|
||||
keywords: "pdf export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!(
|
||||
activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED
|
||||
),
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toast.loading(`${t("Exporting")}…`);
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return document
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && toast.dismiss(id));
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download(ExportContentType.Markdown);
|
||||
await document?.download({
|
||||
contentType: ExportContentType.Pdf,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createActionWithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
children: [
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
downloadDocumentAsMarkdown,
|
||||
],
|
||||
});
|
||||
|
||||
export const copyDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: ActiveDocumentSection,
|
||||
@@ -610,9 +629,11 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toMarkdown(document));
|
||||
const res = await client.post("/documents.export", {
|
||||
id: document.id,
|
||||
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
|
||||
});
|
||||
copy(res.data);
|
||||
toast.success(t("Markdown copied to clipboard"));
|
||||
}
|
||||
},
|
||||
@@ -1440,6 +1461,9 @@ export const rootDocumentActions = [
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
downloadDocumentAsMarkdown,
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
|
||||
@@ -224,13 +224,13 @@ export const openKeyboardShortcuts = createAction({
|
||||
export const downloadApp = createExternalLinkAction({
|
||||
name: ({ t }) =>
|
||||
t("Download {{ platform }} app", {
|
||||
platform: isMac() ? "macOS" : "Windows",
|
||||
platform: isMac ? "macOS" : "Windows",
|
||||
}),
|
||||
analyticsName: "Download app",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <BrowserIcon />,
|
||||
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
|
||||
visible: () => !Desktop.isElectron() && isMac && isCloudHosted,
|
||||
url: "https://desktop.getoutline.com",
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
@@ -25,6 +25,21 @@ export const changeToLightTheme = createAction({
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const toggleTheme = createAction({
|
||||
name: ({ t }) => t("Toggle theme"),
|
||||
analyticsName: "Change theme",
|
||||
iconInContextMenu: false,
|
||||
icon: ({ stores }) =>
|
||||
stores.ui.resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
shortcut: ["Meta+Shift+l"],
|
||||
perform: ({ stores }) =>
|
||||
stores.ui.setTheme(
|
||||
stores.ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light
|
||||
),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
name: ({ t }) => t("System"),
|
||||
analyticsName: "Change to system theme",
|
||||
@@ -47,4 +62,4 @@ export const changeTheme = createActionWithChildren({
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
});
|
||||
|
||||
export const rootSettingsActions = [changeTheme];
|
||||
export const rootSettingsActions = [changeTheme, toggleTheme];
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
@@ -132,6 +133,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -24,6 +24,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
const { policies } = useStores();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
||||
const [copying, setCopying] = React.useState<boolean>(false);
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
@@ -51,6 +52,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
try {
|
||||
setCopying(true);
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
@@ -65,6 +67,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSubmit(result);
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t copy the document, try again?"));
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,8 +118,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</StyledText>
|
||||
<Button disabled={!selectedPath} onClick={copy}>
|
||||
{t("Copy")}
|
||||
<Button disabled={!selectedPath || copying} onClick={copy}>
|
||||
{copying ? `${t("Copying")}…` : t("Copy")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { ExportContentType, NotificationEventType } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const DocumentDownload = observer(({ document, onSubmit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const hasChildDocuments = !!document.childDocuments.length;
|
||||
|
||||
const [contentType, setContentType] = useState<ExportContentType>(
|
||||
ExportContentType.Markdown
|
||||
);
|
||||
const [includeChildDocuments, setIncludeChildDocuments] =
|
||||
useState<boolean>(hasChildDocuments);
|
||||
|
||||
const handleContentTypeChange = useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setContentType(ev.target.value as ExportContentType);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleIncludeChildDocumentsChange = useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludeChildDocuments(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const response = await document.download({
|
||||
contentType,
|
||||
includeChildDocuments,
|
||||
});
|
||||
|
||||
if (includeChildDocuments && response?.data?.fileOperation) {
|
||||
const fileOperationId = response.data.fileOperation.id;
|
||||
const toastId = `export-${fileOperationId}`;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
toast.success(t("Export started"), {
|
||||
id: toastId,
|
||||
description: t("A link to your file will be sent through email soon"),
|
||||
duration: 3000,
|
||||
});
|
||||
ui.exportToasts.delete(fileOperationId);
|
||||
}, 6000);
|
||||
|
||||
ui.registerExportToast(fileOperationId, toastId, timeoutId);
|
||||
|
||||
toast.loading(t("Export started"), {
|
||||
id: toastId,
|
||||
description: `${t("Preparing your download")}…`,
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
}, [t, ui, document, contentType, includeChildDocuments, onSubmit]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const radioItems = [
|
||||
{
|
||||
title: "Markdown",
|
||||
description: t(
|
||||
"A file containing the selected documents in Markdown format."
|
||||
),
|
||||
value: ExportContentType.Markdown,
|
||||
},
|
||||
{
|
||||
title: "HTML",
|
||||
description: t(
|
||||
"A file containing the selected documents in HTML format."
|
||||
),
|
||||
value: ExportContentType.Html,
|
||||
},
|
||||
];
|
||||
|
||||
if (env.PDF_EXPORT_ENABLED) {
|
||||
radioItems.push({
|
||||
title: "PDF",
|
||||
description: t(
|
||||
"A file containing the selected documents in PDF format."
|
||||
),
|
||||
value: ExportContentType.Pdf,
|
||||
});
|
||||
}
|
||||
|
||||
return radioItems;
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={includeChildDocuments ? t("Export") : t("Download")}
|
||||
>
|
||||
<Flex gap={12} column>
|
||||
{items.map((item) => (
|
||||
<Option key={item.value}>
|
||||
<StyledInput
|
||||
type="radio"
|
||||
name="format"
|
||||
value={item.value}
|
||||
checked={contentType === item.value}
|
||||
onChange={handleContentTypeChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.description ? (
|
||||
<Text size="small" type="secondary">
|
||||
{item.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
{hasChildDocuments && (
|
||||
<>
|
||||
<hr style={{ margin: "16px 0 " }} />
|
||||
<Option>
|
||||
<StyledInput
|
||||
type="checkbox"
|
||||
name="includeChildDocuments"
|
||||
checked={includeChildDocuments}
|
||||
onChange={handleIncludeChildDocumentsChange}
|
||||
/>
|
||||
<Flex column gap={4}>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include child documents")}
|
||||
</Text>
|
||||
<Text as="p" size="small" type="secondary">
|
||||
<Trans
|
||||
defaults="When selected, exporting the document <em>{{documentName}}</em> may take some time."
|
||||
values={{
|
||||
documentName: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>{" "}
|
||||
{user.subscribedToEventType(
|
||||
NotificationEventType.ExportCompleted
|
||||
) && t("You will receive an email when it's complete.")}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Option>
|
||||
</>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
});
|
||||
|
||||
const Option = styled.label`
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
position: relative;
|
||||
top: 1.5px;
|
||||
`;
|
||||
@@ -121,6 +121,9 @@ function DocumentListItem(
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
search: highlight
|
||||
? `?q=${encodeURIComponent(highlight)}`
|
||||
: undefined,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
|
||||
@@ -170,7 +170,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 +219,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;
|
||||
|
||||
@@ -42,7 +42,7 @@ function DocumentTasks({ document }: Props) {
|
||||
const message = getMessage(t, total, completed);
|
||||
|
||||
return (
|
||||
<Flex align="center" style={{ padding: "0 1px" }} gap={2}>
|
||||
<Flex align="center" style={{ padding: "0 1px" }} gap={2} shrink={false}>
|
||||
{completed === total ? (
|
||||
<Done
|
||||
color={theme.accent}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
@@ -141,11 +142,12 @@ function EditableTitle(
|
||||
return (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<form onSubmit={handleSave}>
|
||||
<EventBoundary as="form" onSubmit={handleSave}>
|
||||
<Input
|
||||
dir="auto"
|
||||
type="text"
|
||||
lang=""
|
||||
name="title"
|
||||
value={value}
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -155,7 +157,7 @@ function EditableTitle(
|
||||
autoFocus
|
||||
{...rest}
|
||||
/>
|
||||
</form>
|
||||
</EventBoundary>
|
||||
) : (
|
||||
<Text
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import type { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
@@ -41,7 +42,14 @@ export type Props = Optional<
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, onChange, onCreateCommentMark, onDeleteCommentMark } = props;
|
||||
const {
|
||||
id,
|
||||
onChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
onFileUploadStart,
|
||||
onFileUploadStop,
|
||||
} = props;
|
||||
const { comments } = useStores();
|
||||
const { shareId } = useShare();
|
||||
const dictionary = useDictionary();
|
||||
@@ -50,12 +58,27 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
// Upload progress tracking for delayed toast
|
||||
const progressMap = React.useMemo(() => new Map<string, number>(), []);
|
||||
const uploadState = React.useRef<{
|
||||
toastId?: string | number;
|
||||
timeoutId?: ReturnType<typeof setTimeout>;
|
||||
progress: Map<string, number>;
|
||||
}>({ progress: progressMap });
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File | string, uploadOptions?: { id?: string }) => {
|
||||
async (
|
||||
file: File | string,
|
||||
uploadOptions?: {
|
||||
id?: string;
|
||||
onProgress?: (fractionComplete: number) => void;
|
||||
}
|
||||
) => {
|
||||
const options = {
|
||||
id: uploadOptions?.id,
|
||||
documentId: id,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
onProgress: uploadOptions?.onProgress,
|
||||
};
|
||||
const result =
|
||||
file instanceof File
|
||||
@@ -68,6 +91,49 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
const { handleClickLink } = useEditorClickHandlers({ shareId });
|
||||
|
||||
// Show toast only after uploads have been running for 2 seconds
|
||||
const handleFileUploadStart = React.useCallback(() => {
|
||||
uploadState.current.timeoutId = setTimeout(() => {
|
||||
uploadState.current.toastId = toast.loading(
|
||||
dictionary.uploadingWithProgress(0)
|
||||
);
|
||||
}, 2000);
|
||||
onFileUploadStart?.();
|
||||
}, [onFileUploadStart, dictionary.uploadingWithProgress]);
|
||||
|
||||
const handleFileUploadProgress = React.useCallback(
|
||||
(fileId: string, fractionComplete: number) => {
|
||||
uploadState.current.progress.set(fileId, fractionComplete);
|
||||
|
||||
// Calculate average progress across all files
|
||||
const progressValues = Array.from(uploadState.current.progress.values());
|
||||
const avgProgress =
|
||||
progressValues.reduce((a, b) => a + b, 0) / progressValues.length;
|
||||
const percent = Math.round(avgProgress * 100);
|
||||
|
||||
// Update toast if visible
|
||||
if (uploadState.current.toastId) {
|
||||
toast.loading(dictionary.uploadingWithProgress(percent), {
|
||||
id: uploadState.current.toastId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[dictionary.uploadingWithProgress]
|
||||
);
|
||||
|
||||
const handleFileUploadStop = React.useCallback(() => {
|
||||
if (uploadState.current.timeoutId) {
|
||||
clearTimeout(uploadState.current.timeoutId);
|
||||
uploadState.current.timeoutId = undefined;
|
||||
}
|
||||
if (uploadState.current.toastId) {
|
||||
toast.dismiss(uploadState.current.toastId);
|
||||
uploadState.current.toastId = undefined;
|
||||
}
|
||||
uploadState.current.progress.clear();
|
||||
onFileUploadStop?.();
|
||||
}, [onFileUploadStop]);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
localRef?.current?.focusAtEnd();
|
||||
}, [localRef]);
|
||||
@@ -114,16 +180,18 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
return insertFiles(view, event, pos, files, {
|
||||
uploadFile: handleUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
onFileUploadStart: handleFileUploadStart,
|
||||
onFileUploadStop: handleFileUploadStop,
|
||||
onFileUploadProgress: handleFileUploadProgress,
|
||||
dictionary,
|
||||
isAttachment,
|
||||
});
|
||||
},
|
||||
[
|
||||
localRef,
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
handleFileUploadStart,
|
||||
handleFileUploadStop,
|
||||
handleFileUploadProgress,
|
||||
dictionary,
|
||||
handleUploadFile,
|
||||
]
|
||||
@@ -198,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}
|
||||
@@ -224,6 +292,9 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
onFileUploadStart={handleFileUploadStart}
|
||||
onFileUploadStop={handleFileUploadStop}
|
||||
onFileUploadProgress={handleFileUploadProgress}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
|
||||
@@ -11,10 +11,6 @@ import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
@@ -29,9 +25,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
React.useState<boolean>(true);
|
||||
const [includePrivate, setIncludePrivate] = React.useState<boolean>(true);
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
const { collections } = useStores();
|
||||
const { collections, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
@@ -57,29 +51,40 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const options = can.createExport
|
||||
? {
|
||||
description: t(`Your file will be available in {{ location }} soon`, {
|
||||
location: `"${t("Settings")} > ${t("Export")}"`,
|
||||
}),
|
||||
action: {
|
||||
label: t("View"),
|
||||
onClick: () => {
|
||||
history.push(settingsPath("export"));
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
description: t(`A link to your file will be sent through email soon`),
|
||||
};
|
||||
let response;
|
||||
|
||||
if (collection) {
|
||||
await collection.export(format, includeAttachments);
|
||||
toast.success(t("Export started"), options);
|
||||
response = await collection.export(format, includeAttachments);
|
||||
} else {
|
||||
await collections.export({ format, includeAttachments, includePrivate });
|
||||
toast.success(t("Export started"), options);
|
||||
response = await collections.export({
|
||||
format,
|
||||
includeAttachments,
|
||||
includePrivate,
|
||||
});
|
||||
}
|
||||
|
||||
if (response?.data?.fileOperation) {
|
||||
const fileOperationId = response.data.fileOperation.id;
|
||||
const toastId = `export-${fileOperationId}`;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
toast.success(t("Export started"), {
|
||||
id: toastId,
|
||||
description: t("A link to your file will be sent through email soon"),
|
||||
duration: 3000,
|
||||
});
|
||||
ui.exportToasts.delete(fileOperationId);
|
||||
}, 6000);
|
||||
|
||||
ui.registerExportToast(fileOperationId, toastId, timeoutId);
|
||||
|
||||
toast.loading(t("Export started"), {
|
||||
id: toastId,
|
||||
description: `${t("Preparing your download")}…`,
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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" ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -61,35 +60,15 @@ function Notifications(
|
||||
);
|
||||
}, [notifications.active, filter]);
|
||||
|
||||
const isEmpty = filteredNotifications.length === 0;
|
||||
|
||||
// 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>
|
||||
<Flex
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "200px",
|
||||
height: "calc(var(--radix-popover-content-available-height) - 44px)",
|
||||
height:
|
||||
"min(300px, calc(var(--radix-popover-content-available-height) - 44px))",
|
||||
}}
|
||||
column
|
||||
>
|
||||
@@ -107,7 +86,7 @@ function Notifications(
|
||||
short
|
||||
nude
|
||||
/>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
{unreadCount > 0 && (
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button
|
||||
action={markNotificationsAsRead}
|
||||
@@ -120,15 +99,17 @@ function Notifications(
|
||||
<NotificationMenu />
|
||||
</HStack>
|
||||
</Header>
|
||||
{isEmpty && (
|
||||
<EmptyNotifications>{t("You're all caught up")}.</EmptyNotifications>
|
||||
)}
|
||||
<React.Suspense fallback={null}>
|
||||
<Scrollable ref={ref} flex topShadow hiddenScrollbars>
|
||||
<Scrollable ref={ref} flex topShadow hiddenScrollbars>
|
||||
<React.Suspense fallback={null}>
|
||||
<PaginatedList<Notification>
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={filteredNotifications}
|
||||
empty={
|
||||
<EmptyNotifications>
|
||||
{t("You're all caught up")}.
|
||||
</EmptyNotifications>
|
||||
}
|
||||
renderItem={(item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
@@ -137,8 +118,8 @@ function Notifications(
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Scrollable>
|
||||
</React.Suspense>
|
||||
</React.Suspense>
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -53,6 +53,7 @@ function DocumentListItem(
|
||||
pathname: shareId
|
||||
? sharedModelPath(shareId, document.url)
|
||||
: document.url,
|
||||
search: highlight ? `?q=${encodeURIComponent(highlight)}` : undefined,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
@@ -153,9 +155,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 +212,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>
|
||||
|
||||
@@ -10,7 +10,12 @@ import { Theme } from "~/stores/UiStore";
|
||||
export const AppearanceAction = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const { resolvedTheme } = ui;
|
||||
const { resolvedTheme, themeOverride } = ui;
|
||||
|
||||
// Hide when theme is locked via query parameter
|
||||
if (themeOverride) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Action>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
@@ -25,6 +27,7 @@ export const StyledInfoIcon = styled(InfoIcon).attrs({
|
||||
})`
|
||||
vertical-align: bottom;
|
||||
margin-right: 2px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const ShareLinkInput = styled(Input)`
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
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 +18,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";
|
||||
|
||||
@@ -72,11 +68,6 @@ function SharedSidebar({ share }: Props) {
|
||||
<SearchWrapper>
|
||||
<StyledSearchPopover shareId={shareId} />
|
||||
</SearchWrapper>
|
||||
{!teamAvailable && (
|
||||
<ToggleWrapper>
|
||||
<ToggleSidebar />
|
||||
</ToggleWrapper>
|
||||
)}
|
||||
</TopSection>
|
||||
<Section>
|
||||
{share.collectionId ? (
|
||||
@@ -103,27 +94,6 @@ function SharedSidebar({ share }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`;
|
||||
|
||||
@@ -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,6 +123,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
|
||||
const contextMenuAction = useCollectionMenuAction({
|
||||
collectionId: collection.id,
|
||||
onRename: handleRename,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -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(
|
||||
() => (
|
||||
@@ -428,6 +446,7 @@ function InnerDocumentLink(
|
||||
to={toPath}
|
||||
icon={iconElement}
|
||||
label={labelElement}
|
||||
ellipsis={!isEditing}
|
||||
isActive={isActiveCheck}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
|
||||
useKeyDown(
|
||||
(event) =>
|
||||
isMac()
|
||||
isMac
|
||||
? event.metaKey && event.key === "["
|
||||
: event.altKey && event.key === "ArrowLeft",
|
||||
() => {
|
||||
@@ -28,7 +28,7 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
|
||||
useKeyDown(
|
||||
(event) =>
|
||||
isMac()
|
||||
isMac
|
||||
? event.metaKey && event.key === "]"
|
||||
: event.altKey && event.key === "ArrowRight",
|
||||
() => {
|
||||
|
||||
@@ -123,7 +123,9 @@ const NavLink = ({
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!isActive,
|
||||
!isActive &&
|
||||
// Don't navigate if a context menu trigger inside this link is open
|
||||
!event.currentTarget.querySelector('[data-state="open"]'),
|
||||
[rest.target, isActive]
|
||||
);
|
||||
|
||||
@@ -139,7 +141,7 @@ const NavLink = ({
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event);
|
||||
|
||||
if (isActive) {
|
||||
if (isActive && !event.defaultPrevented) {
|
||||
onActiveClick?.(event);
|
||||
}
|
||||
|
||||
@@ -160,7 +162,12 @@ const NavLink = ({
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (isActive) {
|
||||
// Prevent navigation if link is active, event is synthetic, or context menu is open
|
||||
if (
|
||||
isActive ||
|
||||
!event.isTrusted ||
|
||||
event.currentTarget.querySelector('[data-state="open"]')
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 */
|
||||
@@ -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,7 +179,7 @@ function SidebarLink(
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label $ellipsis={typeof label === "string"}>{label}</Label>
|
||||
<Label $ellipsis={ellipsis}>{label}</Label>
|
||||
{unreadBadge && <UnreadBadge style={unreadStyle} />}
|
||||
</Content>
|
||||
</ContextMenu>
|
||||
@@ -199,6 +202,7 @@ const Content = styled.span`
|
||||
align-items: start;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/lib/components/chrome/Chrome")
|
||||
);
|
||||
|
||||
const StyledColorPicker = styled(ColorPicker)`
|
||||
background: inherit !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
+68
-16
@@ -7,34 +7,71 @@ import styled, { useTheme } from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NavLink from "~/components/NavLink";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
|
||||
/**
|
||||
* The path to match against the current location.
|
||||
*/
|
||||
to: LocationDescriptor;
|
||||
interface BaseProps {
|
||||
/**
|
||||
* If true, the tab will only be active if the path matches exactly.
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* If true, the tab will only be active if the query string matches exactly.
|
||||
* By default query string parameters are ignored for location mathing.
|
||||
* By default query string parameters are ignored for location matching.
|
||||
*/
|
||||
exactQueryString?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
const TabLink = styled(NavLink)`
|
||||
interface LinkProps extends BaseProps {
|
||||
/**
|
||||
* The path to match against the current location.
|
||||
*/
|
||||
to: LocationDescriptor;
|
||||
/**
|
||||
* Optional click handler called when the tab is clicked (in addition to navigation).
|
||||
*/
|
||||
onClick?: () => void;
|
||||
active?: never;
|
||||
}
|
||||
|
||||
interface ButtonProps extends BaseProps {
|
||||
/**
|
||||
* Click handler for button mode.
|
||||
*/
|
||||
onClick: () => void;
|
||||
/**
|
||||
* Whether the tab is currently active (only used in button mode).
|
||||
*/
|
||||
active: boolean;
|
||||
to?: never;
|
||||
}
|
||||
|
||||
type Props = LinkProps | ButtonProps;
|
||||
|
||||
const tabStyles = `
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textTertiary")};
|
||||
user-select: none;
|
||||
margin-right: 24px;
|
||||
padding: 6px 0;
|
||||
`;
|
||||
|
||||
const TabLink = styled(NavLink)`
|
||||
${tabStyles}
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&: ${hover} {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
`;
|
||||
|
||||
const TabButton = styled.button<{ $active: boolean }>`
|
||||
${tabStyles}
|
||||
color: ${({ $active }) => ($active ? s("textSecondary") : s("textTertiary"))};
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
&: ${hover} {
|
||||
color: ${s("textSecondary")};
|
||||
@@ -58,20 +95,35 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
const Tab: React.FC<Props> = ({
|
||||
children,
|
||||
exact,
|
||||
exactQueryString,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const Tab: React.FC<Props> = (props: Props) => {
|
||||
const { children, exact, exactQueryString } = props;
|
||||
const theme = useTheme();
|
||||
const activeStyle = {
|
||||
color: theme.textSecondary,
|
||||
};
|
||||
|
||||
// Button mode - controlled by onClick and active props (no `to` prop)
|
||||
if ("active" in props && !("to" in props)) {
|
||||
return (
|
||||
<TabButton $active={props.active} onClick={props.onClick}>
|
||||
{children}
|
||||
{props.active && (
|
||||
<Active
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
/>
|
||||
)}
|
||||
</TabButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Link mode - controlled by react-router
|
||||
const { to, ...rest } = props as LinkProps;
|
||||
return (
|
||||
<TabLink
|
||||
{...rest}
|
||||
to={to}
|
||||
exact={exact || exactQueryString}
|
||||
activeStyle={activeStyle}
|
||||
>
|
||||
@@ -82,7 +134,7 @@ const Tab: React.FC<Props> = ({
|
||||
(!exactQueryString ||
|
||||
isEqual(
|
||||
queryString.parse(location.search ?? ""),
|
||||
queryString.parse(rest.to.search as string)
|
||||
queryString.parse(to.search as string)
|
||||
)) && (
|
||||
<Active
|
||||
layoutId="underline"
|
||||
|
||||
@@ -59,6 +59,7 @@ export type Props<TData> = {
|
||||
};
|
||||
rowHeight: number;
|
||||
stickyOffset?: number;
|
||||
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
|
||||
};
|
||||
|
||||
function Table<TData>({
|
||||
@@ -70,6 +71,7 @@ function Table<TData>({
|
||||
page,
|
||||
rowHeight,
|
||||
stickyOffset = 0,
|
||||
decorateRow,
|
||||
}: Props<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -206,7 +208,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 +233,8 @@ function Table<TData>({
|
||||
))}
|
||||
</TR>
|
||||
);
|
||||
|
||||
return decorateRow ? decorateRow(row.original, baseRow) : baseRow;
|
||||
})}
|
||||
</TBody>
|
||||
{showPlaceholder && (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -118,7 +118,7 @@ export const MenuLabel = styled.div`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
export const MenuHeader = styled.h3`
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
|
||||
@@ -151,14 +151,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: {
|
||||
|
||||
@@ -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,44 @@ 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 embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
|
||||
|
||||
// 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 +139,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 +164,7 @@ function useItems({
|
||||
|
||||
const mentionType = integration
|
||||
? determineMentionType({ url, integration })
|
||||
: undefined;
|
||||
: MentionType.URL;
|
||||
|
||||
if (mentionType) {
|
||||
linksToMentionType[text] = mentionType;
|
||||
@@ -137,7 +173,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 +187,7 @@ function useItems({
|
||||
{
|
||||
name: "mention_list",
|
||||
title: t("Mention"),
|
||||
visible: !!convertibleToMentionList,
|
||||
icon: <EmailIcon />,
|
||||
attrs: { actorId: user?.id, ...linksToMentionType },
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Selection } from "prosemirror-state";
|
||||
import type { EditorState, Selection } from "prosemirror-state";
|
||||
import Suggestion from "~/editor/extensions/Suggestion";
|
||||
import { NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
@@ -29,6 +30,10 @@ import getReadOnlyMenuItems from "../menus/readOnly";
|
||||
import getTableMenuItems from "../menus/table";
|
||||
import getTableColMenuItems from "../menus/tableCol";
|
||||
import getTableRowMenuItems from "../menus/tableRow";
|
||||
import {
|
||||
columnDragPluginKey,
|
||||
rowDragPluginKey,
|
||||
} from "@shared/editor/plugins/TableDragState";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { MediaLinkEditor } from "./MediaLinkEditor";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
@@ -53,12 +58,19 @@ type Props = {
|
||||
canUpdate?: boolean;
|
||||
};
|
||||
|
||||
function useIsDragging() {
|
||||
function useIsDragging(state: EditorState) {
|
||||
const [isDragging, setDragging, setNotDragging] = useBoolean();
|
||||
useEventListener("dragstart", setDragging);
|
||||
useEventListener("dragend", setNotDragging);
|
||||
useEventListener("drop", setNotDragging);
|
||||
return isDragging;
|
||||
|
||||
// Check if table row or column is being dragged
|
||||
const columnDragState = columnDragPluginKey.getState(state);
|
||||
const rowDragState = rowDragPluginKey.getState(state);
|
||||
const isTableDragging =
|
||||
columnDragState?.isDragging || rowDragState?.isDragging;
|
||||
|
||||
return isDragging || isTableDragging;
|
||||
}
|
||||
|
||||
enum Toolbar {
|
||||
@@ -69,36 +81,43 @@ enum Toolbar {
|
||||
|
||||
export function SelectionToolbar(props: Props) {
|
||||
const { readOnly = false } = props;
|
||||
const { view, commands } = useEditor();
|
||||
const { view, extensions, commands } = useEditor();
|
||||
const dictionary = useDictionary();
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useMobile();
|
||||
const isActive = props.isActive || isMobile;
|
||||
const isDragging = useIsDragging();
|
||||
|
||||
const { state } = view;
|
||||
const [autoFocusLinkInput, setAutoFocusLinkInput] = React.useState(false);
|
||||
const isDragging = useIsDragging(state);
|
||||
const { selection } = state;
|
||||
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
|
||||
null
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { selection } = state;
|
||||
const linkMark =
|
||||
selection instanceof NodeSelection
|
||||
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
|
||||
: getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const linkMark =
|
||||
selection instanceof NodeSelection
|
||||
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
|
||||
: getMarkRange(selection.$from, state.schema.marks.link);
|
||||
|
||||
const isEmbedSelection =
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "embed";
|
||||
const isEmbedSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "embed";
|
||||
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
setActiveToolbar(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmbedSelection && !readOnly) {
|
||||
setActiveToolbar(Toolbar.Media);
|
||||
} else if (linkMark && !activeToolbar && !readOnly) {
|
||||
} else if (
|
||||
linkMark &&
|
||||
(activeToolbar === null || activeToolbar === Toolbar.Link) &&
|
||||
!readOnly
|
||||
) {
|
||||
setActiveToolbar(Toolbar.Link);
|
||||
} else if (isCodeSelection) {
|
||||
setActiveToolbar(Toolbar.Menu);
|
||||
@@ -109,9 +128,37 @@ export function SelectionToolbar(props: Props) {
|
||||
} else if (selection.empty) {
|
||||
setActiveToolbar(null);
|
||||
}
|
||||
}, [readOnly, selection]);
|
||||
}, [
|
||||
readOnly,
|
||||
isActive,
|
||||
selection,
|
||||
linkMark,
|
||||
isEmbedSelection,
|
||||
isCodeSelection,
|
||||
isNoticeSelection,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocusLinkInput && activeToolbar !== Toolbar.Link) {
|
||||
setAutoFocusLinkInput(false);
|
||||
}
|
||||
}, [activeToolbar]);
|
||||
|
||||
// Refocus the editor when the link toolbar closes to prevent focus loss
|
||||
const prevActiveToolbar = React.useRef(activeToolbar);
|
||||
React.useLayoutEffect(() => {
|
||||
if (
|
||||
prevActiveToolbar.current === Toolbar.Link &&
|
||||
activeToolbar !== Toolbar.Link &&
|
||||
!readOnly &&
|
||||
isActive
|
||||
) {
|
||||
view.focus();
|
||||
}
|
||||
prevActiveToolbar.current = activeToolbar;
|
||||
}, [activeToolbar, readOnly, isActive, view]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const handleClickOutside = (ev: MouseEvent): void => {
|
||||
if (
|
||||
ev.target instanceof HTMLElement &&
|
||||
@@ -128,13 +175,23 @@ export function SelectionToolbar(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't collapse selection if any suggestion menu is open
|
||||
const isSuggestionMenuOpen = extensions.extensions.some(
|
||||
(ext) => ext instanceof Suggestion && ext.isOpen
|
||||
);
|
||||
if (isSuggestionMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.getSelection()?.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatch } = view;
|
||||
dispatch(
|
||||
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
|
||||
view.state.tr.setSelection(
|
||||
TextSelection.near(view.state.doc.resolve(0))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -153,12 +210,12 @@ export function SelectionToolbar(props: Props) {
|
||||
ev.key.toLowerCase() === "k" &&
|
||||
!view.state.selection.empty
|
||||
) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (activeToolbar === Toolbar.Link) {
|
||||
setActiveToolbar(Toolbar.Menu);
|
||||
} else if (activeToolbar === Toolbar.Menu) {
|
||||
setActiveToolbar(Toolbar.Link);
|
||||
}
|
||||
setAutoFocusLinkInput(true);
|
||||
setActiveToolbar(
|
||||
activeToolbar === Toolbar.Link ? Toolbar.Menu : Toolbar.Link
|
||||
);
|
||||
}
|
||||
},
|
||||
view.dom,
|
||||
@@ -179,12 +236,6 @@ export function SelectionToolbar(props: Props) {
|
||||
const isAttachmentSelection =
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "attachment";
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
const link =
|
||||
selection instanceof NodeSelection
|
||||
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
|
||||
: getMarkRange(selection.$from, state.schema.marks.link);
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
@@ -237,7 +288,7 @@ export function SelectionToolbar(props: Props) {
|
||||
|
||||
items = filterExcessSeparators(items);
|
||||
items = items.map((item) => {
|
||||
if (item.children) {
|
||||
if (item.children && Array.isArray(item.children)) {
|
||||
item.children = item.children.map((child) => {
|
||||
if (child.name === "editImageUrl") {
|
||||
child.onClick = () => {
|
||||
@@ -250,6 +301,7 @@ export function SelectionToolbar(props: Props) {
|
||||
|
||||
if (item.name === "linkOnImage" || item.name === "addLink") {
|
||||
item.onClick = () => {
|
||||
setAutoFocusLinkInput(true);
|
||||
setActiveToolbar(Toolbar.Link);
|
||||
};
|
||||
}
|
||||
@@ -276,10 +328,11 @@ export function SelectionToolbar(props: Props) {
|
||||
>
|
||||
{activeToolbar === Toolbar.Link ? (
|
||||
<LinkEditor
|
||||
key={`${selection.from}-${selection.to}`}
|
||||
key={`link-${selection.anchor}`}
|
||||
dictionary={dictionary}
|
||||
autoFocus={autoFocusLinkInput}
|
||||
view={view}
|
||||
mark={link ? link.mark : undefined}
|
||||
mark={linkMark ? linkMark.mark : undefined}
|
||||
onLinkAdd={() => setActiveToolbar(null)}
|
||||
onLinkUpdate={() => setActiveToolbar(null)}
|
||||
onLinkRemove={() => setActiveToolbar(null)}
|
||||
@@ -289,7 +342,7 @@ export function SelectionToolbar(props: Props) {
|
||||
/>
|
||||
) : activeToolbar === Toolbar.Media ? (
|
||||
<MediaLinkEditor
|
||||
key={`embed-${selection.from}`}
|
||||
key={`embed-${selection.anchor}`}
|
||||
node={
|
||||
"node" in selection ? (selection as NodeSelection).node : undefined
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import commandScore from "command-score";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -15,8 +16,14 @@ import { depths, s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import Input from "./Input";
|
||||
@@ -62,6 +69,7 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
onFileUploadStart?: () => void;
|
||||
onFileUploadStop?: () => void;
|
||||
onFileUploadProgress?: (id: string, fractionComplete: number) => void;
|
||||
/** Callback when the menu is closed */
|
||||
onClose: (insertNewLine?: boolean) => void;
|
||||
/** Optional callback when a suggestion is selected */
|
||||
@@ -72,7 +80,6 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
index: number,
|
||||
options: {
|
||||
selected: boolean;
|
||||
onPointerDown: (event: React.SyntheticEvent) => void;
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
) => React.ReactNode;
|
||||
@@ -84,6 +91,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const { view, commands, props: editorProps } = useEditor();
|
||||
const dictionary = useDictionary();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const hasActivated = React.useRef(false);
|
||||
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
|
||||
clientX: 0,
|
||||
@@ -91,6 +99,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
});
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
|
||||
const [position, setPosition] = React.useState<Position>(defaultPosition);
|
||||
const [insertItem, setInsertItem] = React.useState<
|
||||
MenuItem | EmbedDescriptor
|
||||
@@ -100,7 +109,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
React.useEffect(() => {
|
||||
if (props.isActive) {
|
||||
hasActivated.current = true;
|
||||
// Save the selection position when the menu opens. On mobile, the editor
|
||||
// may lose focus/selection when tapping on menu items, so we restore it.
|
||||
requestAnimationFrame(() => {
|
||||
const { from, to } = view.state.selection;
|
||||
selectionRef.current = { from, to };
|
||||
});
|
||||
} else {
|
||||
selectionRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.isActive]);
|
||||
|
||||
const calculatePosition = React.useCallback(
|
||||
@@ -181,9 +199,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const handleClearSearch = React.useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
const selection =
|
||||
isMobile && selectionRef.current ? selectionRef.current : state.selection;
|
||||
const poss = state.doc.cut(
|
||||
state.selection.from - (props.search ?? "").length - props.trigger.length,
|
||||
state.selection.from
|
||||
selection.from - (props.search ?? "").length - props.trigger.length,
|
||||
selection.from
|
||||
);
|
||||
const trimTrigger = poss.textContent.startsWith(props.trigger);
|
||||
|
||||
@@ -197,11 +217,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
"",
|
||||
Math.max(
|
||||
0,
|
||||
state.selection.from -
|
||||
selection.from -
|
||||
(props.search ?? "").length -
|
||||
(trimTrigger ? props.trigger.length : 0)
|
||||
),
|
||||
state.selection.to
|
||||
selection.to
|
||||
)
|
||||
);
|
||||
}, [props.search, props.trigger, view]);
|
||||
@@ -226,8 +246,27 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
setSelectedIndex(0);
|
||||
}, [props.search]);
|
||||
|
||||
const restoreSelection = React.useCallback(() => {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore the saved selection position. On mobile, the editor selection may be
|
||||
// lost when the drawer opens or when tapping on menu items.
|
||||
if (selectionRef.current) {
|
||||
const { from, to } = selectionRef.current;
|
||||
const { tr, doc } = view.state;
|
||||
const selection = TextSelection.create(doc, from, to);
|
||||
view.dispatch(tr.setSelection(selection));
|
||||
|
||||
// Re-focus the editor post-click
|
||||
requestAnimationFrame(() => view.focus());
|
||||
}
|
||||
}, [isMobile, view]);
|
||||
|
||||
const insertNode = React.useCallback(
|
||||
(item: MenuItem | EmbedDescriptor) => {
|
||||
restoreSelection();
|
||||
handleClearSearch();
|
||||
|
||||
const command = item.name ? commands[item.name] : undefined;
|
||||
@@ -248,11 +287,15 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
props.onClose();
|
||||
},
|
||||
[commands, handleClearSearch, props, view]
|
||||
[commands, handleClearSearch, props, restoreSelection, view]
|
||||
);
|
||||
|
||||
const handleClickItem = React.useCallback(
|
||||
(item) => {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onSelect?.(item);
|
||||
|
||||
switch (item.name) {
|
||||
@@ -373,10 +416,14 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const handleFilesPicked = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
// Re-focus the editor as it loses focus when file picker is opened on iOS
|
||||
view.focus();
|
||||
restoreSelection();
|
||||
|
||||
const { uploadFile, onFileUploadStart, onFileUploadStop } = props;
|
||||
const {
|
||||
uploadFile,
|
||||
onFileUploadStart,
|
||||
onFileUploadStop,
|
||||
onFileUploadProgress,
|
||||
} = props;
|
||||
const files = getEventFiles(event);
|
||||
const parent = findParentNode((node) => !!node)(view.state.selection);
|
||||
const attrs = event.currentTarget.dataset.attrs
|
||||
@@ -394,6 +441,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
uploadFile,
|
||||
onFileUploadStart,
|
||||
onFileUploadStop,
|
||||
onFileUploadProgress,
|
||||
dictionary,
|
||||
isAttachment: inputRef.current?.accept === "*",
|
||||
attrs,
|
||||
@@ -534,12 +582,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (filtered.length) {
|
||||
const prevIndex = selectedIndex - 1;
|
||||
const prev = filtered[prevIndex];
|
||||
|
||||
setSelectedIndex(
|
||||
Math.max(0, prev?.name === "separator" ? prevIndex - 1 : prevIndex)
|
||||
);
|
||||
let prevIndex = selectedIndex - 1;
|
||||
while (prevIndex >= 0) {
|
||||
const item = filtered[prevIndex];
|
||||
if (
|
||||
item?.name !== "separator" &&
|
||||
!("disabled" in item && item.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
prevIndex--;
|
||||
}
|
||||
if (prevIndex >= 0) {
|
||||
setSelectedIndex(prevIndex);
|
||||
}
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
@@ -555,15 +611,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
if (filtered.length) {
|
||||
const total = filtered.length - 1;
|
||||
const nextIndex = selectedIndex + 1;
|
||||
const next = filtered[nextIndex];
|
||||
|
||||
setSelectedIndex(
|
||||
Math.min(
|
||||
next?.name === "separator" ? nextIndex + 1 : nextIndex,
|
||||
total
|
||||
)
|
||||
);
|
||||
let nextIndex = selectedIndex + 1;
|
||||
while (nextIndex <= total) {
|
||||
const item = filtered[nextIndex];
|
||||
if (
|
||||
item?.name !== "separator" &&
|
||||
!("disabled" in item && item.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
nextIndex++;
|
||||
}
|
||||
if (nextIndex <= total) {
|
||||
setSelectedIndex(nextIndex);
|
||||
}
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
@@ -590,7 +651,145 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
let previousHeading: string | undefined;
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
close();
|
||||
}
|
||||
},
|
||||
[close]
|
||||
);
|
||||
|
||||
const fileInput = uploadFile && (
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
<Trans>Import document</Trans>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
onChange={handleFilesPicked}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden.Root>
|
||||
);
|
||||
|
||||
const renderItems = () => {
|
||||
let prevHeading: string | undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator") {
|
||||
return (
|
||||
<ListItem key={index}>
|
||||
<hr />
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
!("disabled" in item && item.disabled) &&
|
||||
selectedIndex !== index &&
|
||||
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
|
||||
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
|
||||
(pointerRef.current.clientX !== ev.clientX ||
|
||||
pointerRef.current.clientY !== ev.clientY)
|
||||
) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
pointerRef.current = {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
if (
|
||||
!("disabled" in item && item.disabled) &&
|
||||
selectedIndex !== index
|
||||
) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handleClickItem(item);
|
||||
};
|
||||
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== prevHeading && (
|
||||
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
|
||||
)}
|
||||
<ListItem
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: handleOnClick,
|
||||
})}
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
prevHeading = currentHeading;
|
||||
return response;
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<ListItem>
|
||||
<Empty>{dictionary.noResults}</Empty>
|
||||
</ListItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<Drawer open={isActive} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent aria-describedby={undefined}>
|
||||
<DrawerTitle hidden>{props.trigger}</DrawerTitle>
|
||||
<MobileScrollable hiddenScrollbars>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
"placeholder" in insertItem && !!insertItem.placeholder
|
||||
? insertItem.placeholder
|
||||
: insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
</MobileScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{fileInput}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
@@ -614,99 +813,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator") {
|
||||
return (
|
||||
<ListItem key={index}>
|
||||
<hr />
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
selectedIndex !== index &&
|
||||
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
|
||||
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
|
||||
(pointerRef.current.clientX !== ev.clientX ||
|
||||
pointerRef.current.clientY !== ev.clientY)
|
||||
) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
pointerRef.current = {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
if (selectedIndex !== index) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handleClickItem(item);
|
||||
};
|
||||
|
||||
const stopPropagation = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== previousHeading && (
|
||||
<MenuHeader key={currentHeading}>
|
||||
{currentHeading}
|
||||
</MenuHeader>
|
||||
)}
|
||||
<ListItem
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onPointerDown: handleOnClick,
|
||||
onClick: stopPropagation,
|
||||
})}
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
previousHeading = currentHeading;
|
||||
return response;
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<ListItem>
|
||||
<Empty>{dictionary.noResults}</Empty>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
{uploadFile && (
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
<Trans>Import document</Trans>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
onChange={handleFilesPicked}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden.Root>
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
{fileInput}
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
@@ -747,6 +856,10 @@ const Empty = styled.div`
|
||||
padding: 0 16px;
|
||||
`;
|
||||
|
||||
const MobileScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Scrollable)<{
|
||||
active: boolean;
|
||||
top?: number;
|
||||
|
||||
@@ -15,7 +15,7 @@ export type Props = {
|
||||
/** Whether the item is disabled */
|
||||
disabled?: boolean;
|
||||
/** Callback when the item is clicked */
|
||||
onPointerDown: (event: React.SyntheticEvent) => void;
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
/** Callback when the item is hovered */
|
||||
onPointerMove?: (event: React.SyntheticEvent) => void;
|
||||
/** An optional icon for the item */
|
||||
@@ -31,7 +31,7 @@ export type Props = {
|
||||
function SuggestionsMenuItem({
|
||||
selected,
|
||||
disabled,
|
||||
onPointerDown,
|
||||
onClick,
|
||||
onPointerMove,
|
||||
title,
|
||||
subtitle,
|
||||
@@ -60,7 +60,7 @@ function SuggestionsMenuItem({
|
||||
<MenuButton
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
$active={selected}
|
||||
>
|
||||
@@ -68,7 +68,10 @@ function SuggestionsMenuItem({
|
||||
<MenuLabel>
|
||||
{title}
|
||||
{subtitle && (
|
||||
<Subtitle $active={selected}>· {subtitle}</Subtitle>
|
||||
<>
|
||||
<Subtitle $active={selected}>·</Subtitle>
|
||||
<Subtitle $active={selected}>{subtitle}</Subtitle>
|
||||
</>
|
||||
)}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuLabel>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import * as Toolbar from "@radix-ui/react-toolbar";
|
||||
@@ -22,16 +22,32 @@ type Props = {
|
||||
items: MenuItem[];
|
||||
};
|
||||
|
||||
/*
|
||||
type ToolbarDropdownProps = {
|
||||
active: boolean;
|
||||
item: MenuItem;
|
||||
tooltip?: string;
|
||||
shortcut?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a dropdown menu in the floating toolbar.
|
||||
*/
|
||||
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
function ToolbarDropdown(props: ToolbarDropdownProps) {
|
||||
const { commands, view } = useEditor();
|
||||
const { t } = useTranslation();
|
||||
const { item } = props;
|
||||
const { item, shortcut, tooltip } = props;
|
||||
const { state } = view;
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setIsOpen(open);
|
||||
}, []);
|
||||
|
||||
const items: TMenuItem[] = useMemo(() => {
|
||||
if (!isOpen) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
@@ -48,47 +64,80 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
}
|
||||
};
|
||||
|
||||
return item.children
|
||||
? item.children.map((child) => {
|
||||
if (child.name === "separator") {
|
||||
return { type: "separator", visible: child.visible };
|
||||
}
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
|
||||
children.map((child) => {
|
||||
if (child.name === "separator") {
|
||||
return { type: "separator", visible: child.visible };
|
||||
}
|
||||
if ("content" in child) {
|
||||
return {
|
||||
type: "button",
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected:
|
||||
child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapChildren(resolvedChildren),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
}, [item.children, commands, state]);
|
||||
}
|
||||
return {
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected:
|
||||
child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedItemChildren = resolveChildren(item.children);
|
||||
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
|
||||
}, [isOpen, commands]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
ev.stopImmediatePropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu>
|
||||
<MenuTrigger>
|
||||
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent
|
||||
align="end"
|
||||
aria-label={item.tooltip || t("More options")}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<EventBoundary>{toMenuItems(items)}</EventBoundary>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
<Tooltip shortcut={shortcut} content={tooltip} disabled={isOpen}>
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger>
|
||||
<ToolbarButton
|
||||
aria-label={item.label ? undefined : item.tooltip}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent
|
||||
align="end"
|
||||
aria-label={item.tooltip || t("More options")}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<EventBoundary>{toMenuItems(items)}</EventBoundary>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +176,20 @@ function ToolbarMenu(props: Props) {
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
|
||||
if (item.children) {
|
||||
return (
|
||||
<ToolbarDropdown
|
||||
key={index}
|
||||
active={isActive && !item.label}
|
||||
item={item}
|
||||
tooltip={
|
||||
item.label === item.tooltip ? undefined : item.tooltip
|
||||
}
|
||||
shortcut={item.shortcut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
@@ -135,17 +198,13 @@ function ToolbarMenu(props: Props) {
|
||||
>
|
||||
{item.name === "dimensions" ? (
|
||||
<MediaDimension key={index} />
|
||||
) : item.children ? (
|
||||
<ToolbarDropdown
|
||||
active={isActive && !item.label}
|
||||
item={item}
|
||||
/>
|
||||
) : (
|
||||
<Toolbar.Button asChild>
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
aria-label={item.label ? undefined : item.tooltip}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
|
||||
@@ -120,6 +120,7 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
uploadFile={props.uploadFile}
|
||||
onFileUploadStart={props.onFileUploadStart}
|
||||
onFileUploadStop={props.onFileUploadStop}
|
||||
onFileUploadProgress={props.onFileUploadProgress}
|
||||
embeds={props.embeds}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,9 @@ import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import type { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { Action, toggleFoldPluginKey } from "@shared/editor/nodes/ToggleBlock";
|
||||
import { isToggleBlock } from "@shared/editor/queries/toggleBlock";
|
||||
import { ancestors } from "@shared/editor/utils";
|
||||
import FindAndReplace from "../components/FindAndReplace";
|
||||
|
||||
const pluginKey = new PluginKey("find-and-replace");
|
||||
@@ -147,6 +150,9 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
this.currentResultIndex = 0;
|
||||
|
||||
dispatch?.(state.tr.setMeta(pluginKey, {}));
|
||||
this.expandFoldedTogglesForCurrentMatch();
|
||||
this.scrollToCurrentMatch();
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -192,20 +198,77 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
}
|
||||
|
||||
dispatch?.(state.tr.setMeta(pluginKey, {}));
|
||||
|
||||
const element = window.document.querySelector(
|
||||
`.${this.options.resultCurrentClassName}`
|
||||
);
|
||||
if (element) {
|
||||
scrollIntoView(element, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
this.expandFoldedTogglesForCurrentMatch();
|
||||
this.scrollToCurrentMatch();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
private scrollToCurrentMatch() {
|
||||
const element = window.document.querySelector(
|
||||
`.${this.options.resultCurrentClassName}`
|
||||
);
|
||||
if (element) {
|
||||
scrollIntoView(element, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand any folded toggle blocks that contain the current match.
|
||||
*/
|
||||
private expandFoldedTogglesForCurrentMatch() {
|
||||
const result = this.results[this.currentResultIndex];
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.editor.view.state;
|
||||
const pluginState = toggleFoldPluginKey.getState(state);
|
||||
if (!pluginState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $pos = state.doc.resolve(result.from);
|
||||
const isToggle = isToggleBlock(state);
|
||||
|
||||
// Find all ancestor toggle block IDs that are folded
|
||||
const foldedToggleIds = ancestors($pos)
|
||||
.filter(
|
||||
(node) => isToggle(node) && pluginState.foldedIds.has(node.attrs.id)
|
||||
)
|
||||
.map((node) => node.attrs.id as string);
|
||||
|
||||
// Unfold each toggle by ID (getting fresh state after each dispatch)
|
||||
foldedToggleIds.forEach((toggleId) => {
|
||||
const currentState = this.editor.view.state;
|
||||
|
||||
// Find the position of this toggle in the current document
|
||||
let togglePos: number | null = null;
|
||||
currentState.doc.descendants((node, pos) => {
|
||||
if (
|
||||
node.type.name === "container_toggle" &&
|
||||
node.attrs.id === toggleId
|
||||
) {
|
||||
togglePos = pos;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (togglePos !== null) {
|
||||
this.editor.view.dispatch(
|
||||
currentState.tr.setMeta(toggleFoldPluginKey, {
|
||||
type: Action.UNFOLD,
|
||||
at: togglePos,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
|
||||
const nextIndex = index + 1;
|
||||
|
||||
|
||||
@@ -447,6 +447,25 @@ export default class PasteHandler extends Extension {
|
||||
}
|
||||
};
|
||||
|
||||
// Not a list of embeds technically, but inserts many embeds at once.
|
||||
private insertEmbedList = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.placeholderId());
|
||||
|
||||
// Remove just the placeholder here.
|
||||
// Embed list will be created by SuggestionsMenu.
|
||||
if (result) {
|
||||
const tr = state.tr.setMeta(this.key, {
|
||||
remove: { id: this.placeholderId() },
|
||||
});
|
||||
|
||||
view.dispatch(
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private handleList(listNode: Node) {
|
||||
const { view, schema } = this.editor;
|
||||
const { state } = view;
|
||||
@@ -547,6 +566,11 @@ export default class PasteHandler extends Extension {
|
||||
this.insertMentionList();
|
||||
break;
|
||||
}
|
||||
case "embed_list": {
|
||||
this.hidePasteMenu();
|
||||
this.insertEmbedList();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -75,4 +75,9 @@ export default class Suggestion extends Extension {
|
||||
open: false,
|
||||
query: "",
|
||||
});
|
||||
|
||||
/** Whether the suggestion menu is currently open. */
|
||||
get isOpen(): boolean {
|
||||
return this.state.open;
|
||||
}
|
||||
}
|
||||
|
||||
+27
-5
@@ -9,11 +9,11 @@ import { gapCursor } from "prosemirror-gapcursor";
|
||||
import type { InputRule } from "prosemirror-inputrules";
|
||||
import { inputRules } from "prosemirror-inputrules";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import type { MarkdownParser } from "prosemirror-markdown";
|
||||
import type { NodeSpec, MarkSpec } from "prosemirror-model";
|
||||
import { Schema, Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import type { Plugin, Transaction } from "prosemirror-state";
|
||||
import { EditorState, Selection } from "prosemirror-state";
|
||||
import { EditorState, Selection, TextSelection } from "prosemirror-state";
|
||||
import type { MarkdownParser } from "prosemirror-markdown";
|
||||
import {
|
||||
AddMarkStep,
|
||||
RemoveMarkStep,
|
||||
@@ -78,6 +78,11 @@ export type Props = {
|
||||
focusedCommentId?: string;
|
||||
/** If the editor should not allow editing */
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Whether we are rendering a cached version of the document while multiplayer loads.
|
||||
* This is used to disable some editor functionality
|
||||
*/
|
||||
cacheOnly?: boolean;
|
||||
/** If the editor should still allow editing checkboxes when it is readOnly */
|
||||
canUpdate?: boolean;
|
||||
/** If the editor should still allow commenting when it is readOnly */
|
||||
@@ -95,7 +100,10 @@ export type Props = {
|
||||
/** Heading id to scroll to when the editor has loaded */
|
||||
scrollTo?: string;
|
||||
/** Callback for handling uploaded images, should return the url of uploaded file */
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
uploadFile?: (
|
||||
file: File | string,
|
||||
options?: { id?: string; onProgress?: (fractionComplete: number) => void }
|
||||
) => Promise<string>;
|
||||
/** Callback when prosemirror nodes are initialized on document mount. */
|
||||
onInit?: () => void;
|
||||
/** Callback when prosemirror nodes are destroyed on document unmount. */
|
||||
@@ -116,10 +124,14 @@ export type Props = {
|
||||
onCreateCommentMark?: (commentId: string, userId: string) => void;
|
||||
/** Callback when a comment mark is removed */
|
||||
onDeleteCommentMark?: (commentId: string) => void;
|
||||
/** Callback when comments sidebar should be opened */
|
||||
onOpenCommentsSidebar?: () => void;
|
||||
/** Callback when a file upload begins */
|
||||
onFileUploadStart?: () => void;
|
||||
/** Callback when a file upload ends */
|
||||
onFileUploadStop?: () => void;
|
||||
/** Callback when file upload progress changes */
|
||||
onFileUploadProgress?: (id: string, fractionComplete: number) => void;
|
||||
/** Callback when a link is created, should return url to created document */
|
||||
onCreateLink?: (params: Properties<Document>) => Promise<string>;
|
||||
/** Callback when user clicks on any link in the document */
|
||||
@@ -165,6 +177,7 @@ export class Editor extends React.PureComponent<
|
||||
defaultValue: "",
|
||||
dir: "auto",
|
||||
placeholder: "Write something nice…",
|
||||
readOnly: false,
|
||||
onFileUploadStart: () => {
|
||||
// no default behavior
|
||||
},
|
||||
@@ -523,6 +536,13 @@ export class Editor extends React.PureComponent<
|
||||
this.mutationObserver = observe(
|
||||
hash,
|
||||
(element) => {
|
||||
const pos = this.view.posAtDOM(element, 0, 1);
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.setSelection(
|
||||
TextSelection.near(this.view.state.doc.resolve(pos), 1)
|
||||
)
|
||||
);
|
||||
|
||||
if (isVisible(element)) {
|
||||
element.scrollIntoView();
|
||||
}
|
||||
@@ -839,7 +859,7 @@ export class Editor extends React.PureComponent<
|
||||
column
|
||||
>
|
||||
<EditorContainer
|
||||
rtl={isRTL}
|
||||
$rtl={isRTL}
|
||||
grow={grow}
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={canUpdate}
|
||||
@@ -852,6 +872,7 @@ export class Editor extends React.PureComponent<
|
||||
/>
|
||||
|
||||
{this.widgets &&
|
||||
!this.props.cacheOnly &&
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget
|
||||
key={String(index)}
|
||||
@@ -868,10 +889,11 @@ export class Editor extends React.PureComponent<
|
||||
</Flex>
|
||||
{!isNull(this.state.activeLightboxImage) && (
|
||||
<Lightbox
|
||||
readOnly={readOnly}
|
||||
images={this.getLightboxImages()}
|
||||
activeImage={this.state.activeLightboxImage}
|
||||
onUpdate={this.updateActiveLightboxImage}
|
||||
onClose={() => this.view.focus()}
|
||||
onClose={this.view.focus.bind(this.view)}
|
||||
/>
|
||||
)}
|
||||
</EditorContext.Provider>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
MathIcon,
|
||||
DoneIcon,
|
||||
EmbedIcon,
|
||||
CollapseIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -234,7 +235,7 @@ export default function blockMenuItems(
|
||||
title: "Mermaid Diagram",
|
||||
icon: <Img src="/images/mermaidjs.png" alt="Mermaid Diagram" />,
|
||||
keywords: "diagram flowchart",
|
||||
attrs: { language: "mermaidjs" },
|
||||
attrs: { language: "mermaid" },
|
||||
},
|
||||
{
|
||||
name: "editDiagram",
|
||||
@@ -242,6 +243,12 @@ export default function blockMenuItems(
|
||||
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
|
||||
keywords: "diagram flowchart draw.io",
|
||||
},
|
||||
{
|
||||
name: "container_toggle",
|
||||
title: dictionary.toggleBlock,
|
||||
icon: <CollapseIcon />,
|
||||
keywords: "toggle collapsible collapse fold",
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out diagrams.net in desktop app
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
codeLanguages,
|
||||
getLabelForLanguage,
|
||||
} from "@shared/editor/lib/code";
|
||||
import { isMermaid } from "@shared/editor/lib/isCode";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -61,7 +62,7 @@ export default function codeMenuItems(
|
||||
tooltip: dictionary.editDiagram,
|
||||
visible:
|
||||
!(mermaidPluginKey.getState(state) as MermaidState)?.editingId &&
|
||||
node?.attrs.language === "mermaidjs" &&
|
||||
isMermaid(node) &&
|
||||
!readOnly,
|
||||
},
|
||||
{
|
||||
|
||||
+209
-30
@@ -19,10 +19,15 @@ import {
|
||||
Heading3Icon,
|
||||
TableMergeCellsIcon,
|
||||
TableSplitCellsIcon,
|
||||
PaletteIcon,
|
||||
CollapseIcon,
|
||||
} from "outline-icons";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
import HighlightColorPicker from "../components/HighlightColorPicker";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import styled from "styled-components";
|
||||
import Highlight from "@shared/editor/marks/Highlight";
|
||||
|
||||
import { getDocumentHighlightColors } from "@shared/editor/queries/getDocumentHighlightColors";
|
||||
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInList } from "@shared/editor/queries/isInList";
|
||||
@@ -37,10 +42,17 @@ import {
|
||||
isTouchDevice,
|
||||
} from "@shared/utils/browser";
|
||||
import {
|
||||
getColorSetForSelectedCells,
|
||||
getDocumentTableBackgroundColors,
|
||||
hasNodeAttrMarkCellSelection,
|
||||
hasNodeAttrMarkWithAttrsCellSelection,
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import TableCell from "@shared/editor/nodes/TableCell";
|
||||
import Highlight from "@shared/editor/marks/Highlight";
|
||||
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
|
||||
|
||||
export default function formattingMenuItems(
|
||||
state: EditorState,
|
||||
@@ -60,7 +72,16 @@ export default function formattingMenuItems(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
state
|
||||
).find(({ mark }) => mark.type.name === "highlight");
|
||||
).find(({ mark }) => mark.type === state.schema.marks.highlight);
|
||||
|
||||
const cellSelectionHasBackground = isTableCell
|
||||
? hasNodeAttrMarkCellSelection(
|
||||
state.selection as CellSelection,
|
||||
"background"
|
||||
)
|
||||
: false;
|
||||
|
||||
const selectedCellsColorSet = getColorSetForSelectedCells(state.selection);
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -98,36 +119,193 @@ export default function formattingMenuItems(
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
getColorSetForSelectedCells(state.selection).size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : getColorSetForSelectedCells(state.selection).size === 1 ? (
|
||||
<CircleIcon
|
||||
color={
|
||||
getColorSetForSelectedCells(state.selection).values().next().value
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
visible: !isCode && (!isMobile || !isEmpty) && isTableCell,
|
||||
children: (): MenuItem[] => {
|
||||
// Get all unique background colors used in table cells (lazily computed when menu opens)
|
||||
const documentTableColors = getDocumentTableBackgroundColors(state);
|
||||
|
||||
// Filter out preset colors and currently selected colors
|
||||
const nonPresetDocumentColors = documentTableColors.filter(
|
||||
(color: string) =>
|
||||
!TableCell.isPresetColor(color) && !selectedCellsColorSet.has(color)
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "toggleCellSelectionBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (cellSelectionHasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleCellSelectionBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () =>
|
||||
hasNodeAttrMarkWithAttrsCellSelection(
|
||||
state.selection as CellSelection,
|
||||
"background",
|
||||
{ color: preset.hex }
|
||||
),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(selectedCellsColorSet.size === 1 &&
|
||||
!TableCell.isPresetColor(selectedCellsColorSet.values().next().value)
|
||||
? [
|
||||
{
|
||||
name: "toggleCellSelectionBackgroundAndCollapseSelection",
|
||||
label: selectedCellsColorSet.values().next().value,
|
||||
icon: (
|
||||
<CircleIcon
|
||||
retainColor
|
||||
color={selectedCellsColorSet.values().next().value}
|
||||
/>
|
||||
),
|
||||
active: () => true,
|
||||
attrs: { color: selectedCellsColorSet.values().next().value },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Add all other document table background colors
|
||||
...nonPresetDocumentColors.map((color: string) => ({
|
||||
name: "toggleCellSelectionBackgroundAndCollapseSelection",
|
||||
label: color,
|
||||
icon: <CircleIcon retainColor color={color} />,
|
||||
active: () => selectedCellsColorSet.has(color),
|
||||
attrs: { color },
|
||||
})),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
command="toggleCellSelectionBackground"
|
||||
activeColor={
|
||||
selectedCellsColorSet.size === 1
|
||||
? selectedCellsColorSet.values().next().value
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
shortcut: `${metaDisplay}+⇧+H`,
|
||||
icon: highlight ? (
|
||||
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
|
||||
<CircleIcon
|
||||
color={highlight.mark.attrs.color || Highlight.presetColors[0].hex}
|
||||
/>
|
||||
) : (
|
||||
<HighlightIcon />
|
||||
),
|
||||
active: () => !!highlight,
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
children: [
|
||||
...(highlight
|
||||
? [
|
||||
visible: !isCode && (!isMobile || !isEmpty) && !isTableCell,
|
||||
children: (): MenuItem[] => {
|
||||
// Get all unique highlight colors used in the document (lazily computed when menu opens)
|
||||
const documentHighlightColors = getDocumentHighlightColors(state);
|
||||
|
||||
// Filter out preset colors and the currently selected color
|
||||
const currentHighlightColor = highlight?.mark.attrs.color;
|
||||
const nonPresetDocumentColors = documentHighlightColors.filter(
|
||||
(color: string) =>
|
||||
!Highlight.isPresetColor(color) && color !== currentHighlightColor
|
||||
);
|
||||
|
||||
return [
|
||||
...(highlight
|
||||
? [
|
||||
{
|
||||
name: "highlight",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => false,
|
||||
attrs: { color: highlight.mark.attrs.color },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...Highlight.presetColors.map((preset) => ({
|
||||
name: "highlight",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: isMarkActive(schema.marks.highlight, { color: preset.hex }),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(highlight &&
|
||||
highlight.mark.attrs.color &&
|
||||
!Highlight.isPresetColor(highlight.mark.attrs.color)
|
||||
? [
|
||||
{
|
||||
name: "highlight",
|
||||
label: highlight.mark.attrs.color,
|
||||
icon: (
|
||||
<CircleIcon
|
||||
retainColor
|
||||
color={highlight.mark.attrs.color}
|
||||
/>
|
||||
),
|
||||
active: isMarkActive(schema.marks.highlight, {
|
||||
color: highlight.mark.attrs.color,
|
||||
}),
|
||||
attrs: { color: highlight.mark.attrs.color },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Add all other document highlight colors
|
||||
...nonPresetDocumentColors.map((color: string) => ({
|
||||
name: "highlight",
|
||||
label: color,
|
||||
icon: <CircleIcon retainColor color={color} />,
|
||||
active: () => currentHighlightColor === color,
|
||||
attrs: { color },
|
||||
})),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
name: "highlight",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => false,
|
||||
attrs: { color: highlight.mark.attrs.color },
|
||||
content: (
|
||||
<HighlightColorPicker
|
||||
activeColor={
|
||||
highlight?.mark.attrs.color ||
|
||||
Highlight.presetColors[0].hex
|
||||
}
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...Highlight.colors.map((color, index) => ({
|
||||
name: "highlight",
|
||||
label: Highlight.colorNames[index],
|
||||
icon: <CircleIcon retainColor color={color} />,
|
||||
active: isMarkActive(schema.marks.highlight, { color }),
|
||||
attrs: { color },
|
||||
})),
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "code_inline",
|
||||
@@ -192,6 +370,14 @@ export default function formattingMenuItems(
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "container_toggle",
|
||||
icon: <CollapseIcon />,
|
||||
tooltip: dictionary.toggleBlock,
|
||||
active: isNodeActive(schema.nodes.container_toggle),
|
||||
attrs: { id: uuidv4() },
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
@@ -287,10 +473,3 @@ export default function formattingMenuItems(
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const DottedCircleIcon = styled(CircleIcon)`
|
||||
circle {
|
||||
stroke: ${(props) => props.theme.textSecondary};
|
||||
stroke-dasharray: 2, 2;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
TableSplitCellsIcon,
|
||||
AlphabeticalSortIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
SortAscendingIcon,
|
||||
SortDescendingIcon,
|
||||
TableColumnsDistributeIcon,
|
||||
} from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
@@ -18,12 +19,41 @@ import { CellSelection, selectedRect } from "prosemirror-tables";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import {
|
||||
getAllSelectedColumns,
|
||||
getCellsInColumn,
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
tableHasRowspan,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
import TableCell from "@shared/editor/nodes/TableCell";
|
||||
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
|
||||
|
||||
/**
|
||||
* Get the set of background colors used in a column
|
||||
*/
|
||||
function getColumnColors(state: EditorState, colIndex: number): Set<string> {
|
||||
const colors = new Set<string>();
|
||||
const cells = getCellsInColumn(colIndex)(state) || [];
|
||||
|
||||
cells.forEach((pos) => {
|
||||
const node = state.doc.nodeAt(pos);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const backgroundMark = (node.attrs.marks ?? []).find(
|
||||
(mark: NodeAttrMark) => mark.type === "background"
|
||||
);
|
||||
if (backgroundMark && backgroundMark.attrs.color) {
|
||||
colors.add(backgroundMark.attrs.color);
|
||||
}
|
||||
});
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
export default function tableColMenuItems(
|
||||
state: EditorState,
|
||||
@@ -47,6 +77,14 @@ export default function tableColMenuItems(
|
||||
}
|
||||
|
||||
const tableMap = selectedRect(state);
|
||||
const colColors = getColumnColors(state, index);
|
||||
const hasBackground = colColors.size > 0;
|
||||
const activeColor =
|
||||
colColors.size === 1 ? colColors.values().next().value : null;
|
||||
const customColor =
|
||||
colColors.size === 1 && !TableCell.isPresetColor(activeColor)
|
||||
? activeColor
|
||||
: undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -89,17 +127,77 @@ export default function tableColMenuItems(
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortAsc,
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <AlphabeticalSortIcon />,
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortDesc,
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <AlphabeticalReverseSortIcon />,
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : colColors.size === 1 ? (
|
||||
<CircleIcon color={colColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => colColors.size === 1 && colColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleColumnBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderRowIcon,
|
||||
TableSplitCellsIcon,
|
||||
TableMergeCellsIcon,
|
||||
@@ -10,12 +11,40 @@ import {
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { CellSelection, selectedRect } from "prosemirror-tables";
|
||||
import {
|
||||
getCellsInRow,
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
import TableCell from "@shared/editor/nodes/TableCell";
|
||||
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
|
||||
|
||||
/**
|
||||
* Get the set of background colors used in a row
|
||||
*/
|
||||
function getRowColors(state: EditorState, rowIndex: number): Set<string> {
|
||||
const colors = new Set<string>();
|
||||
const cells = getCellsInRow(rowIndex)(state) || [];
|
||||
|
||||
cells.forEach((pos) => {
|
||||
const node = state.doc.nodeAt(pos);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const backgroundMark = (node.attrs.marks ?? []).find(
|
||||
(mark: NodeAttrMark) => mark.type === "background"
|
||||
);
|
||||
if (backgroundMark && backgroundMark.attrs.color) {
|
||||
colors.add(backgroundMark.attrs.color);
|
||||
}
|
||||
});
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
export default function tableRowMenuItems(
|
||||
state: EditorState,
|
||||
@@ -37,8 +66,74 @@ export default function tableRowMenuItems(
|
||||
}
|
||||
|
||||
const tableMap = selectedRect(state);
|
||||
const rowColors = getRowColors(state, index);
|
||||
const hasBackground = rowColors.size > 0;
|
||||
const activeColor =
|
||||
rowColors.size === 1 ? rowColors.values().next().value : null;
|
||||
const customColor =
|
||||
rowColors.size === 1
|
||||
? [...rowColors].find((c) => !TableCell.isPresetColor(c))
|
||||
: undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : rowColors.size === 1 ? (
|
||||
<CircleIcon color={rowColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleRowBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
|
||||
+6
-2
@@ -6,11 +6,15 @@ declare global {
|
||||
|
||||
if (!window.env) {
|
||||
throw new Error(
|
||||
"Config could not be be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
|
||||
"Config could not be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
|
||||
);
|
||||
}
|
||||
|
||||
const env: Record<string, any> = {
|
||||
const env: Record<string, any> & {
|
||||
isDevelopment: boolean;
|
||||
isTest: boolean;
|
||||
isProduction: boolean;
|
||||
} = {
|
||||
...window.env,
|
||||
isDevelopment: window.env.ENVIRONMENT === "development",
|
||||
isTest: window.env.ENVIRONMENT === "test",
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { createContext, useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type Model from "~/models/base/Model";
|
||||
import type Policy from "~/models/Policy";
|
||||
import type { ActionContext as ActionContextType } from "~/types";
|
||||
|
||||
export const ActionContext = createContext<ActionContextType | undefined>(
|
||||
@@ -49,8 +51,31 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
isMenu: false,
|
||||
isCommandBar: false,
|
||||
isButton: false,
|
||||
|
||||
// Legacy (backward compatibility)
|
||||
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
|
||||
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
|
||||
|
||||
// New API
|
||||
getActiveModels: <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T[] => stores.ui.getActiveModels<T>(modelClass),
|
||||
|
||||
getActiveModel: <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T | undefined => stores.ui.getActiveModels<T>(modelClass)[0],
|
||||
|
||||
getActivePolicies: <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): Policy[] =>
|
||||
stores.ui
|
||||
.getActiveModels<T>(modelClass)
|
||||
.map((node) => stores.policies.get(node.id))
|
||||
.filter((policy): policy is Policy => policy !== undefined),
|
||||
|
||||
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
|
||||
activeModels: new Set(stores.ui.activeModels.values()),
|
||||
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
location,
|
||||
@@ -59,9 +84,50 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
};
|
||||
|
||||
// Merge the parent context with the provided overrides
|
||||
const activeCollectionId =
|
||||
value.activeCollectionId ?? baseContext.activeCollectionId;
|
||||
const activeDocumentId =
|
||||
value.activeDocumentId ?? baseContext.activeDocumentId;
|
||||
|
||||
const getActiveModels = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T[] => {
|
||||
// @ts-expect-error modelName
|
||||
if (activeCollectionId && modelClass.modelName === "Collection") {
|
||||
const model = stores.collections.get(activeCollectionId);
|
||||
if (model) {
|
||||
return [model as unknown as T];
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error modelName
|
||||
if (activeDocumentId && modelClass.modelName === "Document") {
|
||||
const model = stores.documents.get(activeDocumentId);
|
||||
if (model) {
|
||||
return [model as unknown as T];
|
||||
}
|
||||
}
|
||||
|
||||
return baseContext.getActiveModels(modelClass);
|
||||
};
|
||||
|
||||
const getActiveModel = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T | undefined => getActiveModels(modelClass)[0];
|
||||
|
||||
const getActivePolicies = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): Policy[] =>
|
||||
getActiveModels(modelClass)
|
||||
.map((node) => stores.policies.get(node.id))
|
||||
.filter((policy): policy is Policy => policy !== undefined);
|
||||
|
||||
const contextValue: ActionContextType = {
|
||||
...baseContext,
|
||||
...value,
|
||||
getActiveModels,
|
||||
getActiveModel,
|
||||
getActivePolicies,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import {
|
||||
buildDarkTheme,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
buildPitchBlackTheme,
|
||||
} from "@shared/styles/theme";
|
||||
import type { CustomTheme } from "@shared/types";
|
||||
import type { Theme } from "~/stores/UiStore";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useStores from "./useStores";
|
||||
import useQuery from "./useQuery";
|
||||
@@ -28,7 +28,18 @@ export default function useBuildTheme(
|
||||
const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`);
|
||||
const isPrinting = useMediaQuery("print");
|
||||
const queryTheme = (params.get("theme") as Theme) || undefined;
|
||||
const resolvedTheme = overrideTheme ?? queryTheme ?? ui.resolvedTheme;
|
||||
|
||||
// Store the theme override in UiStore so it persists during navigation
|
||||
useEffect(() => {
|
||||
if (
|
||||
queryTheme &&
|
||||
(queryTheme === Theme.Light || queryTheme === Theme.Dark)
|
||||
) {
|
||||
ui.setThemeOverride(queryTheme);
|
||||
}
|
||||
}, [queryTheme, ui]);
|
||||
|
||||
const resolvedTheme = overrideTheme ?? ui.resolvedTheme;
|
||||
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -26,6 +26,9 @@ export default function useDictionary() {
|
||||
alignFullWidth: t("Full width"),
|
||||
bulletList: t("Bulleted list"),
|
||||
checkboxList: t("Todo list"),
|
||||
showCompleted: (count: number) =>
|
||||
t("Show {{ count }} completed", { count }),
|
||||
hideCompleted: t("Hide completed"),
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
@@ -61,12 +64,15 @@ export default function useDictionary() {
|
||||
hr: t("Divider"),
|
||||
image: t("Image"),
|
||||
fileUploadError: t("Sorry, an error occurred uploading the file"),
|
||||
uploadingWithProgress: (progress: number) =>
|
||||
t("Uploading… {{ progress }}%", { progress }),
|
||||
imageCaptionPlaceholder: t("Write a caption"),
|
||||
info: t("Info"),
|
||||
infoNotice: t("Info notice"),
|
||||
link: t("Link"),
|
||||
linkCopied: t("Link copied to clipboard"),
|
||||
mark: t("Highlight"),
|
||||
background: t("Background color"),
|
||||
newLineEmpty: `${t("Type '/' to insert")}…`,
|
||||
newLineWithSlash: `${t("Keep typing to filter")}…`,
|
||||
noResults: t("No results"),
|
||||
@@ -110,6 +116,9 @@ export default function useDictionary() {
|
||||
video: t("Video"),
|
||||
untitled: t("Untitled"),
|
||||
none: t("None"),
|
||||
toggleBlock: t("Toggle block"),
|
||||
emptyToggleBlockHead: `${t("Add title")}…`,
|
||||
emptyToggleBlockBody: `${t("Add content")}…`,
|
||||
deleteEmbed: t("Delete embed"),
|
||||
uploadImage: t("Upload an image"),
|
||||
formattingControls: t("Formatting controls"),
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
// Middle-click events in Firefox are not prevented in the same way as other browsers
|
||||
// so we need to explicitly return here to prevent two tabs from being opened when
|
||||
// middle-clicking a link (#10083).
|
||||
if (event?.button === 1 && isFirefox()) {
|
||||
if (event?.button === 1 && isFirefox) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { createAction } from "~/actions";
|
||||
import { EmojiSecion } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for emoji management operations.
|
||||
*
|
||||
* @param targetEmoji - the emoji to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if emoji is null.
|
||||
*/
|
||||
export function useEmojiMenuActions(targetEmoji: Emoji | null) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(targetEmoji ?? ({} as Emoji));
|
||||
|
||||
const openDeleteDialog = React.useCallback(() => {
|
||||
if (!targetEmoji) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Delete Emoji"),
|
||||
content: (
|
||||
<DeleteEmojiDialog emoji={targetEmoji} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetEmoji, dialogs]);
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetEmoji || !can.delete
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: EmojiSecion,
|
||||
visible: true,
|
||||
dangerous: true,
|
||||
perform: openDeleteDialog,
|
||||
}),
|
||||
],
|
||||
[t, targetEmoji, can.delete, openDeleteDialog]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
|
||||
const DeleteEmojiDialog = ({
|
||||
emoji,
|
||||
onSubmit,
|
||||
}: {
|
||||
emoji: Emoji;
|
||||
onSubmit: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (emoji) {
|
||||
await emoji.delete();
|
||||
onSubmit();
|
||||
toast.success(t("Emoji deleted"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I'm sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
|
||||
values={{
|
||||
emojiName: emoji.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Group from "~/models/Group";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for group management operations.
|
||||
*
|
||||
* @param targetGroup - the group to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if group is null.
|
||||
*/
|
||||
export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(targetGroup ?? ({} as Group));
|
||||
|
||||
const openMembersDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={targetGroup} />,
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const openEditDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const openDeleteDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetGroup
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.read),
|
||||
perform: openMembersDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.update),
|
||||
perform: openEditDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.delete),
|
||||
dangerous: true,
|
||||
perform: openDeleteDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createExternalLinkAction({
|
||||
name: targetGroup.externalId ?? "",
|
||||
section: GroupSection,
|
||||
visible: !!targetGroup.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
targetGroup,
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
openMembersDialog,
|
||||
openEditDialog,
|
||||
openDeleteDialog,
|
||||
]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import type Share from "~/models/Share";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { ActionSeparator } from "~/actions";
|
||||
import {
|
||||
copyShareUrlFactory,
|
||||
goToShareSourceFactory,
|
||||
revokeShareFactory,
|
||||
} from "~/actions/definitions/shares";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for share management operations.
|
||||
*
|
||||
* @param targetShare - the share to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if share is null.
|
||||
*/
|
||||
export function useShareMenuActions(targetShare: Share | null) {
|
||||
const can = usePolicy(targetShare ?? ({} as Share));
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetShare
|
||||
? []
|
||||
: [
|
||||
copyShareUrlFactory({ share: targetShare }),
|
||||
goToShareSourceFactory({ share: targetShare }),
|
||||
ActionSeparator,
|
||||
revokeShareFactory({ share: targetShare, can }),
|
||||
],
|
||||
[targetShare, can]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import type User from "~/models/User";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
} from "~/actions";
|
||||
import {
|
||||
deleteUserActionFactory,
|
||||
updateUserRoleActionFactory,
|
||||
} from "~/actions/definitions/users";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for user management operations.
|
||||
*
|
||||
* @param targetUser - the user to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if user is null.
|
||||
*/
|
||||
export function useUserMenuActions(targetUser: User | null) {
|
||||
const { users, dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(targetUser ?? ({} as User));
|
||||
|
||||
const openNameDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Change name"),
|
||||
content: (
|
||||
<UserChangeNameDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const openEmailDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const openSuspendDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Suspend user"),
|
||||
content: (
|
||||
<UserSuspendDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const revokeInvitation = React.useCallback(async () => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
await users.delete(targetUser);
|
||||
}, [users, targetUser]);
|
||||
|
||||
const resendInvitation = React.useCallback(async () => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await users.resendInvite(targetUser);
|
||||
toast.success(t(`Invite was resent to ${targetUser.name}`));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message ?? t(`An error occurred while sending the invite`)
|
||||
);
|
||||
}
|
||||
}, [users, targetUser, t]);
|
||||
|
||||
const activateUser = React.useCallback(async () => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
await users.activate(targetUser);
|
||||
}, [users, targetUser]);
|
||||
|
||||
const roleChangeActions = React.useMemo(
|
||||
() =>
|
||||
targetUser
|
||||
? [UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
|
||||
updateUserRoleActionFactory(targetUser, role)
|
||||
)
|
||||
: [],
|
||||
[targetUser]
|
||||
);
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetUser
|
||||
? []
|
||||
: [
|
||||
createActionWithChildren({
|
||||
name: t("Change role"),
|
||||
section: UserSection,
|
||||
visible: can.demote || can.promote,
|
||||
children: roleChangeActions,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change name")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: openNameDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change email")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: openEmailDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Resend invite"),
|
||||
section: UserSection,
|
||||
visible: can.resendInvite,
|
||||
perform: resendInvitation,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Revoke invite")}…`,
|
||||
section: UserSection,
|
||||
visible: targetUser.isInvited,
|
||||
dangerous: true,
|
||||
perform: revokeInvitation,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Activate user"),
|
||||
section: UserSection,
|
||||
visible: !targetUser.isInvited && targetUser.isSuspended,
|
||||
perform: activateUser,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Suspend user")}…`,
|
||||
section: UserSection,
|
||||
visible: !targetUser.isInvited && !targetUser.isSuspended,
|
||||
dangerous: true,
|
||||
perform: openSuspendDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
deleteUserActionFactory(targetUser.id),
|
||||
],
|
||||
[
|
||||
t,
|
||||
targetUser,
|
||||
can.demote,
|
||||
can.promote,
|
||||
can.update,
|
||||
can.resendInvite,
|
||||
roleChangeActions,
|
||||
openNameDialog,
|
||||
openEmailDialog,
|
||||
resendInvitation,
|
||||
revokeInvitation,
|
||||
activateUser,
|
||||
openSuspendDialog,
|
||||
]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "vite/modulepreload-polyfill";
|
||||
import { LazyMotion } from "framer-motion";
|
||||
import { KBarProvider } from "kbar";
|
||||
import { Provider } from "mobx-react";
|
||||
import { configure as configureMobx } from "mobx";
|
||||
import { StrictMode } from "react";
|
||||
import { render } from "react-dom";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
@@ -37,6 +38,13 @@ if (env.SENTRY_DSN) {
|
||||
initSentry(history);
|
||||
}
|
||||
|
||||
configureMobx({
|
||||
// TODO: Enable these options and fix any resulting warnings
|
||||
// enforceActions: env.isDevelopment ? "always" : "never",
|
||||
// computedRequiresReaction: true,
|
||||
isolateGlobalState: true,
|
||||
});
|
||||
|
||||
// Make sure to return the specific export containing the feature bundle.
|
||||
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
|
||||
|
||||
|
||||
+23
-70
@@ -1,75 +1,28 @@
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { IconButton } from "~/components/IconPicker/components/IconButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { useEmojiMenuActions } from "~/hooks/useEmojiMenuActions";
|
||||
|
||||
const EmojisMenu = ({ emoji }: { emoji: Emoji }) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(emoji);
|
||||
|
||||
const handleDelete = () => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete Emoji"),
|
||||
content: (
|
||||
<DeleteEmojiDialog emoji={emoji} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (!can.delete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Delete Emoji")}>
|
||||
<IconButton onClick={handleDelete}>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteEmojiDialog = ({
|
||||
emoji,
|
||||
onSubmit,
|
||||
}: {
|
||||
type Props = {
|
||||
emoji: Emoji;
|
||||
onSubmit: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (emoji) {
|
||||
await emoji.delete();
|
||||
onSubmit();
|
||||
toast.success(t("Emoji deleted"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
|
||||
values={{
|
||||
emojiName: emoji.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojisMenu;
|
||||
function EmojisMenu({ emoji }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rootAction = useEmojiMenuActions(emoji);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Emoji options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(EmojisMenu);
|
||||
|
||||
+3
-91
@@ -1,24 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Group from "~/models/Group";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -26,81 +12,7 @@ type Props = {
|
||||
|
||||
function GroupMenu({ group }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleViewMembers = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleEditGroup = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleDeleteGroup = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
createAction({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.read),
|
||||
perform: handleViewMembers,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.update),
|
||||
perform: handleEditGroup,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.delete),
|
||||
dangerous: true,
|
||||
perform: handleDeleteGroup,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createExternalLinkAction({
|
||||
name: group.externalId ?? "",
|
||||
section: GroupSection,
|
||||
visible: !!group.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
group,
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
handleViewMembers,
|
||||
handleEditGroup,
|
||||
handleDeleteGroup,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useGroupMenuActions(group);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
||||
+2
-21
@@ -4,14 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type Share from "~/models/Share";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { ActionSeparator } from "~/actions";
|
||||
import {
|
||||
copyShareUrlFactory,
|
||||
goToShareSourceFactory,
|
||||
revokeShareFactory,
|
||||
} from "~/actions/definitions/shares";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useShareMenuActions } from "~/hooks/useShareMenuActions";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
@@ -19,19 +12,7 @@ type Props = {
|
||||
|
||||
function ShareMenu({ share }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(share);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
copyShareUrlFactory({ share }),
|
||||
goToShareSourceFactory({ share }),
|
||||
ActionSeparator,
|
||||
revokeShareFactory({ share, can }),
|
||||
],
|
||||
[share, can]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useShareMenuActions(share);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TableOfContentsIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { EmojiText } from "@shared/components/EmojiText";
|
||||
import { createAction, createActionGroup } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import Button from "~/components/Button";
|
||||
@@ -26,7 +27,7 @@ function TableOfContentsMenu() {
|
||||
createAction({
|
||||
name: (
|
||||
<HeadingWrapper $level={heading.level - minHeading}>
|
||||
{t(heading.title)}
|
||||
<EmojiText>{heading.title}</EmojiText>
|
||||
</HeadingWrapper>
|
||||
),
|
||||
section: ActiveDocumentSection,
|
||||
@@ -38,7 +39,7 @@ function TableOfContentsMenu() {
|
||||
),
|
||||
})
|
||||
),
|
||||
[t, headings, minHeading]
|
||||
[headings, minHeading]
|
||||
);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
|
||||
+2
-147
@@ -1,163 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import type User from "~/models/User";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
} from "~/actions";
|
||||
import {
|
||||
deleteUserActionFactory,
|
||||
updateUserRoleActionFactory,
|
||||
} from "~/actions/definitions/users";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useUserMenuActions } from "~/hooks/useUserMenuActions";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
function UserMenu({ user }: Props) {
|
||||
const { users, dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(user);
|
||||
|
||||
const handleChangeName = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Change name"),
|
||||
content: (
|
||||
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleChangeEmail = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleSuspend = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Suspend user"),
|
||||
content: (
|
||||
<UserSuspendDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleRevoke = React.useCallback(async () => {
|
||||
await users.delete(user);
|
||||
}, [users, user]);
|
||||
|
||||
const handleResendInvite = React.useCallback(async () => {
|
||||
try {
|
||||
await users.resendInvite(user);
|
||||
toast.success(t(`Invite was resent to ${user.name}`));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message ?? t(`An error occurred while sending the invite`)
|
||||
);
|
||||
}
|
||||
}, [users, user, t]);
|
||||
|
||||
const handleActivate = React.useCallback(async () => {
|
||||
await users.activate(user);
|
||||
}, [users, user]);
|
||||
|
||||
const changeRoleActions = React.useMemo(
|
||||
() =>
|
||||
[UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
|
||||
updateUserRoleActionFactory(user, role)
|
||||
),
|
||||
[user]
|
||||
);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createActionWithChildren({
|
||||
name: t("Change role"),
|
||||
section: UserSection,
|
||||
visible: can.demote || can.promote,
|
||||
children: changeRoleActions,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change name")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: handleChangeName,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change email")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: handleChangeEmail,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Resend invite"),
|
||||
section: UserSection,
|
||||
visible: can.resendInvite,
|
||||
perform: handleResendInvite,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Revoke invite")}…`,
|
||||
section: UserSection,
|
||||
visible: user.isInvited,
|
||||
dangerous: true,
|
||||
perform: handleRevoke,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Activate user"),
|
||||
section: UserSection,
|
||||
visible: !user.isInvited && user.isSuspended,
|
||||
perform: handleActivate,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Suspend user")}…`,
|
||||
section: UserSection,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
dangerous: true,
|
||||
perform: handleSuspend,
|
||||
}),
|
||||
ActionSeparator,
|
||||
deleteUserActionFactory(user.id),
|
||||
],
|
||||
[
|
||||
t,
|
||||
can.demote,
|
||||
can.promote,
|
||||
can.update,
|
||||
can.resendInvite,
|
||||
user.id,
|
||||
user.isInvited,
|
||||
user.isSuspended,
|
||||
changeRoleActions,
|
||||
handleChangeName,
|
||||
handleChangeEmail,
|
||||
handleResendInvite,
|
||||
handleRevoke,
|
||||
handleActivate,
|
||||
handleSuspend,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useUserMenuActions(user);
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} align="end" ariaLabel={t("User options")}>
|
||||
|
||||
+24
-3
@@ -229,6 +229,13 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
@observable
|
||||
isCollectionDeleted: boolean;
|
||||
|
||||
/**
|
||||
* Array of backlink document IDs for publicly shared documents.
|
||||
* Only populated when viewing through a share link.
|
||||
*/
|
||||
@observable
|
||||
backlinkIds?: string[];
|
||||
|
||||
/**
|
||||
* Returns the notifications associated with this document.
|
||||
*/
|
||||
@@ -347,11 +354,18 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
|
||||
/**
|
||||
* Returns the documents that link to this document.
|
||||
* For publicly shared documents, uses the backlinkIds provided by the server.
|
||||
* For authenticated users, uses the store's backlink data.
|
||||
*
|
||||
* @returns documents that link to this document
|
||||
* @returns documents that link to this document.
|
||||
*/
|
||||
@computed
|
||||
get backlinks(): Document[] {
|
||||
if (this.backlinkIds) {
|
||||
return this.backlinkIds
|
||||
.map((id) => this.store.get(id))
|
||||
.filter(Boolean) as Document[];
|
||||
}
|
||||
return this.store.getBacklinkedDocuments(this.id);
|
||||
}
|
||||
|
||||
@@ -688,14 +702,21 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
);
|
||||
}
|
||||
|
||||
download = (contentType: ExportContentType) =>
|
||||
download = ({
|
||||
contentType,
|
||||
includeChildDocuments,
|
||||
}: {
|
||||
contentType: ExportContentType;
|
||||
includeChildDocuments?: boolean;
|
||||
}) =>
|
||||
client.post(
|
||||
`/documents.export`,
|
||||
{
|
||||
id: this.id,
|
||||
includeChildDocuments: includeChildDocuments ?? false,
|
||||
},
|
||||
{
|
||||
download: true,
|
||||
...(includeChildDocuments ? {} : { download: true }),
|
||||
headers: {
|
||||
accept: contentType,
|
||||
},
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import type {
|
||||
FileOperationFormat,
|
||||
import { t } from "i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
type FileOperationFormat,
|
||||
} from "@shared/types";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import type FileOperationsStore from "~/stores/FileOperationsStore";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import { AfterChange } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class FileOperation extends Model {
|
||||
static modelName = "FileOperation";
|
||||
|
||||
store: FileOperationsStore;
|
||||
|
||||
@observable
|
||||
state: FileOperationState;
|
||||
|
||||
@@ -21,6 +27,8 @@ class FileOperation extends Model {
|
||||
|
||||
collectionId: string | null;
|
||||
|
||||
documentId: string | null;
|
||||
|
||||
@observable
|
||||
size: number;
|
||||
|
||||
@@ -40,6 +48,55 @@ class FileOperation extends Model {
|
||||
get downloadUrl(): string {
|
||||
return `/api/fileOperations.redirect?id=${this.id}`;
|
||||
}
|
||||
|
||||
// Hooks
|
||||
|
||||
@AfterChange
|
||||
static handleExportToast(
|
||||
model: FileOperation,
|
||||
previousAttributes: Partial<FileOperation>
|
||||
) {
|
||||
if (model.type !== FileOperationType.Export) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ui, auth } = model.store.rootStore;
|
||||
if (model.user?.id !== auth.user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tracked = ui.exportToasts.get(model.id);
|
||||
if (!tracked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
previousAttributes.state !== model.state &&
|
||||
model.state === FileOperationState.Complete
|
||||
) {
|
||||
toast.success(t("Export complete"), {
|
||||
id: tracked.toastId,
|
||||
description: model.name,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: t("Download"),
|
||||
onClick: () => window.open(model.downloadUrl, "_blank"),
|
||||
},
|
||||
});
|
||||
ui.removeExportToast(model.id);
|
||||
}
|
||||
|
||||
if (
|
||||
previousAttributes.state !== model.state &&
|
||||
model.state === FileOperationState.Error
|
||||
) {
|
||||
toast.error(t("Export failed"), {
|
||||
id: tracked.toastId,
|
||||
description: model.error || t("An unexpected error occurred"),
|
||||
});
|
||||
ui.removeExportToast(model.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FileOperation;
|
||||
|
||||
+13
-5
@@ -231,10 +231,14 @@ class User extends ParanoidModel implements Searchable {
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @returns The value
|
||||
*/
|
||||
getPreference(key: UserPreference, defaultValue = false): boolean {
|
||||
return (
|
||||
this.preferences?.[key] ?? UserPreferenceDefaults[key] ?? defaultValue
|
||||
);
|
||||
getPreference<K extends UserPreference>(
|
||||
key: K,
|
||||
defaultValue?: UserPreferences[K]
|
||||
): NonNullable<UserPreferences[K]> {
|
||||
return (this.preferences?.[key] ??
|
||||
UserPreferenceDefaults[key] ??
|
||||
defaultValue ??
|
||||
false) as NonNullable<UserPreferences[K]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +247,11 @@ class User extends ParanoidModel implements Searchable {
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference(key: UserPreference, value: boolean) {
|
||||
@action
|
||||
setPreference<K extends UserPreference>(
|
||||
key: K,
|
||||
value: NonNullable<UserPreferences[K]>
|
||||
) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
|
||||
@@ -372,9 +372,8 @@ const Action = styled.span<{ $rounded?: boolean }>`
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:
|
||||
${hover},
|
||||
&[aria-expanded= "true"] {
|
||||
&[aria-expanded="true"],
|
||||
&:${hover} {
|
||||
background: ${s("backgroundQuaternary")};
|
||||
|
||||
svg {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { EmojiText } from "@shared/components/EmojiText";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, hideScrollbars, s } from "@shared/styles";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
@@ -80,7 +81,9 @@ function Contents() {
|
||||
level={heading.level - headingAdjustment}
|
||||
active={activeSlug === heading.id}
|
||||
>
|
||||
<Link href={`#${heading.id}`}>{heading.title}</Link>
|
||||
<Link href={`#${heading.id}`}>
|
||||
<EmojiText>{heading.title}</EmojiText>
|
||||
</Link>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -104,18 +104,23 @@ function DataLoader({ match, children }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchRevision() {
|
||||
if (revisionId) {
|
||||
try {
|
||||
await revisions[revisionId === "latest" ? "fetchLatest" : "fetch"](
|
||||
revisionId
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
if (!revisionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (revisionId === "latest") {
|
||||
if (document?.id) {
|
||||
await revisions.fetchLatest(document.id);
|
||||
}
|
||||
} else {
|
||||
await revisions.fetch(revisionId);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
void fetchRevision();
|
||||
}, [revisions, revisionId]);
|
||||
}, [revisions, revisionId, document?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchViews() {
|
||||
@@ -162,7 +167,7 @@ function DataLoader({ match, children }: Props) {
|
||||
|
||||
// If we're attempting to update an archived, deleted, or otherwise
|
||||
// uneditable document then forward to the canonical read url.
|
||||
if (!can.update && isEditRoute && !document.template) {
|
||||
if (!missingPolicy && !can.update && isEditRoute && !document.template) {
|
||||
history.push(document.url);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ import Contents from "./Contents";
|
||||
import Editor from "./Editor";
|
||||
import Header from "./Header";
|
||||
import Notices from "./Notices";
|
||||
import PublicReferences from "./PublicReferences";
|
||||
import References from "./References";
|
||||
import RevisionViewer from "./RevisionViewer";
|
||||
|
||||
@@ -200,7 +199,18 @@ class DocumentScene extends React.Component<Props> {
|
||||
const revisionId = location.state?.revisionId;
|
||||
const editorRef = this.editor.current;
|
||||
|
||||
if (!editorRef || !restore) {
|
||||
if (!editorRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Highlight search term when navigating from search results
|
||||
const params = new URLSearchParams(location.search);
|
||||
const searchTerm = params.get("q");
|
||||
if (searchTerm) {
|
||||
editorRef.commands.find({ text: searchTerm });
|
||||
}
|
||||
|
||||
if (!restore) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -606,15 +616,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
canComment={abilities.comment}
|
||||
autoFocus={document.createdAt === document.updatedAt}
|
||||
>
|
||||
{shareId ? (
|
||||
<ReferencesWrapper>
|
||||
<PublicReferences documentId={document.id} />
|
||||
</ReferencesWrapper>
|
||||
) : !revision ? (
|
||||
{!revision && (
|
||||
<ReferencesWrapper>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
) : null}
|
||||
)}
|
||||
</Editor>
|
||||
</>
|
||||
)}
|
||||
@@ -663,6 +669,13 @@ const Main = styled.div<MainProps>`
|
||||
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
|
||||
: `1fr minmax(0, ${`calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`}) 1fr`};
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
max-width: calc(
|
||||
${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter}
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
type ContentsContainerProps = {
|
||||
|
||||
@@ -25,9 +25,7 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
|
||||
import IconPicker from "~/components/IconPicker";
|
||||
|
||||
type Props = {
|
||||
/** ID of the associated document */
|
||||
|
||||
@@ -248,6 +248,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
onDeleteCommentMark={
|
||||
commentingEnabled && can.comment ? handleRemoveComment : undefined
|
||||
}
|
||||
onOpenCommentsSidebar={
|
||||
commentingEnabled ? ui.toggleComments : undefined
|
||||
}
|
||||
onInit={handleInit}
|
||||
onDestroy={handleDestroy}
|
||||
onChange={updateDocState}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user