mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db364ae2a8 | |||
| b5e9c9019c | |||
| 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 |
+6
-2
@@ -203,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=
|
||||
@@ -212,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=
|
||||
|
||||
@@ -223,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
|
||||
|
||||
|
||||
@@ -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,6 +38,7 @@ 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";
|
||||
@@ -549,7 +550,7 @@ export const downloadDocument = createAction({
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Downloas as Markdown"),
|
||||
name: ({ t }) => t("Download as Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
@@ -630,7 +631,7 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
if (document) {
|
||||
const res = await client.post("/documents.export", {
|
||||
id: document.id,
|
||||
signedUrls: 3600 * 24 * 30, // 30 days
|
||||
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
|
||||
});
|
||||
copy(res.data);
|
||||
toast.success(t("Markdown copied to clipboard"));
|
||||
|
||||
@@ -121,6 +121,9 @@ function DocumentListItem(
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
search: highlight
|
||||
? `?q=${encodeURIComponent(highlight)}`
|
||||
: undefined,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -157,6 +157,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
ellipsis={!isEditing}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
@@ -197,6 +198,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
<SidebarLink
|
||||
depth={2}
|
||||
isActive={() => true}
|
||||
ellipsis={false}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
|
||||
@@ -106,8 +106,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,
|
||||
@@ -428,6 +427,7 @@ function InnerDocumentLink(
|
||||
to={toPath}
|
||||
icon={iconElement}
|
||||
label={labelElement}
|
||||
ellipsis={!isEditing}
|
||||
isActive={isActiveCheck}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
@@ -449,6 +449,7 @@ function InnerDocumentLink(
|
||||
<SidebarLink
|
||||
isActive={() => true}
|
||||
depth={depth + 1}
|
||||
ellipsis={false}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
|
||||
@@ -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()}
|
||||
|
||||
* {
|
||||
|
||||
@@ -63,7 +63,7 @@ type StarredCollectionLinkProps = {
|
||||
reorderStarProps: any;
|
||||
};
|
||||
|
||||
function StarredDocumentLink({
|
||||
const StarredDocumentLink = observer(function StarredDocumentLink({
|
||||
star,
|
||||
documentId,
|
||||
expanded,
|
||||
@@ -156,9 +156,9 @@ function StarredDocumentLink({
|
||||
</SidebarContext.Provider>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function StarredCollectionLink({
|
||||
const StarredCollectionLink = observer(function StarredCollectionLink({
|
||||
star,
|
||||
collection,
|
||||
sidebarContext,
|
||||
@@ -185,7 +185,7 @@ function StarredCollectionLink({
|
||||
<Relative>{cursor}</Relative>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function StarredLink({ star }: Props) {
|
||||
const theme = useTheme();
|
||||
@@ -240,10 +240,16 @@ function StarredLink({ star }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@@ -2,13 +2,17 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "./primitives/Drawer";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "./primitives/Popover";
|
||||
import Text from "./Text";
|
||||
import { ColorButton } from "./ColorButton";
|
||||
import ColorPicker from "@shared/components/ColorPicker";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
|
||||
/**
|
||||
* Props for the SwatchButton component.
|
||||
@@ -50,19 +54,11 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
|
||||
);
|
||||
|
||||
const pickerContent = (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<StyledColorPicker
|
||||
disableAlpha
|
||||
color={color}
|
||||
onChange={(c) => onChange(c.hex)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<StyledColorPicker
|
||||
alpha={false}
|
||||
activeColor={color}
|
||||
onSelect={(c) => onChange(c)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
@@ -70,7 +66,8 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
|
||||
<DrawerContent aria-label={t("Select a color")}>
|
||||
{pickerContent}
|
||||
<DrawerHandle />
|
||||
<EventBoundary>{pickerContent}</EventBoundary>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
@@ -96,10 +93,6 @@ const StyledContent = styled(PopoverContent)`
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const ColorPicker = lazyWithRetry(() =>
|
||||
import("react-color").then((mod) => ({ default: mod.ChromePicker }))
|
||||
);
|
||||
|
||||
const StyledColorPicker = styled(ColorPicker)`
|
||||
background: inherit !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
@@ -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;
|
||||
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query });
|
||||
res.data.documents.map(documents.add);
|
||||
}, [query])
|
||||
}, [documents, query])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -201,7 +201,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<InputWrapper ref={wrapperRef}>
|
||||
<InputWrapper>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
@@ -235,8 +235,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 +274,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 { 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";
|
||||
@@ -80,7 +81,7 @@ 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();
|
||||
@@ -108,7 +109,11 @@ export function SelectionToolbar(props: Props) {
|
||||
|
||||
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);
|
||||
@@ -121,6 +126,19 @@ export function SelectionToolbar(props: Props) {
|
||||
}
|
||||
}, [readOnly, selection]);
|
||||
|
||||
// Refocus the editor when the link toolbar closes to prevent focus loss
|
||||
const prevActiveToolbar = React.useRef(activeToolbar);
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
prevActiveToolbar.current === Toolbar.Link &&
|
||||
activeToolbar !== Toolbar.Link &&
|
||||
!readOnly
|
||||
) {
|
||||
view.focus();
|
||||
}
|
||||
prevActiveToolbar.current = activeToolbar;
|
||||
}, [activeToolbar, readOnly, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (ev: MouseEvent): void => {
|
||||
if (
|
||||
@@ -138,13 +156,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))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -163,6 +191,7 @@ export function SelectionToolbar(props: Props) {
|
||||
ev.key.toLowerCase() === "k" &&
|
||||
!view.state.selection.empty
|
||||
) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (activeToolbar === Toolbar.Link) {
|
||||
setActiveToolbar(Toolbar.Menu);
|
||||
@@ -247,7 +276,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 = () => {
|
||||
|
||||
@@ -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";
|
||||
@@ -73,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;
|
||||
@@ -85,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,
|
||||
@@ -92,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
|
||||
@@ -101,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(
|
||||
@@ -182,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);
|
||||
|
||||
@@ -198,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]);
|
||||
@@ -227,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;
|
||||
@@ -249,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) {
|
||||
@@ -374,8 +416,7 @@ 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,
|
||||
@@ -541,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();
|
||||
}
|
||||
@@ -562,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();
|
||||
}
|
||||
@@ -597,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>
|
||||
@@ -621,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>
|
||||
@@ -754,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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -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,
|
||||
@@ -119,6 +119,8 @@ 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 */
|
||||
@@ -170,6 +172,7 @@ export class Editor extends React.PureComponent<
|
||||
defaultValue: "",
|
||||
dir: "auto",
|
||||
placeholder: "Write something nice…",
|
||||
readOnly: false,
|
||||
onFileUploadStart: () => {
|
||||
// no default behavior
|
||||
},
|
||||
@@ -528,6 +531,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();
|
||||
}
|
||||
@@ -873,10 +883,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}
|
||||
/>
|
||||
)}
|
||||
</EditorContext.Provider>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
MathIcon,
|
||||
DoneIcon,
|
||||
EmbedIcon,
|
||||
CollapseIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -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
|
||||
|
||||
+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: [
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: stores.ui.activeModels,
|
||||
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
location,
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function useDictionary() {
|
||||
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"),
|
||||
@@ -112,6 +113,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"),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -199,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;
|
||||
}
|
||||
|
||||
@@ -658,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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { TableOfContentsIcon, EditIcon } from "outline-icons";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
@@ -82,6 +82,15 @@ function DocumentHeader({
|
||||
const isMobileMedia = useMobile();
|
||||
const isRevision = !!revision;
|
||||
const isEditingFocus = useEditingFocus();
|
||||
|
||||
// Set CSS variable for header offset (used by sticky table headers)
|
||||
useEffect(() => {
|
||||
window.document.documentElement.style.setProperty(
|
||||
"--header-offset",
|
||||
isEditingFocus ? "0px" : "64px"
|
||||
);
|
||||
}, [isEditingFocus]);
|
||||
|
||||
const { hasHeadings, editor } = useDocumentContext();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [measureRef, size] = useMeasure();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { KeyboardIcon } from "outline-icons";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -7,24 +8,33 @@ import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function KeyboardShortcutsButton() {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const isEditingFocus = useEditingFocus();
|
||||
const query = useQuery();
|
||||
const shortcutsQuery = query.get("shortcuts");
|
||||
|
||||
const handleOpenKeyboardShortcuts = () => {
|
||||
const handleOpenKeyboardShortcuts = (defaultQuery?: string) => {
|
||||
dialogs.openGuide({
|
||||
title: t("Keyboard shortcuts"),
|
||||
content: <KeyboardShortcuts />,
|
||||
content: <KeyboardShortcuts defaultQuery={defaultQuery} />,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutsQuery !== null) {
|
||||
handleOpenKeyboardShortcuts(shortcutsQuery);
|
||||
}
|
||||
}, [shortcutsQuery]);
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
|
||||
<Button
|
||||
onClick={handleOpenKeyboardShortcuts}
|
||||
onClick={() => handleOpenKeyboardShortcuts()}
|
||||
$hidden={isEditingFocus}
|
||||
aria-label={t("Keyboard shortcuts")}
|
||||
>
|
||||
|
||||
@@ -79,7 +79,8 @@ function DocumentNew({ template }: Props) {
|
||||
}
|
||||
|
||||
void createDocument();
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex column auto>
|
||||
|
||||
@@ -8,7 +8,12 @@ import Flex from "~/components/Flex";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Key from "~/components/Key";
|
||||
|
||||
function KeyboardShortcuts() {
|
||||
type Props = {
|
||||
/** Initial search query to filter shortcuts */
|
||||
defaultQuery?: string;
|
||||
};
|
||||
|
||||
function KeyboardShortcuts({ defaultQuery = "" }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
@@ -346,6 +351,31 @@ function KeyboardShortcuts() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Toggle blocks"),
|
||||
items: [
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key symbol>{metaDisplay}</Key> + <Key>Enter</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Open / close"),
|
||||
},
|
||||
{
|
||||
shortcut: <Key>{t("Tab")}</Key>,
|
||||
label: t("Indent item"),
|
||||
},
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key symbol>⇧</Key> + <Key>{t("Tab")}</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Outdent item"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Tables"),
|
||||
items: [
|
||||
@@ -450,6 +480,14 @@ function KeyboardShortcuts() {
|
||||
),
|
||||
label: t("LaTeX block"),
|
||||
},
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key>+++</Key> <Key>{t("Space")}</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Toggle block"),
|
||||
},
|
||||
{
|
||||
shortcut: <Key>{":::"}</Key>,
|
||||
label: t("Info notice"),
|
||||
@@ -500,7 +538,7 @@ function KeyboardShortcuts() {
|
||||
],
|
||||
[t]
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState(defaultQuery);
|
||||
const normalizedSearchTerm = searchTerm.toLocaleLowerCase();
|
||||
const handleChange = useCallback((event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
@@ -524,10 +562,15 @@ function KeyboardShortcuts() {
|
||||
/>
|
||||
</StickySearch>
|
||||
{categories.map((category, x) => {
|
||||
const titleMatches = category.title
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearchTerm);
|
||||
const filtered = searchTerm
|
||||
? category.items.filter((item) =>
|
||||
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
|
||||
)
|
||||
? titleMatches
|
||||
? category.items
|
||||
: category.items.filter((item) =>
|
||||
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
|
||||
)
|
||||
: category.items;
|
||||
|
||||
if (!filtered.length) {
|
||||
|
||||
@@ -8,7 +8,11 @@ import { Waypoint } from "react-waypoint";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import type { DateFilter as TDateFilter } from "@shared/types";
|
||||
import type {
|
||||
SortFilter as TSortFilter,
|
||||
DirectionFilter as TDirectionFilter,
|
||||
DateFilter as TDateFilter,
|
||||
} from "@shared/types";
|
||||
import { StatusFilter as TStatusFilter } from "@shared/types";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DocumentListItem from "~/components/DocumentListItem";
|
||||
@@ -32,6 +36,7 @@ import { DocumentFilter } from "./components/DocumentFilter";
|
||||
import DocumentTypeFilter from "./components/DocumentTypeFilter";
|
||||
import RecentSearches from "./components/RecentSearches";
|
||||
import SearchInput from "./components/SearchInput";
|
||||
import { SortInput } from "./components/SortInput";
|
||||
import UserFilter from "./components/UserFilter";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
@@ -63,6 +68,8 @@ function Search() {
|
||||
? (params.getAll("statusFilter") as TStatusFilter[])
|
||||
: [TStatusFilter.Published, TStatusFilter.Draft];
|
||||
const titleFilter = params.get("titleFilter") === "true";
|
||||
const sort = (params.get("sort") as TSortFilter) ?? "";
|
||||
const direction = (params.get("direction") as TDirectionFilter) ?? "";
|
||||
|
||||
const isSearchable = !!(query || collectionId || userId);
|
||||
|
||||
@@ -75,6 +82,7 @@ function Search() {
|
||||
documentType: isSearchable,
|
||||
date: isSearchable,
|
||||
title: !!query && !document,
|
||||
sort: isSearchable,
|
||||
};
|
||||
|
||||
const filters = React.useMemo(
|
||||
@@ -86,6 +94,8 @@ function Search() {
|
||||
dateFilter,
|
||||
titleFilter,
|
||||
documentId,
|
||||
sort,
|
||||
direction,
|
||||
}),
|
||||
[
|
||||
query,
|
||||
@@ -95,6 +105,8 @@ function Search() {
|
||||
dateFilter,
|
||||
titleFilter,
|
||||
documentId,
|
||||
sort,
|
||||
direction,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -147,7 +159,14 @@ function Search() {
|
||||
dateFilter?: TDateFilter;
|
||||
statusFilter?: TStatusFilter[];
|
||||
titleFilter?: boolean | undefined;
|
||||
sort?: string | undefined;
|
||||
direction?: string | undefined;
|
||||
}) => {
|
||||
if (search.sort === "relevance") {
|
||||
search.sort = undefined;
|
||||
search.direction = undefined;
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: queryString.stringify(
|
||||
@@ -231,53 +250,64 @@ function Search() {
|
||||
/>
|
||||
|
||||
<Filters>
|
||||
{filterVisibility.document && (
|
||||
<DocumentFilter
|
||||
document={document!}
|
||||
onClick={() => {
|
||||
handleFilterChange({ documentId: undefined });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.collection && (
|
||||
<CollectionFilter
|
||||
collectionId={collectionId}
|
||||
onSelect={(collectionId) =>
|
||||
handleFilterChange({ collectionId })
|
||||
<Flex align="center" gap={4}>
|
||||
{filterVisibility.document && (
|
||||
<DocumentFilter
|
||||
document={document!}
|
||||
onClick={() => {
|
||||
handleFilterChange({ documentId: undefined });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.collection && (
|
||||
<CollectionFilter
|
||||
collectionId={collectionId}
|
||||
onSelect={(collectionId) =>
|
||||
handleFilterChange({ collectionId })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.user && (
|
||||
<UserFilter
|
||||
userId={userId}
|
||||
onSelect={(userId) => handleFilterChange({ userId })}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.documentType && (
|
||||
<DocumentTypeFilter
|
||||
statusFilter={statusFilter}
|
||||
onSelect={({ statusFilter }) =>
|
||||
handleFilterChange({ statusFilter })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.date && (
|
||||
<DateFilter
|
||||
dateFilter={dateFilter}
|
||||
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.title && (
|
||||
<SearchTitlesFilter
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Search titles only")}
|
||||
onChange={(checked: boolean) => {
|
||||
handleFilterChange({ titleFilter: checked });
|
||||
}}
|
||||
checked={titleFilter}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{filterVisibility.sort && (
|
||||
<SortInput
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
onSelect={(sort, direction) =>
|
||||
handleFilterChange({ sort, direction })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.user && (
|
||||
<UserFilter
|
||||
userId={userId}
|
||||
onSelect={(userId) => handleFilterChange({ userId })}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.documentType && (
|
||||
<DocumentTypeFilter
|
||||
statusFilter={statusFilter}
|
||||
onSelect={({ statusFilter }) =>
|
||||
handleFilterChange({ statusFilter })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.date && (
|
||||
<DateFilter
|
||||
dateFilter={dateFilter}
|
||||
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
|
||||
/>
|
||||
)}
|
||||
{filterVisibility.title && (
|
||||
<SearchTitlesFilter
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Search titles only")}
|
||||
onChange={(checked: boolean) => {
|
||||
handleFilterChange({ titleFilter: checked });
|
||||
}}
|
||||
checked={titleFilter}
|
||||
/>
|
||||
)}
|
||||
</Filters>
|
||||
</form>
|
||||
{isSearchable ? (
|
||||
@@ -365,6 +395,7 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
|
||||
|
||||
const Filters = styled(HStack)`
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
padding: 8px 0;
|
||||
@@ -377,7 +408,7 @@ const Filters = styled(HStack)`
|
||||
const SearchTitlesFilter = styled(Switch)`
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
margin-top: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { DirectionFilter, SortFilter as TSortFilter } from "@shared/types";
|
||||
import { SortAscendingIcon, SortDescendingIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "~/components/FilterOptions";
|
||||
|
||||
type Props = {
|
||||
/** The selected sort field */
|
||||
sort?: TSortFilter | null;
|
||||
/** The selected sort direction */
|
||||
direction?: DirectionFilter | null;
|
||||
/** Callback when a sort option is selected */
|
||||
onSelect: (sort: string, direction: string) => void;
|
||||
};
|
||||
|
||||
export const SortInput = ({ sort, direction, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "relevance-DESC",
|
||||
label: t("Relevance"),
|
||||
icon: <SortDescendingIcon size={20} />,
|
||||
},
|
||||
{
|
||||
key: "updatedAt-DESC",
|
||||
label: t("Recently updated"),
|
||||
icon: <SortDescendingIcon size={20} />,
|
||||
},
|
||||
{
|
||||
key: "updatedAt-ASC",
|
||||
label: t("Least recently updated"),
|
||||
icon: <SortAscendingIcon size={20} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt-DESC",
|
||||
label: t("Newest"),
|
||||
icon: <SortDescendingIcon size={20} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt-ASC",
|
||||
label: t("Oldest"),
|
||||
icon: <SortAscendingIcon size={20} />,
|
||||
},
|
||||
{
|
||||
key: "title-ASC",
|
||||
label: t("A → Z"),
|
||||
icon: <SortAscendingIcon size={20} />,
|
||||
},
|
||||
{
|
||||
key: "title-DESC",
|
||||
label: t("Z → A"),
|
||||
icon: <SortDescendingIcon size={20} />,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const selectedKey =
|
||||
sort && direction ? `${sort}-${direction}` : "relevance-DESC";
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
const [sortField, sortDirection] = key.split("-");
|
||||
onSelect(sortField, sortDirection);
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
showFilter={false}
|
||||
showIcons={false}
|
||||
disclosure={false}
|
||||
options={options}
|
||||
selectedKeys={[selectedKey]}
|
||||
onSelect={handleSelect}
|
||||
defaultLabel={t("Relevance")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ function DomainManagement({ onSuccess }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [allowedDomains, setAllowedDomains] = React.useState([
|
||||
const [allowedDomains, setAllowedDomains] = React.useState(() => [
|
||||
...(team.allowedDomains ?? []),
|
||||
]);
|
||||
const [lastKnownDomainCount, updateLastKnownDomainCount] = React.useState(
|
||||
|
||||
@@ -5,11 +5,12 @@ import type DocumentModel from "~/models/Document";
|
||||
import DocumentComponent from "~/scenes/Document/components/Document";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { useTeamContext } from "~/components/TeamContext";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import Branding from "~/components/Branding";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
|
||||
type Props = {
|
||||
document: DocumentModel;
|
||||
@@ -17,21 +18,38 @@ type Props = {
|
||||
|
||||
function SharedDocument({ document }: Props) {
|
||||
const { shareId } = useShare();
|
||||
const query = useQuery();
|
||||
const searchTerm = query.get("q") || undefined;
|
||||
const team = useTeamContext() as PublicTeam | undefined;
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { hasHeadings, setDocument } = useDocumentContext();
|
||||
const { hasHeadings, setDocument, isEditorInitialized, editor } =
|
||||
useDocumentContext();
|
||||
const abilities = useMemo(() => ({}), []);
|
||||
const isCustomDomain = useMemo(
|
||||
() => parseDomain(window.location.origin).custom,
|
||||
[]
|
||||
);
|
||||
const showBranding = !isCustomDomain && !user;
|
||||
const searchTermProcessed = useRef<string | null>(null);
|
||||
|
||||
const tocPosition = hasHeadings
|
||||
? (team?.tocPosition ?? TOCPosition.Left)
|
||||
: false;
|
||||
setDocument(document);
|
||||
|
||||
// Highlight search term when navigating from search results
|
||||
useEffect(() => {
|
||||
if (
|
||||
isEditorInitialized &&
|
||||
editor &&
|
||||
searchTerm &&
|
||||
searchTermProcessed.current !== searchTerm
|
||||
) {
|
||||
searchTermProcessed.current = searchTerm;
|
||||
editor.commands.find({ text: searchTerm });
|
||||
}
|
||||
}, [isEditorInitialized, editor, searchTerm]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentComponent
|
||||
|
||||
+12
-5
@@ -116,6 +116,7 @@ export default class AuthStore extends Store<Team> {
|
||||
if (isNil(newData.user)) {
|
||||
void this.logout({
|
||||
savePath: false,
|
||||
clearCache: false,
|
||||
revokeToken: false,
|
||||
userInitiated: true,
|
||||
});
|
||||
@@ -306,18 +307,22 @@ export default class AuthStore extends Store<Team> {
|
||||
/**
|
||||
* Logs the user out and optionally revokes the authentication token.
|
||||
*
|
||||
* @param savePath Whether the current path should be saved and returned to after login.
|
||||
* @param clearCache Whether to clear the IndexedDB databases used for document caching.
|
||||
* @param revokeToken Whether the auth token should attempt to be revoked, this should be
|
||||
* @param savePath Whether the current path should be saved and returned to after login.
|
||||
* @param userInitiated Whether the logout was initiated by the user.
|
||||
* disabled with requests from ApiClient to prevent infinite loops.
|
||||
*/
|
||||
@action
|
||||
logout = async ({
|
||||
savePath = false,
|
||||
clearCache = true,
|
||||
revokeToken = true,
|
||||
savePath = false,
|
||||
userInitiated = false,
|
||||
}: {
|
||||
savePath?: boolean;
|
||||
clearCache?: boolean;
|
||||
revokeToken?: boolean;
|
||||
savePath?: boolean;
|
||||
userInitiated?: boolean;
|
||||
}) => {
|
||||
// if this logout was forced from an authenticated route then
|
||||
@@ -350,8 +355,10 @@ export default class AuthStore extends Store<Team> {
|
||||
this.logoutRedirectUri = env.OIDC_LOGOUT_URI;
|
||||
}
|
||||
|
||||
// clear IndexedDB databases used for document caching
|
||||
await deleteAllDatabases();
|
||||
if (clearCache) {
|
||||
// clear IndexedDB databases used for document caching
|
||||
await deleteAllDatabases();
|
||||
}
|
||||
|
||||
// clear all credentials from cache (and local storage via autorun)
|
||||
this.currentUserId = null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import filter from "lodash/filter";
|
||||
import omitBy from "lodash/omitBy";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observable, action, computed, runInAction } from "mobx";
|
||||
import type { DirectionFilter, SortFilter } from "@shared/types";
|
||||
import {
|
||||
SubscriptionType,
|
||||
type DateFilter,
|
||||
@@ -39,6 +40,8 @@ export type SearchParams = {
|
||||
collectionId?: string;
|
||||
userId?: string;
|
||||
shareId?: string;
|
||||
sort?: SortFilter;
|
||||
direction?: DirectionFilter;
|
||||
};
|
||||
|
||||
type ImportOptions = {
|
||||
@@ -650,6 +653,14 @@ export default class DocumentsStore extends Store<Document> {
|
||||
}
|
||||
) {
|
||||
await super.delete(document, options);
|
||||
|
||||
// For permanent deletion, we need to actually remove the document from the
|
||||
// local store data Map, as the base Store's remove() method only soft-deletes
|
||||
// ParanoidModel instances by setting deletedAt.
|
||||
if (options?.permanent) {
|
||||
this.data.delete(document.id);
|
||||
}
|
||||
|
||||
// check to see if we have any shares related to this document already
|
||||
// loaded in local state. If so we can go ahead and remove those too.
|
||||
const share = this.rootStore.shares.getByDocumentId(document.id);
|
||||
@@ -737,7 +748,11 @@ export default class DocumentsStore extends Store<Document> {
|
||||
await client.post("/documents.empty_trash");
|
||||
|
||||
const documentIdsSet = new Set(this.deleted.map((doc) => doc.id));
|
||||
// Call removeAll to handle inverse relations, policies, and lifecycle hooks
|
||||
this.removeAll((doc: Document) => documentIdsSet.has(doc.id));
|
||||
// For permanent deletion (empty trash), we need to hard delete from the store
|
||||
// after the cleanup is done, as removeAll only soft-deletes ParanoidModel instances
|
||||
documentIdsSet.forEach((id) => this.data.delete(id));
|
||||
};
|
||||
|
||||
star = (document: Document, index?: string) =>
|
||||
|
||||
+112
-13
@@ -2,7 +2,9 @@ import { action, computed, observable } from "mobx";
|
||||
import { flushSync } from "react-dom";
|
||||
import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import type Document from "~/models/Document";
|
||||
import type Model from "~/models/base/Model";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
|
||||
import { startViewTransition } from "~/utils/viewTransition";
|
||||
import type RootStore from "./RootStore";
|
||||
@@ -52,10 +54,7 @@ class UiStore {
|
||||
systemTheme: SystemTheme;
|
||||
|
||||
@observable
|
||||
activeDocumentId: string | undefined;
|
||||
|
||||
@observable
|
||||
activeCollectionId?: string | null;
|
||||
activeModels = new Set<Model>();
|
||||
|
||||
@observable
|
||||
observingUserId: string | undefined;
|
||||
@@ -150,6 +149,86 @@ class UiStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a model instance to the active set.
|
||||
*
|
||||
* @param model the model instance to add.
|
||||
*/
|
||||
@action
|
||||
addActiveModel = (model: Model): void => {
|
||||
this.activeModels.add(model);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a model instance from the active set.
|
||||
*
|
||||
* @param model the model instance to remove.
|
||||
*/
|
||||
@action
|
||||
removeActiveModel = (model: Model): void => {
|
||||
this.activeModels.delete(model);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all active models of a specific type.
|
||||
*
|
||||
* @param modelClass the model class to filter by.
|
||||
* @returns array of active models of the specified type.
|
||||
*/
|
||||
getActiveModels<T extends Model>(modelClass: new (...args: any[]) => T): T[] {
|
||||
return Array.from(this.activeModels).filter(
|
||||
(model) => model.constructor === modelClass
|
||||
) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model instance is in the active set.
|
||||
*
|
||||
* @param model the model instance to check.
|
||||
* @returns true if the model is active.
|
||||
*/
|
||||
isModelActive(model: Model): boolean {
|
||||
return this.activeModels.has(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all active models, or only models of a specific type.
|
||||
*
|
||||
* @param modelClass optional model class to filter by.
|
||||
*/
|
||||
@action
|
||||
clearActiveModels(modelClass?: new (...args: any[]) => Model): void {
|
||||
if (modelClass) {
|
||||
const modelsToRemove = this.getActiveModels(modelClass);
|
||||
modelsToRemove.forEach((model) => this.activeModels.delete(model));
|
||||
} else {
|
||||
this.activeModels.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recently added model of a specific type (primary).
|
||||
*
|
||||
* @param modelClass the model class to filter by.
|
||||
* @returns the most recently added model of the specified type.
|
||||
*/
|
||||
getPrimaryActiveModel<T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T | undefined {
|
||||
const models = this.getActiveModels<T>(modelClass);
|
||||
return models[models.length - 1];
|
||||
}
|
||||
|
||||
@computed
|
||||
get activeDocumentId(): string | undefined {
|
||||
return this.getPrimaryActiveModel<Document>(Document)?.id;
|
||||
}
|
||||
|
||||
@computed
|
||||
get activeCollectionId(): string | undefined {
|
||||
return this.getPrimaryActiveModel<Collection>(Collection)?.id;
|
||||
}
|
||||
|
||||
@action
|
||||
setTheme = (theme: Theme) => {
|
||||
startViewTransition(() => {
|
||||
@@ -173,17 +252,28 @@ class UiStore {
|
||||
|
||||
@action
|
||||
setActiveDocument = (document: Document | string): void => {
|
||||
let model: Document | undefined;
|
||||
|
||||
if (typeof document === "string") {
|
||||
this.activeDocumentId = document;
|
||||
this.observingUserId = undefined;
|
||||
model = this.rootStore.documents.get(document);
|
||||
} else {
|
||||
model = document;
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeDocumentId = document.id;
|
||||
this.clearActiveModels(Document);
|
||||
this.addActiveModel(model);
|
||||
this.observingUserId = undefined;
|
||||
|
||||
if (document.isActive) {
|
||||
this.activeCollectionId = document.collectionId;
|
||||
if (model.isActive && model.collectionId) {
|
||||
const collection = this.rootStore.collections.get(model.collectionId);
|
||||
if (collection) {
|
||||
this.clearActiveModels(Collection);
|
||||
this.addActiveModel(collection);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,7 +293,16 @@ class UiStore {
|
||||
|
||||
@action
|
||||
setActiveCollection = (collectionId: string | undefined): void => {
|
||||
this.activeCollectionId = collectionId;
|
||||
if (collectionId === undefined || collectionId === null) {
|
||||
this.clearActiveModels(Collection);
|
||||
return;
|
||||
}
|
||||
|
||||
const model = this.rootStore.collections.get(collectionId);
|
||||
if (model) {
|
||||
this.clearActiveModels(Collection);
|
||||
this.addActiveModel(model);
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -213,12 +312,12 @@ class UiStore {
|
||||
|
||||
@action
|
||||
clearActiveDocument = (): void => {
|
||||
this.activeDocumentId = undefined;
|
||||
this.clearActiveModels(Document);
|
||||
this.observingUserId = undefined;
|
||||
|
||||
// Unset when navigating away from a document (e.g. to another document, home, settings, etc.)
|
||||
// Next document's onMount will set the right activeCollectionId.
|
||||
this.activeCollectionId = undefined;
|
||||
this.clearActiveModels(Collection);
|
||||
};
|
||||
|
||||
@action
|
||||
|
||||
+28
-2
@@ -8,12 +8,14 @@ import type {
|
||||
} from "@shared/types";
|
||||
import type RootStore from "~/stores/RootStore";
|
||||
import type { SidebarContextType } from "./components/Sidebar/components/SidebarContext";
|
||||
import type Model from "./models/base/Model";
|
||||
import type Document from "./models/Document";
|
||||
import type FileOperation from "./models/FileOperation";
|
||||
import type Pin from "./models/Pin";
|
||||
import type Star from "./models/Star";
|
||||
import type User from "./models/User";
|
||||
import type UserMembership from "./models/UserMembership";
|
||||
import type Policy from "./models/Policy";
|
||||
|
||||
export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
|
||||
Required<Pick<T, K>>;
|
||||
@@ -37,7 +39,8 @@ export type MenuItemWithChildren = {
|
||||
disabled?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
hover?: boolean;
|
||||
|
||||
/** Condition to check before preventing the submenu from closing */
|
||||
preventCloseCondition?: () => boolean;
|
||||
items: MenuItem[];
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
@@ -82,6 +85,12 @@ export type MenuGroup = {
|
||||
items: MenuItem[];
|
||||
};
|
||||
|
||||
export type MenuCustomContent = {
|
||||
type: "custom";
|
||||
visible?: boolean;
|
||||
content: React.ReactNode;
|
||||
};
|
||||
|
||||
export type MenuItem =
|
||||
| MenuInternalLink
|
||||
| MenuItemButton
|
||||
@@ -89,15 +98,32 @@ export type MenuItem =
|
||||
| MenuItemWithChildren
|
||||
| MenuSeparator
|
||||
| MenuHeading
|
||||
| MenuGroup;
|
||||
| MenuGroup
|
||||
| MenuCustomContent;
|
||||
|
||||
export type ActionContext = {
|
||||
isMenu: boolean;
|
||||
isCommandBar: boolean;
|
||||
isButton: boolean;
|
||||
sidebarContext?: SidebarContextType;
|
||||
|
||||
// Legacy (backward compatibility) - returns primary active model's ID
|
||||
activeCollectionId?: string | undefined;
|
||||
activeDocumentId: string | undefined;
|
||||
|
||||
// New API - work directly with Model instances
|
||||
getActiveModels: <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
) => T[];
|
||||
getActiveModel: <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
) => T | undefined;
|
||||
getActivePolicies: <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
) => Policy[];
|
||||
isModelActive: (model: Model) => boolean;
|
||||
activeModels: ReadonlySet<Model>;
|
||||
|
||||
currentUserId: string | undefined;
|
||||
currentTeamId: string | undefined;
|
||||
location: Location;
|
||||
|
||||
@@ -176,6 +176,7 @@ class ApiClient {
|
||||
if (!this.shareId) {
|
||||
await stores.auth.logout({
|
||||
savePath: true,
|
||||
clearCache: false,
|
||||
revokeToken: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,13 +113,19 @@ export function newDocumentPath(
|
||||
templateId?: string;
|
||||
} = {}
|
||||
): string {
|
||||
const search = queryString.stringify(params);
|
||||
|
||||
return collectionId
|
||||
? `/collection/${collectionId}/new?${queryString.stringify(params)}`
|
||||
: `/doc/new?${queryString.stringify(params)}`;
|
||||
? `/collection/${collectionId}/new${search ? `?${search}` : ""}`
|
||||
: `/doc/new${search ? `?${search}` : ""}`;
|
||||
}
|
||||
|
||||
export function newNestedDocumentPath(parentDocumentId?: string): string {
|
||||
return `/doc/new?${queryString.stringify({ parentDocumentId })}`;
|
||||
const search = parentDocumentId
|
||||
? `?${queryString.stringify({ parentDocumentId })}`
|
||||
: "";
|
||||
|
||||
return `/doc/new${search}`;
|
||||
}
|
||||
|
||||
export function searchPath({
|
||||
@@ -133,15 +139,14 @@ export function searchPath({
|
||||
documentId?: string;
|
||||
ref?: string;
|
||||
} = {}): string {
|
||||
let search = queryString.stringify({
|
||||
const search = queryString.stringify({
|
||||
q: query,
|
||||
collectionId,
|
||||
documentId,
|
||||
ref,
|
||||
});
|
||||
|
||||
search = search ? `?${search}` : "";
|
||||
return `/search${search}`;
|
||||
return `/search${search ? `?${search}` : ""}`;
|
||||
}
|
||||
|
||||
export function sharedModelPath(shareId: string, modelPath?: string) {
|
||||
|
||||
+10
-17
@@ -56,13 +56,6 @@
|
||||
"@aws-sdk/s3-presigned-post": "3.956.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.956.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.956.0",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-destructuring": "^7.28.5",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.4",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.13.0",
|
||||
@@ -82,7 +75,6 @@
|
||||
"@hocuspocus/extension-throttle": "1.1.2",
|
||||
"@hocuspocus/provider": "1.1.2",
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@linear/sdk": "^58.1.0",
|
||||
"@node-oauth/oauth2-server": "^5.2.0",
|
||||
@@ -116,8 +108,6 @@
|
||||
"addressparser": "^1.0.1",
|
||||
"async-sema": "^3.1.1",
|
||||
"autotrack": "^2.4.1",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"body-scroll-lock": "^4.0.0-beta.0",
|
||||
"bull": "^4.16.5",
|
||||
"class-validator": "^0.14.3",
|
||||
@@ -130,7 +120,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.76.0",
|
||||
"dd-trace": "^5.82.0",
|
||||
"diff": "^5.2.0",
|
||||
"email-providers": "^1.14.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
@@ -184,7 +174,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^3.18.0",
|
||||
"outline-icons": "^4.0.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"pako": "^2.1.0",
|
||||
"passport": "^0.7.0",
|
||||
@@ -217,7 +207,7 @@
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
"react": "^17.0.2",
|
||||
"react-avatar-editor": "^13.0.2",
|
||||
"react-color": "^2.17.3",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
@@ -262,7 +252,6 @@
|
||||
"tiny-cookie": "^2.5.1",
|
||||
"tmp": "^0.2.5",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"turndown": "^7.2.2",
|
||||
"ukkonen": "^2.2.0",
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
@@ -282,6 +271,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.3.1",
|
||||
@@ -328,7 +322,6 @@
|
||||
"@types/quoted-printable": "^1.0.2",
|
||||
"@types/react": "17.0.75",
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/react-color": "^3.0.13",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@types/react-portal": "^4.0.7",
|
||||
@@ -346,11 +339,11 @@
|
||||
"@types/styled-components": "^5.1.32",
|
||||
"@types/throng": "^5.0.7",
|
||||
"@types/tmp": "^0.2.6",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/utf8": "^3.0.3",
|
||||
"@types/validator": "^13.15.3",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
|
||||
"babel-plugin-transform-typescript-metadata": "^0.4.0",
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
|
||||
@@ -390,6 +383,6 @@
|
||||
"prismjs": "1.30.0",
|
||||
"cheerio": "1.0.0-rc.12"
|
||||
},
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.0",
|
||||
"packageManager": "yarn@4.11.0"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export function DiscordGuildError(
|
||||
) {
|
||||
return httpErrors(400, message, {
|
||||
id: "discord_guild_error",
|
||||
isReportable: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,5 +14,6 @@ export function DiscordGuildRoleError(
|
||||
) {
|
||||
return httpErrors(400, message, {
|
||||
id: "discord_guild_role_error",
|
||||
isReportable: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
fill?: string;
|
||||
};
|
||||
|
||||
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.3259 5.24514H9.3371C8.23873 5.24514 7.34832 6.0674 7.34832 7.08171C7.34832 8.09602 8.23873 8.91828 9.3371 8.91828H11.3259V5.24514ZM11.3259 4H12.6742H14.663C16.5061 4 18 5.37972 18 7.08171C18 8.08609 17.4798 8.97825 16.6745 9.54085C17.4798 10.1035 18 10.9956 18 12C18 13.702 16.5061 15.0817 14.663 15.0817C13.9178 15.0817 13.2296 14.8561 12.6742 14.4749V15.0817V16.9183C12.6742 18.6203 11.1801 20 9.3371 20C7.49406 20 6 18.6203 6 16.9183C6 15.9138 6.52029 15.0218 7.32556 14.4591C6.52029 13.8965 6 13.0044 6 12C6 10.9956 6.5203 10.1035 7.32559 9.54086C6.5203 8.97825 6 8.08609 6 7.08171C6 5.37972 7.49406 4 9.3371 4H11.3259ZM12.6742 5.24514V8.91828H14.663C15.7614 8.91828 16.6517 8.09602 16.6517 7.08171C16.6517 6.0674 15.7614 5.24514 14.663 5.24514H12.6742ZM9.3371 13.8366H11.3259V12.0047V12V11.9953V10.1634H9.3371C8.23873 10.1634 7.34832 10.9857 7.34832 12C7.34832 13.0119 8.23447 13.8326 9.32921 13.8366L9.3371 13.8366ZM7.34832 16.9183C7.34832 15.9064 8.23447 15.0856 9.32921 15.0817L9.3371 15.0817H11.3259V16.9183C11.3259 17.9326 10.4355 18.7549 9.3371 18.7549C8.23873 18.7549 7.34832 17.9326 7.34832 16.9183ZM12.6742 11.9963C12.6763 10.9837 13.5659 10.1634 14.663 10.1634C15.7614 10.1634 16.6517 10.9857 16.6517 12C16.6517 13.0143 15.7614 13.8366 14.663 13.8366C13.5659 13.8366 12.6763 13.0163 12.6742 12.0037V11.9963Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
|
||||
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Heading from "~/components/Heading";
|
||||
import List from "~/components/List";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Notice from "~/components/Notice";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import FigmaIcon from "./Icon";
|
||||
import { FigmaConnectButton } from "./components/FigmaButton";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import type Integration from "~/models/Integration";
|
||||
import Time from "~/components/Time";
|
||||
|
||||
function Figma() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const query = useQuery();
|
||||
const error = query.get("error");
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
const linkedAccountIntegration = integrations.find({
|
||||
type: IntegrationType.LinkedAccount,
|
||||
service: IntegrationService.Figma,
|
||||
}) as Integration<IntegrationType.LinkedAccount> | undefined;
|
||||
|
||||
const figmaAccount = linkedAccountIntegration?.settings?.figma?.account;
|
||||
|
||||
return (
|
||||
<IntegrationScene title="Figma" icon={<FigmaIcon />}>
|
||||
<Heading>Figma</Heading>
|
||||
|
||||
{error === "access_denied" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Whoops, you need to accept the permissions in Figma to connect{" "}
|
||||
{{ appName }} to your workspace. Try again?
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{error === "unauthenticated" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Something went wrong while authenticating your request. Please try
|
||||
logging in again.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{error === "unknown" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Something went wrong while processing your request. Please try
|
||||
again.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{env.FIGMA_CLIENT_ID ? (
|
||||
<>
|
||||
<Text as="p">
|
||||
<Trans>
|
||||
Link your {{ appName }} account to Figma to enable previews of
|
||||
design files you have access to, directly within documents.
|
||||
</Trans>
|
||||
</Text>
|
||||
{linkedAccountIntegration ? (
|
||||
<List>
|
||||
<ListItem
|
||||
small
|
||||
title={`${figmaAccount?.name} (${figmaAccount?.email})`}
|
||||
subtitle={
|
||||
<>
|
||||
<Trans>Enabled on</Trans>{" "}
|
||||
<Time
|
||||
dateTime={linkedAccountIntegration.createdAt}
|
||||
relative={false}
|
||||
format={{ en_US: "MMMM d, y" }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
image={
|
||||
<TeamLogo
|
||||
src={
|
||||
linkedAccountIntegration.settings?.figma?.account
|
||||
?.avatarUrl
|
||||
}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<ConnectedButton
|
||||
onClick={linkedAccountIntegration.delete}
|
||||
confirmationMessage={t(
|
||||
"Disconnecting will prevent previewing Figma design files from this account in documents. Are you sure?"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
) : (
|
||||
<p>
|
||||
<FigmaConnectButton icon={<FigmaIcon />} />
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The Figma integration is currently disabled. Please set the
|
||||
associated environment variables and restart the server to enable
|
||||
the integration.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Figma);
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button, { type Props } from "~/components/Button";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { redirectTo } from "~/utils/urls";
|
||||
import { FigmaUtils } from "../../shared/FigmaUtils";
|
||||
|
||||
export function FigmaConnectButton(props: Props<HTMLButtonElement>) {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
redirectTo(FigmaUtils.authUrl({ state: { teamId: team.id } }))
|
||||
}
|
||||
neutral
|
||||
{...props}
|
||||
>
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
description:
|
||||
"Connect your Figma account to Outline to enable rich design file previews inside documents.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "figma",
|
||||
"name": "Figma",
|
||||
"priority": 15,
|
||||
"description": "Adds a Figma integration for link unfurling and converting links to mentions.",
|
||||
"after": "linear"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import Router from "koa-router";
|
||||
import * as T from "./schema";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import type { APIContext } from "@server/types";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { FigmaUtils } from "plugins/figma/shared/FigmaUtils";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { Integration, IntegrationAuthentication } from "@server/models";
|
||||
import { addSeconds } from "date-fns";
|
||||
import { Figma } from "../figma";
|
||||
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get(
|
||||
"figma.callback",
|
||||
auth({ optional: true }),
|
||||
validate(T.FigmaCallbackSchema),
|
||||
apexAuthRedirect<T.FigmaCallbackReq>({
|
||||
getTeamId: (ctx) => FigmaUtils.parseState(ctx.input.query.state)?.teamId,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
FigmaUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => FigmaUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.FigmaCallbackReq>) => {
|
||||
const { code, error } = ctx.input.query;
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(FigmaUtils.errorUrl(error));
|
||||
return;
|
||||
}
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
try {
|
||||
// validation middleware ensures that code is non-null at this point.
|
||||
const oauth = await Figma.oauthAccess(code!);
|
||||
const figmaAccount = await Figma.getInstalledAccount(oauth.access_token);
|
||||
|
||||
const authentication = await IntegrationAuthentication.create(
|
||||
{
|
||||
service: IntegrationService.Figma,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: oauth.access_token,
|
||||
refreshToken: oauth.refresh_token,
|
||||
expiresAt: addSeconds(Date.now(), oauth.expires_in),
|
||||
scopes: FigmaUtils.oauthScopes,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
const integration = await Integration.create<
|
||||
Integration<IntegrationType.LinkedAccount>
|
||||
>(
|
||||
{
|
||||
service: IntegrationService.Figma,
|
||||
type: IntegrationType.LinkedAccount,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
figma: {
|
||||
account: {
|
||||
id: figmaAccount.id,
|
||||
name: figmaAccount.handle,
|
||||
email: figmaAccount.email,
|
||||
avatarUrl: figmaAccount.img_url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
transaction.afterCommit(async () => {
|
||||
await new UploadIntegrationLogoTask().schedule({
|
||||
integrationId: integration.id,
|
||||
logoUrl: figmaAccount.img_url,
|
||||
});
|
||||
});
|
||||
|
||||
ctx.redirect(FigmaUtils.successUrl());
|
||||
} catch (err) {
|
||||
Logger.error("Encountered error during Figma OAuth callback", err);
|
||||
ctx.redirect(FigmaUtils.errorUrl("unknown"));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
|
||||
export const FigmaCallbackSchema = BaseSchema.extend({
|
||||
query: z
|
||||
.object({
|
||||
code: z.string().nullish(),
|
||||
state: z.string(),
|
||||
error: z.string().nullish(),
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
})
|
||||
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
|
||||
message: "code and error cannot both be present",
|
||||
}),
|
||||
});
|
||||
|
||||
export type FigmaCallbackReq = z.infer<typeof FigmaCallbackSchema>;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Environment } from "@server/env";
|
||||
import { Public } from "@server/utils/decorators/Public";
|
||||
import environment from "@server/utils/environment";
|
||||
import { CannotUseWithout } from "@server/utils/validators";
|
||||
import { IsOptional } from "class-validator";
|
||||
|
||||
class FigmaPluginEnvironment extends Environment {
|
||||
/**
|
||||
* Figma OAuth2 app client id. To enable integration with Figma.
|
||||
*/
|
||||
@Public
|
||||
@IsOptional()
|
||||
public FIGMA_CLIENT_ID = this.toOptionalString(environment.FIGMA_CLIENT_ID);
|
||||
|
||||
/**
|
||||
* Figma OAuth2 app client secret. To enable integration with Figma.
|
||||
*/
|
||||
@IsOptional()
|
||||
@CannotUseWithout("FIGMA_CLIENT_ID")
|
||||
public FIGMA_CLIENT_SECRET = this.toOptionalString(
|
||||
environment.FIGMA_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
export default new FigmaPluginEnvironment();
|
||||
@@ -0,0 +1,208 @@
|
||||
import { z } from "zod";
|
||||
import env from "./env";
|
||||
import { FigmaUtils } from "../shared/FigmaUtils";
|
||||
import type { UnfurlSignature } from "@server/types";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import type { User } from "@server/models";
|
||||
import { Integration } from "@server/models";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { IntegrationService, UnfurlResourceType } from "@shared/types";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
|
||||
const Credentials = Buffer.from(
|
||||
`${env.FIGMA_CLIENT_ID}:${env.FIGMA_CLIENT_SECRET}`
|
||||
).toString("base64");
|
||||
|
||||
const AccessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
refresh_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
});
|
||||
|
||||
const RefreshTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
});
|
||||
|
||||
const AccountResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
handle: z.string(),
|
||||
email: z.string(),
|
||||
img_url: z.string(),
|
||||
});
|
||||
|
||||
export class Figma {
|
||||
private static supportedHosts = ["www.figma.com", "figma.com"];
|
||||
private static supportedFileTypes = [
|
||||
"design", // Design files
|
||||
"board", // Figjam
|
||||
"slides",
|
||||
"buzz",
|
||||
"site",
|
||||
"make",
|
||||
];
|
||||
/**
|
||||
* Exchange an OAuth code for an access token
|
||||
*
|
||||
* @param code OAuth code to exchange for an access token
|
||||
* @returns An object containing the access token and refresh token
|
||||
*/
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${Credentials}`,
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set("code", code);
|
||||
body.set("redirect_uri", FigmaUtils.callbackUrl());
|
||||
body.set("grant_type", "authorization_code");
|
||||
|
||||
const res = await fetch(FigmaUtils.tokenUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Error exchanging Figma OAuth code; status: ${res.status}, ${await res.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
return AccessTokenResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
static async refreshToken(refreshToken: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${Credentials}`,
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set("refresh_token", refreshToken);
|
||||
|
||||
const res = await fetch(FigmaUtils.refreshUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Error while refreshing access token from Figma; status: ${res.status}, ${await res.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshTokenResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
static async getInstalledAccount(accessToken: string) {
|
||||
const res = await fetch(FigmaUtils.accountUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Error getting Figma current account; status: ${res.status}, ${await res.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
return AccountResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
|
||||
const resource = Figma.parseUrl(url);
|
||||
if (!resource || !actor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integrations = (await Integration.scope("withAuthentication").findAll(
|
||||
{
|
||||
where: {
|
||||
type: IntegrationType.LinkedAccount,
|
||||
service: IntegrationService.Figma,
|
||||
userId: actor.id,
|
||||
teamId: actor.teamId,
|
||||
},
|
||||
}
|
||||
)) as Integration<IntegrationType.LinkedAccount>[];
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to unfurl with any of the linked accounts
|
||||
// Note: We support only one figma account per team for now.
|
||||
for (const integration of integrations) {
|
||||
try {
|
||||
const accessToken =
|
||||
await integration.authentication.refreshTokenIfNeeded(
|
||||
async (refreshToken: string) => Figma.refreshToken(refreshToken),
|
||||
5 * Minute.ms
|
||||
);
|
||||
|
||||
const res = await fetch(Figma.fileMetadataUrl(resource.key), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// This connected account has access to the file.
|
||||
if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
return {
|
||||
type: UnfurlResourceType.URL,
|
||||
url,
|
||||
title: data.file.name,
|
||||
description: `Created by ${data.file.creator.handle}`,
|
||||
thumbnailUrl: data.file.thumbnail_url,
|
||||
faviconUrl: cdnPath("/images/figma.png"),
|
||||
transformedUnfurl: true,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(
|
||||
`Error fetching Figma file metadata for integration ${integration.id}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Either no linked accounts have access to the file, or we faced an error.
|
||||
// Fallback to iframely unfurl either way.
|
||||
return;
|
||||
};
|
||||
|
||||
private static parseUrl(url: string) {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (!Figma.supportedHosts.includes(hostname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
const type = parts[1];
|
||||
const key = parts[2];
|
||||
|
||||
if (!Figma.supportedFileTypes.includes(type) || isEmpty(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
private static fileMetadataUrl(key: string) {
|
||||
return `https://api.figma.com/v1/files/${key}/meta`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import router from "./api/figma";
|
||||
import env from "./env";
|
||||
import { Figma } from "./figma";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
|
||||
const enabled = !!env.FIGMA_CLIENT_ID && !!env.FIGMA_CLIENT_SECRET;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.API,
|
||||
value: router,
|
||||
},
|
||||
{
|
||||
type: Hook.UnfurlProvider,
|
||||
value: { unfurl: Figma.unfurl, cacheExpiry: 10 * Minute.seconds },
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import queryString from "query-string";
|
||||
import env from "@shared/env";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
|
||||
export type OAuthState = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export class FigmaUtils {
|
||||
public static oauthScopes = ["current_user:read", "file_metadata:read"];
|
||||
|
||||
public static accountUrl = "https://api.figma.com/v1/me";
|
||||
public static tokenUrl = "https://api.figma.com/v1/oauth/token";
|
||||
public static refreshUrl = "https://api.figma.com/v1/oauth/refresh";
|
||||
private static authBaseUrl = "https://www.figma.com/oauth";
|
||||
|
||||
private static settingsUrl = integrationSettingsPath("figma");
|
||||
|
||||
static parseState(state: string): OAuthState {
|
||||
return JSON.parse(state);
|
||||
}
|
||||
|
||||
static successUrl() {
|
||||
return this.settingsUrl;
|
||||
}
|
||||
|
||||
static errorUrl(error: string) {
|
||||
return `${this.settingsUrl}?error=${error}`;
|
||||
}
|
||||
|
||||
static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: env.URL,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
return params
|
||||
? `${baseUrl}/api/figma.callback?${params}`
|
||||
: `${baseUrl}/api/figma.callback`;
|
||||
}
|
||||
|
||||
static authUrl({ state }: { state: OAuthState }) {
|
||||
const params = {
|
||||
client_id: env.FIGMA_CLIENT_ID,
|
||||
redirect_uri: this.callbackUrl(),
|
||||
state: JSON.stringify(state),
|
||||
scope: this.oauthScopes.join(","),
|
||||
response_type: "code",
|
||||
};
|
||||
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export const GitHubCallbackSchema = BaseSchema.extend({
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
})
|
||||
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
|
||||
message: "code and error cannot both be present",
|
||||
})
|
||||
.refine(
|
||||
(req) =>
|
||||
!(
|
||||
|
||||
@@ -226,11 +226,10 @@ export class GitHub {
|
||||
* @param actor User attempting to unfurl resource url
|
||||
* @returns An object containing resource details e.g, a GitHub Pull Request details
|
||||
*/
|
||||
public static unfurl: UnfurlSignature = async (url: string, actor: User) => {
|
||||
// Early return if URL doesn't match GitHub pattern (before any DB queries)
|
||||
public static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
|
||||
const resource = GitHub.parseUrl(url);
|
||||
|
||||
if (!resource) {
|
||||
if (!resource || !actor) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Logger from "@server/logging/Logger";
|
||||
import type { UnfurlError, UnfurlSignature } from "@server/types";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import env from "./env";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
|
||||
class Iframely {
|
||||
public static defaultUrl = "https://iframe.ly";
|
||||
@@ -40,9 +41,25 @@ class Iframely {
|
||||
*/
|
||||
public static unfurl: UnfurlSignature = async (url: string) => {
|
||||
const data = await Iframely.requestResource(url);
|
||||
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
|
||||
? ({ error: data.error } as UnfurlError)
|
||||
: { ...data, type: UnfurlResourceType.URL };
|
||||
|
||||
if ("error" in data) {
|
||||
return { error: data.error } as UnfurlError; // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
|
||||
}
|
||||
|
||||
const parsedData = data as Record<string, any>;
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.URL,
|
||||
url: parsedData.url,
|
||||
title: parsedData.meta.title,
|
||||
description: parsedData.meta.description,
|
||||
thumbnailUrl: (parsedData.links.thumbnail ?? [])[0]?.href ?? "",
|
||||
faviconUrl:
|
||||
parsedData.meta.site === "Figma"
|
||||
? cdnPath("/images/figma.png")
|
||||
: ((parsedData.links.icon ?? [])[0]?.href ?? ""),
|
||||
transformedUnfurl: true,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ function Linear() {
|
||||
<Text as="p">
|
||||
<Trans>
|
||||
Enable previews of Linear issues in documents by connecting a
|
||||
Linear workspace to {appName}.
|
||||
Linear workspace to {{ appName }}.
|
||||
</Trans>
|
||||
</Text>
|
||||
{integrations.linear.length ? (
|
||||
|
||||
@@ -8,7 +8,7 @@ import validate from "@server/middlewares/validate";
|
||||
import { IntegrationAuthentication, Integration } from "@server/models";
|
||||
import type { APIContext } from "@server/types";
|
||||
import { Linear } from "../linear";
|
||||
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
|
||||
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
|
||||
import * as T from "./schema";
|
||||
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
|
||||
import { addSeconds } from "date-fns";
|
||||
@@ -86,7 +86,7 @@ router.get(
|
||||
|
||||
transaction.afterCommit(async () => {
|
||||
if (workspace.logoUrl) {
|
||||
await new UploadLinearWorkspaceLogoTask().schedule({
|
||||
await new UploadIntegrationLogoTask().schedule({
|
||||
integrationId: integration.id,
|
||||
logoUrl: workspace.logoUrl,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@ export const LinearCallbackSchema = BaseSchema.extend({
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
})
|
||||
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
|
||||
message: "code and error cannot both be present",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import config from "../plugin.json";
|
||||
import router from "./api/linear";
|
||||
import env from "./env";
|
||||
import { Linear } from "./linear";
|
||||
import UploadLinearWorkspaceLogoTask from "./tasks/UploadLinearWorkspaceLogoTask";
|
||||
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
|
||||
import { uninstall } from "./uninstall";
|
||||
|
||||
const enabled = !!env.LINEAR_CLIENT_ID && !!env.LINEAR_CLIENT_SECRET;
|
||||
@@ -18,7 +18,7 @@ if (enabled) {
|
||||
},
|
||||
{
|
||||
type: Hook.Task,
|
||||
value: UploadLinearWorkspaceLogoTask,
|
||||
value: UploadIntegrationLogoTask,
|
||||
},
|
||||
{
|
||||
type: Hook.UnfurlProvider,
|
||||
|
||||
@@ -104,11 +104,10 @@ export class Linear {
|
||||
* @param actor User attempting to unfurl resource url
|
||||
* @returns An object containing resource details e.g, a Linear issue details
|
||||
*/
|
||||
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
|
||||
// Early return if URL doesn't match Linear pattern (before any DB queries)
|
||||
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
|
||||
const resource = Linear.parseUrl(url);
|
||||
|
||||
if (!resource) {
|
||||
if (!resource || !actor) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export const NotionCallbackSchema = BaseSchema.extend({
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
})
|
||||
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
|
||||
message: "code and error cannot both be present",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ PluginManager.add([
|
||||
description:
|
||||
"Manage your passkeys for passwordless authentication using biometrics or security keys.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: () => true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { APIContext } from "@server/types";
|
||||
import { getExpectedOrigin } from "./passkeys";
|
||||
|
||||
describe("getExpectedOrigin", () => {
|
||||
// Helper to mock APIContext for testing
|
||||
const createMockContext = (options: {
|
||||
protocol: string;
|
||||
hostname: string;
|
||||
host: string;
|
||||
forwardedPort?: string;
|
||||
}): APIContext => ({
|
||||
protocol: options.protocol,
|
||||
request: {
|
||||
hostname: options.hostname,
|
||||
host: options.host,
|
||||
get: (header: string) => {
|
||||
if (header === "X-Forwarded-Port" && options.forwardedPort) {
|
||||
return options.forwardedPort;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as unknown,
|
||||
}) as unknown as APIContext;
|
||||
|
||||
it("should construct origin with non-standard HTTPS port from X-Forwarded-Port", () => {
|
||||
const ctx = createMockContext({
|
||||
protocol: "https",
|
||||
hostname: "outline.example.com",
|
||||
host: "outline.example.com", // Without port (from X-Forwarded-Host)
|
||||
forwardedPort: "10081",
|
||||
});
|
||||
|
||||
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com:10081");
|
||||
});
|
||||
|
||||
it("should construct origin without port for standard HTTPS port (443)", () => {
|
||||
const ctx = createMockContext({
|
||||
protocol: "https",
|
||||
hostname: "outline.example.com",
|
||||
host: "outline.example.com",
|
||||
forwardedPort: "443",
|
||||
});
|
||||
|
||||
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com");
|
||||
});
|
||||
|
||||
it("should construct origin without port for standard HTTP port (80)", () => {
|
||||
const ctx = createMockContext({
|
||||
protocol: "http",
|
||||
hostname: "outline.example.com",
|
||||
host: "outline.example.com",
|
||||
forwardedPort: "80",
|
||||
});
|
||||
|
||||
expect(getExpectedOrigin(ctx)).toBe("http://outline.example.com");
|
||||
});
|
||||
|
||||
it("should use host with port when X-Forwarded-Port is not present", () => {
|
||||
const ctx = createMockContext({
|
||||
protocol: "https",
|
||||
hostname: "outline.example.com",
|
||||
host: "outline.example.com:8443",
|
||||
});
|
||||
|
||||
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com:8443");
|
||||
});
|
||||
|
||||
it("should construct origin without port when not in host and no X-Forwarded-Port", () => {
|
||||
const ctx = createMockContext({
|
||||
protocol: "https",
|
||||
hostname: "outline.example.com",
|
||||
host: "outline.example.com",
|
||||
});
|
||||
|
||||
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com");
|
||||
});
|
||||
|
||||
it("should handle HTTP with non-standard port", () => {
|
||||
const ctx = createMockContext({
|
||||
protocol: "http",
|
||||
hostname: "outline.example.com",
|
||||
host: "outline.example.com",
|
||||
forwardedPort: "8080",
|
||||
});
|
||||
|
||||
expect(getExpectedOrigin(ctx)).toBe("http://outline.example.com:8080");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { isoBase64URL } from "@simplewebauthn/server/helpers";
|
||||
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
|
||||
import Router from "koa-router";
|
||||
import { randomBytes } from "crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { User, UserPasskey, Team } from "@server/models";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import validate from "@server/middlewares/validate";
|
||||
@@ -30,6 +30,43 @@ const CHALLENGE_EXPIRY_MS = Minute.ms * 5;
|
||||
// Helper to get RP ID (domain) - for simplicity, we can use the hostname but strip port.
|
||||
const getRpID = (ctx: APIContext) => ctx.request.hostname;
|
||||
|
||||
/**
|
||||
* Helper to get the expected origin for WebAuthn.
|
||||
* Properly handles non-standard ports by checking X-Forwarded-Port header.
|
||||
*
|
||||
* @param ctx - the API context.
|
||||
* @returns the expected origin (protocol://host:port).
|
||||
*/
|
||||
export const getExpectedOrigin = (ctx: APIContext): string => {
|
||||
const protocol = ctx.protocol;
|
||||
const hostname = ctx.request.hostname;
|
||||
|
||||
// When behind a proxy with app.proxy = true, Koa uses X-Forwarded-Host
|
||||
// which typically doesn't include the port. We need to check X-Forwarded-Port.
|
||||
const forwardedPort = ctx.request.get("X-Forwarded-Port");
|
||||
|
||||
// ctx.request.host includes port if present (e.g., "example.com:3000")
|
||||
// ctx.request.hostname excludes port (e.g., "example.com")
|
||||
const hostWithPort = ctx.request.host;
|
||||
|
||||
// Determine if we need to add a port to the origin
|
||||
let origin = `${protocol}://${hostname}`;
|
||||
|
||||
// Check if X-Forwarded-Port exists (when behind a proxy)
|
||||
if (forwardedPort) {
|
||||
const port = parseInt(forwardedPort, 10);
|
||||
// Only add port if it's not the default for the protocol
|
||||
if ((protocol === "https" && port !== 443) || (protocol === "http" && port !== 80)) {
|
||||
origin = `${protocol}://${hostname}:${port}`;
|
||||
}
|
||||
} else if (hostWithPort !== hostname) {
|
||||
// hostWithPort includes port, use it directly
|
||||
origin = `${protocol}://${hostWithPort}`;
|
||||
}
|
||||
|
||||
return origin;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate Redis key for registration challenge.
|
||||
*
|
||||
@@ -64,7 +101,6 @@ router.post(
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
authenticatorAttachment: "platform",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,7 +141,7 @@ router.post(
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: body,
|
||||
expectedChallenge,
|
||||
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`, // Origin includes port
|
||||
expectedOrigin: getExpectedOrigin(ctx),
|
||||
expectedRPID: getRpID(ctx),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -215,9 +251,14 @@ router.post(
|
||||
include: [{ model: Team, as: "team", required: true }],
|
||||
},
|
||||
],
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw ValidationError(
|
||||
"Passkey not found. It may have been removed or registered on a different account."
|
||||
);
|
||||
}
|
||||
|
||||
const user = passkey.user;
|
||||
const team = user.team;
|
||||
|
||||
@@ -226,7 +267,7 @@ router.post(
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: body,
|
||||
expectedChallenge,
|
||||
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`,
|
||||
expectedOrigin: getExpectedOrigin(ctx),
|
||||
expectedRPID: getRpID(ctx),
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
|
||||
@@ -11,6 +11,9 @@ export const SlackPostSchema = BaseSchema.extend({
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
})
|
||||
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
|
||||
message: "code and error cannot both be present",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Integration.create(
|
||||
await Integration.create<Integration<IntegrationType.Post>>(
|
||||
{
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
@@ -226,7 +226,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Integration.create(
|
||||
await Integration.create<Integration<IntegrationType.Command>>(
|
||||
{
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Command,
|
||||
@@ -246,7 +246,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
case IntegrationType.LinkedAccount: {
|
||||
// validation middleware ensures that code is non-null at this point
|
||||
const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl());
|
||||
await Integration.create({
|
||||
await Integration.create<Integration<IntegrationType.LinkedAccount>>({
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.LinkedAccount,
|
||||
userId: user.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import querystring from "querystring";
|
||||
import querystring from "node:querystring";
|
||||
import { InvalidRequestError } from "@server/errors";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import { SlackUtils } from "../shared/SlackUtils";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, copyFileSync } from "fs";
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { existsSync, copyFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import FormData from "form-data";
|
||||
import { ensureDirSync } from "fs-extra";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
import { randomUUID } from "crypto";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
@@ -340,6 +340,75 @@ describe("#files.get", () => {
|
||||
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when public-read avatar in uploads bucket is requested by non-owner", async () => {
|
||||
const owner = await buildUser();
|
||||
const otherUser = await buildUser({ teamId: owner.teamId });
|
||||
const key = AttachmentHelper.getKey({
|
||||
id: randomUUID(),
|
||||
name: "avatar.jpg",
|
||||
userId: owner.id,
|
||||
});
|
||||
await buildAttachment({
|
||||
key,
|
||||
teamId: owner.teamId,
|
||||
userId: owner.id,
|
||||
contentType: "image/jpg",
|
||||
acl: "public-read",
|
||||
});
|
||||
|
||||
ensureDirSync(
|
||||
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
|
||||
);
|
||||
|
||||
copyFileSync(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
|
||||
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
|
||||
);
|
||||
|
||||
// Non-owner user should be able to access public-read attachment
|
||||
const res = await server.get(`/api/files.get?key=${key}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${otherUser.getJwtToken()}`,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.headers.get("Content-Type")).toEqual("image/jpg");
|
||||
});
|
||||
|
||||
it("should fail with status 403 when private attachment in uploads bucket is requested by non-owner", async () => {
|
||||
const owner = await buildUser();
|
||||
const otherUser = await buildUser({ teamId: owner.teamId });
|
||||
const key = AttachmentHelper.getKey({
|
||||
id: randomUUID(),
|
||||
name: "document.pdf",
|
||||
userId: owner.id,
|
||||
});
|
||||
await buildAttachment({
|
||||
key,
|
||||
teamId: owner.teamId,
|
||||
userId: owner.id,
|
||||
contentType: "application/pdf",
|
||||
acl: "private",
|
||||
});
|
||||
|
||||
ensureDirSync(
|
||||
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
|
||||
);
|
||||
|
||||
copyFileSync(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
|
||||
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
|
||||
);
|
||||
|
||||
// Non-owner user should NOT be able to access private attachment
|
||||
const res = await server.get(`/api/files.get?key=${key}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${otherUser.getJwtToken()}`,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when exported file is requested using signature", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "export-markdown.zip";
|
||||
|
||||
@@ -77,10 +77,15 @@ router.get(
|
||||
const forceDownload = !!ctx.input.query.download;
|
||||
const isSignedRequest = !!ctx.input.query.sig;
|
||||
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
|
||||
const skipAuthorize = isPublicBucket || isSignedRequest;
|
||||
const cacheHeader = "max-age=604800, immutable";
|
||||
const attachment = await Attachment.findByKey(key);
|
||||
|
||||
// Skip authorization for public bucket, signed requests, or public-read ACL attachments
|
||||
const skipAuthorize =
|
||||
isPublicBucket ||
|
||||
isSignedRequest ||
|
||||
(attachment && !attachment.isPrivate);
|
||||
|
||||
if (!skipAuthorize) {
|
||||
if (!attachment && !!ctx.input.query.key) {
|
||||
throw NotFoundError();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user