Compare commits

...

62 Commits

Author SHA1 Message Date
Tom Moor 6e07aa877f clear secret 2024-10-01 22:17:45 -04:00
Tom Moor 19d5ef5694 Fix code scanning alert no. 67: Shell command built from environment values
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2024-10-01 19:05:20 -07:00
Tom Moor b37074304a fix 2024-10-01 22:04:50 -04:00
Tom Moor 35c7cc2086 suppress on cloud 2024-10-01 21:40:09 -04:00
Tom Moor 82f9600d9e fix: last4 not written 2024-10-01 21:38:31 -04:00
Tom Moor 686f9aeb5c Merge main 2024-10-01 21:02:36 -04:00
Tom Moor a41e17f875 feat: Add 'Copy public link' option to document menu 2024-10-01 09:55:33 -04:00
Hemachandar db114fd966 perf: store document state in context (#7658)
* perf: store document state in context

* provide doc-context for shared routes
2024-10-01 05:16:32 -07:00
dependabot[bot] ce987d23ed chore(deps): bump socket.io-client from 4.7.5 to 4.8.0 (#7701)
Bumps [socket.io-client](https://github.com/socketio/socket.io) from 4.7.5 to 4.8.0.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-client@4.7.5...socket.io-client@4.8.0)

---
updated-dependencies:
- dependency-name: socket.io-client
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 17:16:53 -07:00
Tom Moor 5e61fcd336 Add hashed column for API keys (#7699)
* Add hashed column for API keys

* test

* Add obfuscatedValue getter
2024-09-30 17:16:35 -07:00
dependabot[bot] 4f84daf558 chore(deps-dev): bump @types/body-scroll-lock from 3.1.0 to 3.1.2 (#7703)
Bumps [@types/body-scroll-lock](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/body-scroll-lock) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/body-scroll-lock)

---
updated-dependencies:
- dependency-name: "@types/body-scroll-lock"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 16:30:17 -07:00
dependabot[bot] f80842ca20 chore(deps-dev): bump @types/jest from 29.5.12 to 29.5.13 (#7702)
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 29.5.12 to 29.5.13.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

---
updated-dependencies:
- dependency-name: "@types/jest"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 16:30:07 -07:00
Tom Moor 53758b69fb fix: Another race condition with policy removal on startup 2024-09-30 18:57:39 -04:00
Tom Moor cd86877cb0 fix: Race condition with policies removed on app load 2024-09-30 17:51:01 -04:00
Tom Moor a2d5598b96 wip 2024-09-30 17:46:30 -04:00
清纯的小黄瓜 ffae5d2f20 fix: sidebar cannot display correctly on mobile, when collapsed is true (#7694) 2024-09-30 05:21:44 -07:00
Tom Moor 53272c8c3d test 2024-09-30 07:16:03 -04:00
Tom Moor 65ff9bde3e Add hashed column for API keys 2024-09-30 07:07:38 -04:00
Hemachandar 21adfdd1bf perf: use findAll for querying document collaborators (#7697)
* perf: use findAll for querying document collaborators

* remove unnecessary compact
2024-09-29 09:43:07 -07:00
Hemachandar 91c2f60827 fix: align document and collection title (#7696) 2024-09-29 09:37:09 -07:00
Tom Moor a253d2921a Add reverse alphabetical sort for collections, closes #7692 2024-09-29 09:53:50 -04:00
Tom Moor b83d218fbe fix: Add icons to copy functionality in command menu 2024-09-29 09:37:15 -04:00
Tom Moor dce96955a1 Tweak command bar heading style 2024-09-29 09:33:09 -04:00
Tom Moor ba7c446f59 Add confirmation dialog when dragging 2024-09-29 09:31:00 -04:00
Tom Moor 7b9ec4c43a Show targeted collection/document in command bar, closes #7678 2024-09-28 22:46:41 -04:00
Tom Moor faaf0a6733 Add recently viewed documents to top of command menu (#7685)
* Add recent documents to command menu
Add priority key to actions and sections

* refactor

* Rename section

* refactor, remove more unused code
2024-09-28 17:30:40 -07:00
Hemachandar c58aafeb32 fix: check doc access before sending mention email (#7664)
* fix: check doc access before sending mention email

* refactor

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-09-28 14:29:34 -07:00
bLue 3f73c9d2bf fix: Adjust math delimiter to fix markdown import/export compatibility issue (#6727)
#6650)
2024-09-28 12:45:03 -07:00
Tom Moor b6e43e1990 Add Heading 4 to block menu 2024-09-28 13:13:25 -04:00
Tom Moor 0a2c066253 fix: Improve display of Mermaid pie charts 2024-09-28 12:31:46 -04:00
Tom Moor 840db4692e fix: Correctly decorate urlId as Unique (#7671) 2024-09-28 06:23:27 -07:00
Tom Moor fa961d7464 fix: Drop to reorder cursor appearing where move location is invalid 2024-09-28 09:21:26 -04:00
Tom Moor 3e75b24f7a fix: Table sorting does not take into account tables without a header 2024-09-27 22:12:01 -04:00
Tom Moor ce91071995 fix: post login redirect path not correctly spent, closes #7662 2024-09-26 17:35:13 -04:00
Tom Moor 9b807f7a9e v0.80.2 2024-09-25 22:50:22 -04:00
Tom Moor 17493ca0cf fix: Account for multiple existing OIDC authentication providers, closes #7638 2024-09-25 22:49:48 -04:00
Tom Moor 1d4b05c9f6 fix: Add check for paths in useLastVisitedPath, closes #7655 2024-09-25 22:00:52 -04:00
Tom Moor 8a5e42071f fix: Remove deprecated meta tag 2024-09-25 08:58:17 -04:00
Tom Moor 6b53755f5a fix: Maximum call stack size exceeded, closes #7642 2024-09-24 23:11:58 -04:00
Tom Moor 709e4f44fd fix: Allow team creation without SSO methods 2024-09-24 22:33:53 -04:00
dependabot[bot] c37646b5ad chore(deps): bump bull from 4.12.2 to 4.16.3 (#7649)
Bumps [bull](https://github.com/OptimalBits/bull) from 4.12.2 to 4.16.3.
- [Release notes](https://github.com/OptimalBits/bull/releases)
- [Changelog](https://github.com/OptimalBits/bull/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/OptimalBits/bull/compare/v4.12.2...v4.16.3)

---
updated-dependencies:
- dependency-name: bull
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-23 20:37:56 -07:00
dependabot[bot] 36ca667c50 chore(deps-dev): bump discord-api-types from 0.37.87 to 0.37.101 (#7646)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.37.87 to 0.37.101.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.37.87...0.37.101)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-23 19:13:38 -07:00
uy/sun 009e66a466 fix: disable bilibili autoplay (#7639) 2024-09-23 04:50:52 -07:00
Tom Moor 7adda26c6d v0.80.1 2024-09-22 13:00:56 -04:00
Tom Moor 62860c593b fix: Version number not updated from previous release, #7635 2024-09-22 13:00:48 -04:00
Tom Moor bdc2357984 Merge branch 'main' of github.com:outline/outline 2024-09-22 12:59:16 -04:00
Tom Moor 4fc1ed0d7e Add release script (#7637) 2024-09-22 09:59:10 -07:00
Tom Moor 5d068361cc fix: Protect against dangerous characters in env 2024-09-22 12:58:49 -04:00
Tom Moor 176cfff7f8 fix: Document editors are sometimes not included in insights/collaboratorIds, closes #7613 2024-09-21 15:30:56 -04:00
Tom Moor 2fd18f7fdb fix: Add resilience for absolute attachment paths in Markdown importer, closes #7512 2024-09-21 14:34:28 -04:00
Tom Moor 34f951c511 fix: Pragmatic fix to drafts count in sidebar, closes #7219 2024-09-21 13:51:46 -04:00
Tom Moor f0c26cf8c8 refactor: Move hooks to correct folder 2024-09-21 13:27:10 -04:00
Translate-O-Tron d77ddbd7de New Crowdin updates (#7595) 2024-09-21 06:31:07 -07:00
Tom Moor 4e1038837b chore: Remove old feature flag
fix: Loading jank when creating new collection
Add italic prop to Text component
2024-09-21 09:03:20 -04:00
Tom Moor c54fcc3536 fix: Improve scroll to comment logic, closes #7435 2024-09-20 23:31:46 -04:00
Tom Moor c4fa63df3d Sticky public access toggle (#7617)
* Sticky public access toggle

* tweak
2024-09-20 18:42:50 -07:00
Tom Moor 2b42ce0c0f fix: document.content column not updated when sending text attribute through API (#7630) 2024-09-20 07:37:42 -07:00
Apoorv Mishra 3208156591 In documents.archived, allow sort: "index" in request (#7628)
* fix: allow sorting by index and filtering by collectionId

* fix: cleanup
2024-09-20 11:29:20 +05:30
Tom Moor e8577ef2a8 chore: Bump prosemirror-view 2024-09-19 20:01:22 -04:00
Tom Moor ca66dec22b Merge branch 'main' of github.com:outline/outline 2024-09-19 20:00:36 -04:00
Tom Moor 41ccad7cce Adds natural behavior of Tab, Shift-Tab in code blocks (#7622)
* Adds proper handling of Tab, Shift-Tab in code blocks

* refactor
2024-09-19 17:00:05 -07:00
Tom Moor bd52b364dd Increase collection rate limit to 50/hour 2024-09-19 09:01:05 -04:00
146 changed files with 2169 additions and 1552 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.79.1
Licensed Work: Outline 0.80.2
The Licensed Work is (c) 2024 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: 2028-09-05
Change Date: 2028-09-26
Change License: Apache License, Version 2.0
+8 -8
View File
@@ -19,7 +19,7 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
@@ -70,7 +70,7 @@ export const editCollection = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection",
section: CollectionSection,
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
@@ -96,7 +96,7 @@ export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: CollectionSection,
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
@@ -127,7 +127,7 @@ export const editCollectionPermissions = createAction({
export const searchInCollection = createAction({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: CollectionSection,
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
@@ -140,7 +140,7 @@ export const searchInCollection = createAction({
export const starCollection = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: CollectionSection,
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId }) => {
@@ -167,7 +167,7 @@ export const starCollection = createAction({
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: CollectionSection,
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId }) => {
@@ -193,7 +193,7 @@ export const unstarCollection = createAction({
export const deleteCollection = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: CollectionSection,
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId }) => {
@@ -227,7 +227,7 @@ export const deleteCollection = createAction({
export const createTemplate = createAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: CollectionSection,
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId }) =>
+98 -58
View File
@@ -27,10 +27,15 @@ import {
CopyIcon,
EyeIcon,
PadlockIcon,
GlobeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import { ExportContentType, TeamPreference } from "@shared/types";
import {
ExportContentType,
TeamPreference,
NavigationNode,
} from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
@@ -39,11 +44,17 @@ import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import Icon from "~/components/Icon";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection, TrashSection } from "~/actions/sections";
import {
ActiveDocumentSection,
DocumentSection,
TrashSection,
} from "~/actions/sections";
import env from "~/env";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
@@ -67,23 +78,24 @@ export const openDocument = createAction({
keywords: "go to",
icon: <DocumentIcon />,
children: ({ stores }) => {
const paths = stores.collections.pathsToDocuments;
const nodes = stores.collections.navigationNodes.reduce(
(acc, node) => [...acc, ...node.children],
[] as NavigationNode[]
);
return paths
.filter((path) => path.type === "document")
.map((path) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: path.url,
name: path.title,
icon: function _Icon() {
return stores.documents.get(path.id)?.isStarred ? (
<StarredIcon />
) : null;
},
section: DocumentSection,
perform: () => history.push(path.url),
}));
return nodes.map((item) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
section: DocumentSection,
perform: () => history.push(item.url),
}));
},
});
@@ -134,7 +146,7 @@ export const createDocumentFromTemplate = createAction({
export const createNestedDocument = createAction({
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
@@ -151,7 +163,7 @@ export const createNestedDocument = createAction({
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeDocumentId, stores }) => {
@@ -177,7 +189,7 @@ export const starDocument = createAction({
export const unstarDocument = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeDocumentId, stores }) => {
@@ -203,7 +215,7 @@ export const unstarDocument = createAction({
export const publishDocument = createAction({
name: ({ t }) => t("Publish"),
analyticsName: "Publish document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <PublishIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -245,7 +257,7 @@ export const publishDocument = createAction({
export const unpublishDocument = createAction({
name: ({ t }) => t("Unpublish"),
analyticsName: "Unpublish document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <UnpublishIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -276,7 +288,7 @@ export const unpublishDocument = createAction({
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <SubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -304,7 +316,7 @@ export const subscribeDocument = createAction({
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <UnsubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -334,7 +346,7 @@ export const unsubscribeDocument = createAction({
export const shareDocument = createAction({
name: ({ t }) => `${t("Permissions")}`,
analyticsName: "Share document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <PadlockIcon />,
visible: ({ stores, activeDocumentId }) => {
const can = stores.policies.abilities(activeDocumentId!);
@@ -371,7 +383,7 @@ export const shareDocument = createAction({
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
analyticsName: "Download document as HTML",
section: DocumentSection,
section: ActiveDocumentSection,
keywords: "html export",
icon: <DownloadIcon />,
iconInContextMenu: false,
@@ -390,7 +402,7 @@ export const downloadDocumentAsHTML = createAction({
export const downloadDocumentAsPDF = createAction({
name: ({ t }) => t("PDF"),
analyticsName: "Download document as PDF",
section: DocumentSection,
section: ActiveDocumentSection,
keywords: "export",
icon: <DownloadIcon />,
iconInContextMenu: false,
@@ -414,7 +426,7 @@ export const downloadDocumentAsPDF = createAction({
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
analyticsName: "Download document as Markdown",
section: DocumentSection,
section: ActiveDocumentSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
iconInContextMenu: false,
@@ -434,9 +446,11 @@ export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
analyticsName: "Download document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <DownloadIcon />,
keywords: "export",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
children: [
downloadDocumentAsHTML,
downloadDocumentAsPDF,
@@ -446,8 +460,10 @@ export const downloadDocument = createAction({
export const copyDocumentAsMarkdown = createAction({
name: ({ t }) => t("Copy as Markdown"),
section: DocumentSection,
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <MarkdownIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
@@ -461,10 +477,33 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
keywords: "clipboard share",
icon: <GlobeIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId &&
!!stores.shares.getByDocumentId(activeDocumentId)?.published,
perform: ({ stores, activeDocumentId, t }) => {
if (!activeDocumentId) {
return;
}
const share = stores.shares.getByDocumentId(activeDocumentId);
if (share) {
copy(share.url);
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyDocumentLink = createAction({
name: ({ t }) => t("Copy link"),
section: DocumentSection,
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CopyIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
@@ -480,17 +519,17 @@ export const copyDocumentLink = createAction({
export const copyDocument = createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyDocumentLink, copyDocumentAsMarkdown],
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
analyticsName: "Duplicate document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <DuplicateIcon />,
keywords: "copy",
visible: ({ activeDocumentId, stores }) =>
@@ -534,7 +573,7 @@ export const pinDocumentToCollection = createAction({
});
},
analyticsName: "Pin document to collection",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
@@ -570,7 +609,7 @@ export const pinDocumentToCollection = createAction({
export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
analyticsName: "Pin document to home",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, currentTeamId, stores }) => {
@@ -602,7 +641,7 @@ export const pinDocumentToHome = createAction({
export const pinDocument = createAction({
name: ({ t }) => t("Pin"),
analyticsName: "Pin document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <PinIcon />,
children: [pinDocumentToCollection, pinDocumentToHome],
});
@@ -610,7 +649,7 @@ export const pinDocument = createAction({
export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -628,7 +667,7 @@ export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
analyticsName: "Print document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
@@ -687,7 +726,7 @@ export const importDocument = createAction({
export const createTemplateFromDocument = createAction({
name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
@@ -722,14 +761,14 @@ export const openRandomDocument = createAction({
section: DocumentSection,
icon: <ShuffleIcon />,
perform: ({ stores, activeDocumentId }) => {
const documentPaths = stores.collections.pathsToDocuments.filter(
(path) => path.type === "document" && path.id !== activeDocumentId
);
const randomPath =
documentPaths[Math.round(Math.random() * documentPaths.length)];
const nodes = stores.collections.navigationNodes
.reduce((acc, node) => [...acc, ...node.children], [] as NavigationNode[])
.filter((node) => node.id !== activeDocumentId);
if (randomPath) {
history.push(randomPath.url);
const random = nodes[Math.round(Math.random() * nodes.length)];
if (random) {
history.push(random.url);
}
},
});
@@ -787,7 +826,7 @@ export const moveDocumentToCollection = createAction({
: t("Move");
},
analyticsName: "Move document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
@@ -816,7 +855,7 @@ export const moveDocumentToCollection = createAction({
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -835,7 +874,7 @@ export const moveDocument = createAction({
export const moveTemplate = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -854,7 +893,7 @@ export const moveTemplate = createAction({
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -894,7 +933,7 @@ export const archiveDocument = createAction({
export const deleteDocument = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
@@ -928,7 +967,7 @@ export const deleteDocument = createAction({
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
analyticsName: "Permanently delete document",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <CrossIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
@@ -983,7 +1022,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
export const openDocumentComments = createAction({
name: ({ t }) => t("Comments"),
analyticsName: "Open comments",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <CommentIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1005,7 +1044,7 @@ export const openDocumentComments = createAction({
export const openDocumentHistory = createAction({
name: ({ t }) => t("History"),
analyticsName: "Open document history",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <HistoryIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1026,7 +1065,7 @@ export const openDocumentHistory = createAction({
export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <GraphIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1063,7 +1102,7 @@ export const toggleViewerInsights = createAction({
: t("Enable viewer insights");
},
analyticsName: "Toggle viewer insights",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <EyeIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1093,6 +1132,7 @@ export const rootDocumentActions = [
importDocument,
downloadDocument,
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
starDocument,
unstarDocument,
+6
View File
@@ -98,6 +98,11 @@ export function actionToKBar(
)
: [];
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? (action.section.priority as number) ?? 0
: 0;
return [
{
id: action.id,
@@ -108,6 +113,7 @@ export function actionToKBar(
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
perform: action.perform
? () => performAction(action, context)
: undefined,
+20
View File
@@ -2,10 +2,28 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
};
ActiveCollectionSection.priority = 0.8;
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
const activeDocument = stores.documents.active;
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
};
ActiveDocumentSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
@@ -21,4 +39,6 @@ export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
RecentSearchesSection.priority = -0.1;
export const TrashSection = ({ t }: ActionContext) => t("Trash");
+4 -12
View File
@@ -1,17 +1,14 @@
import { AnimatePresence } from "framer-motion";
import { observer, useLocalStore } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import DocumentContext from "~/components/DocumentContext";
import type { DocumentContextValue } from "~/components/DocumentContext";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import type { Editor as TEditor } from "~/editor";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -25,6 +22,7 @@ import {
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import { PortalContext } from "./Portal";
@@ -50,12 +48,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const documentContext = useLocalStore<DocumentContextValue>(() => ({
editor: null,
setEditor: (editor: TEditor) => {
documentContext.editor = editor;
},
}));
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
@@ -125,7 +117,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
);
return (
<DocumentContext.Provider value={documentContext}>
<DocumentContextProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout
title={team.name}
@@ -142,7 +134,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
</React.Suspense>
</Layout>
</PortalContext.Provider>
</DocumentContext.Provider>
</DocumentContextProvider>
);
};
+10 -13
View File
@@ -19,7 +19,6 @@ import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { EmptySelectValue } from "~/types";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -156,18 +155,16 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{team.sharing &&
(!collection ||
FeatureFlags.isEnabled(Feature.newCollectionSharing)) && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
{team.sharing && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
<Flex justify="flex-end">
<Button
+6 -3
View File
@@ -1,7 +1,7 @@
import { runInAction } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { CollectionForm, FormData } from "./CollectionForm";
@@ -17,8 +17,11 @@ export const CollectionNew = observer(function CollectionNew_({
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const collection = new Collection(data, collections);
await collection.save();
const collection = await collections.save(data);
// Avoid flash of loading state for the new collection, we know it's empty.
runInAction(() => {
collection.documents = [];
});
onSubmit?.();
history.push(collection.path);
} catch (error) {
@@ -6,20 +6,27 @@ import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsActions";
import useTemplateActions from "~/hooks/useTemplateActions";
import CommandBarResults from "./CommandBarResults";
import useRecentDocumentActions from "./useRecentDocumentActions";
import useSettingsAction from "./useSettingsAction";
import useTemplatesAction from "./useTemplatesAction";
function CommandBar() {
const { t } = useTranslation();
const settingsActions = useSettingsActions();
const templateActions = useTemplateActions();
const recentDocumentActions = useRecentDocumentActions();
const settingsAction = useSettingsAction();
const templatesAction = useTemplatesAction();
const commandBarActions = React.useMemo(
() => [...rootActions, templateActions, settingsActions],
[settingsActions, templateActions]
() => [
...recentDocumentActions,
...rootActions,
templatesAction,
settingsAction,
],
[recentDocumentActions, settingsAction, templatesAction]
);
useCommandBarActions(commandBarActions);
@@ -30,7 +37,9 @@ function CommandBar() {
<Positioner>
<Animator>
<SearchActions />
<SearchInput defaultPlaceholder={t("Type a command or search")} />
<SearchInput
defaultPlaceholder={`${t("Type a command or search")}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
@@ -60,12 +69,15 @@ const Positioner = styled(KBarPositioner)`
`;
const SearchInput = styled(KBarSearch)`
padding: 16px 20px;
width: 100%;
position: relative;
padding: 16px 12px;
margin: 0 8px;
width: calc(100% - 16px);
outline: none;
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
border-bottom: 1px solid ${s("inputBorder")};
&:disabled,
&::placeholder {
@@ -5,7 +5,7 @@ import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "./Text";
import Text from "~/components/Text";
type Props = {
action: ActionImpl;
@@ -1,8 +1,8 @@
import { useMatches, KBarResults } from "kbar";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import CommandBarItem from "~/components/CommandBarItem";
import Text from "~/components/Text";
import CommandBarItem from "./CommandBarItem";
export default function CommandBarResults() {
const { results, rootActionId } = useMatches();
@@ -14,7 +14,9 @@ export default function CommandBarResults() {
maxHeight={400}
onRender={({ item, active }) =>
typeof item === "string" ? (
<Header>{item}</Header>
<Header type="tertiary" size="xsmall" ellipsis>
{item}
</Header>
) : (
<CommandBarItem
action={item}
@@ -35,11 +37,10 @@ const Container = styled.div`
}
`;
const Header = styled.h3`
font-size: 13px;
letter-spacing: 0.04em;
const Header = styled(Text).attrs({ as: "h3" })`
letter-spacing: 0.03em;
margin: 0;
padding: 16px 0 4px 20px;
color: ${s("textTertiary")};
height: 36px;
cursor: default;
`;
+3
View File
@@ -0,0 +1,3 @@
import CommandBar from "./CommandBar";
export default CommandBar;
@@ -0,0 +1,35 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
const { documents, ui } = useStores();
return React.useMemo(
() =>
documents.recentlyViewed
.filter((document) => document.id !== ui.activeDocumentId)
.slice(0, count)
.map((item) =>
createAction({
name: item.titleWithDefault,
analyticsName: "Recently viewed document",
section: RecentSection,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
perform: () => history.push(documentPath(item)),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed]
);
};
export default useRecentDocumentActions;
@@ -2,10 +2,10 @@ import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import history from "~/utils/history";
import useSettingsConfig from "./useSettingsConfig";
const useSettingsActions = () => {
const useSettingsAction = () => {
const config = useSettingsConfig();
const actions = React.useMemo(
() =>
@@ -38,4 +38,4 @@ const useSettingsActions = () => {
return navigateToSettings;
};
export default useSettingsActions;
export default useSettingsAction;
@@ -3,11 +3,11 @@ import * as React from "react";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
import useStores from "./useStores";
const useTemplatesActions = () => {
const useTemplatesAction = () => {
const { documents } = useStores();
React.useEffect(() => {
@@ -60,4 +60,4 @@ const useTemplatesActions = () => {
return newFromTemplate;
};
export default useTemplatesActions;
export default useTemplatesAction;
+62
View File
@@ -0,0 +1,62 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
type Props = {
/** The navigation node to move, must represent a document. */
item: NavigationNode;
/** The collection to move the document to. */
collection: Collection;
/** The parent document to move the document under. */
parentDocumentId?: string | null;
/** The index to move the document to. */
index?: number | null;
};
function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
const { documents, dialogs, collections } = useStores();
const { t } = useTranslation();
const prevCollection = collections.get(item.collectionId!);
const accessMapping = {
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
null: t("no access"),
};
const handleSubmit = async () => {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
dialogs.closeAllModals();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Move document")}
savingText={`${t("Moving")}`}
>
<Trans
defaults="Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>."
values={{
title: item.title,
prevCollectionName: prevCollection?.name,
newCollectionName: collection.name,
prevPermission: accessMapping[prevCollection?.permission || "null"],
newPermission: accessMapping[collection.permission || "null"],
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(ConfirmMoveDialog);
@@ -49,7 +49,7 @@ const DefaultCollectionInputSelect = ({
const options = React.useMemo(
() =>
collections.publicCollections.reduce(
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
@@ -78,7 +78,7 @@ const DefaultCollectionInputSelect = ({
},
]
),
[collections.publicCollections, t]
[collections.nonPrivate, t]
);
if (fetching) {
-37
View File
@@ -1,37 +0,0 @@
import * as React from "react";
import { Editor } from "~/editor";
import useIdle from "~/hooks/useIdle";
export type DocumentContextValue = {
/** The current editor instance for this document. */
editor: Editor | null;
/** Set the current editor instance for this document. */
setEditor: (editor: Editor) => void;
};
const DocumentContext = React.createContext<DocumentContextValue>({
editor: null,
// eslint-disable-next-line @typescript-eslint/no-empty-function
setEditor() {},
});
export const useDocumentContext = () => React.useContext(DocumentContext);
const activityEvents = [
"click",
"mousemove",
"DOMMouseScroll",
"mousewheel",
"mousedown",
"touchstart",
"touchmove",
"focus",
];
export const useEditingFocus = () => {
const { editor } = useDocumentContext();
const isIdle = useIdle(3000, activityEvents);
return isIdle && !!editor?.view.hasFocus();
};
export default DocumentContext;
+76
View File
@@ -0,0 +1,76 @@
import { action, computed, observable } from "mobx";
import React, { PropsWithChildren } from "react";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import Document from "~/models/Document";
import { Editor } from "~/editor";
class DocumentContext {
/** The current document */
document?: Document;
/** The editor instance for this document */
editor?: Editor;
@observable
headings: Heading[] = [];
@computed
get hasHeadings() {
return this.headings.length > 0;
}
@action
setDocument = (document: Document) => {
this.document = document;
this.updateState();
};
@action
setEditor = (editor: Editor) => {
this.editor = editor;
this.updateState();
};
@action
updateState = () => {
this.updateHeadings();
this.updateTasks();
};
private updateHeadings() {
const currHeadings = this.editor?.getHeadings() ?? [];
const hasChanged =
currHeadings.map((h) => h.level + h.title).join("") !==
this.headings.map((h) => h.level + h.title).join("");
if (hasChanged) {
this.headings = currHeadings;
}
}
private updateTasks() {
const tasks = this.editor?.getTasks() ?? [];
const total = tasks.length ?? 0;
const completed = tasks.filter((t) => t.completed).length ?? 0;
this.document?.updateTasks(total, completed);
}
}
const Context = React.createContext<DocumentContext | null>(null);
export const useDocumentContext = () => {
const ctx = React.useContext(Context);
if (!ctx) {
throw new Error(
"useDocumentContext must be used within DocumentContextProvider"
);
}
return ctx;
};
export const DocumentContextProvider = ({
children,
}: PropsWithChildren<unknown>) => {
const context = React.useMemo(() => new DocumentContext(), []);
return <Context.Provider value={context}>{children}</Context.Provider>;
};
+1 -1
View File
@@ -11,7 +11,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { NavigationNode } from "@shared/types";
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
+1 -1
View File
@@ -140,7 +140,7 @@ const DocumentMeta: React.FC<Props> = ({
}
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
+4 -30
View File
@@ -9,7 +9,6 @@ import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { AttachmentPreset } from "@shared/types";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
@@ -43,21 +42,14 @@ export type Props = Optional<
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => void;
editorStyle?: React.CSSProperties;
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const {
id,
shareId,
onChange,
onHeadingsChange,
onCreateCommentMark,
onDeleteCommentMark,
} = props;
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const { comments, documents } = useStores();
@@ -65,7 +57,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousHeadings = React.useRef<Heading[] | null>(null);
const previousCommentIds = React.useRef<string[]>();
const handleSearchLink = React.useCallback(
@@ -212,21 +203,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[]
);
// Calculate if headings have changed and trigger callback if so
const updateHeadings = React.useCallback(() => {
if (onHeadingsChange) {
const headings = localRef?.current?.getHeadings();
if (
headings &&
headings.map((h) => h.level + h.title).join("") !==
previousHeadings.current?.map((h) => h.level + h.title).join("")
) {
previousHeadings.current = headings;
onHeadingsChange(headings);
}
}
}, [localRef, onHeadingsChange]);
const updateComments = React.useCallback(() => {
if (onCreateCommentMark && onDeleteCommentMark && localRef.current) {
const commentMarks = localRef.current.getComments();
@@ -261,20 +237,18 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const handleChange = React.useCallback(
(event) => {
onChange?.(event);
updateHeadings();
updateComments();
},
[onChange, updateComments, updateHeadings]
[onChange, updateComments]
);
const handleRefChanged = React.useCallback(
(node: SharedEditor | null) => {
if (node) {
updateHeadings();
updateComments();
}
},
[updateComments, updateHeadings]
[updateComments]
);
return (
+1 -7
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { getLuminance } from "polished";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
@@ -118,12 +117,7 @@ export const IconTitleWrapper = styled(Flex)<{ dir?: string }>`
z-index: 1;
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
${breakpoint("desktop")`
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
`}
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
`;
export default Icon;
+1 -1
View File
@@ -4,7 +4,7 @@ import {
} from "@getoutline/react-roving-tabindex";
import { LocationDescriptor } from "history";
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
+1 -1
View File
@@ -10,7 +10,7 @@ export default function SearchActions() {
const { searches } = useStores();
React.useEffect(() => {
if (!searches.isLoaded) {
if (!searches.isLoaded && !searches.isFetching) {
void searches.fetchPage({
source: "app",
});
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { Pagination } from "@shared/constants";
import { s } from "@shared/styles";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection";
@@ -67,7 +68,7 @@ export const AccessControlList = observer(
const containerRef = React.useRef<HTMLDivElement | null>(null);
const { maxHeight, calcMaxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
maxViewportPercentage: 65,
margin: 24,
});
@@ -201,7 +202,7 @@ export const AccessControlList = observer(
</>
)}
{team.sharing && can.share && !collectionSharingDisabled && visible && (
<>
<Sticky>
{document.members.length ? <Separator /> : null}
<PublicAccess
document={document}
@@ -209,7 +210,7 @@ export const AccessControlList = observer(
sharedParent={sharedParent}
onRequestClose={onRequestClose}
/>
</>
</Sticky>
)}
</ScrollableContainer>
);
@@ -274,6 +275,12 @@ function useUsersInCollection(collection?: Collection) {
: false;
}
const Sticky = styled.div`
background: ${s("menuBackground")};
position: sticky;
bottom: -12px;
`;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
@@ -203,7 +203,7 @@ const StyledInfoIcon = styled(InfoIcon)`
`;
const Wrapper = styled.div`
margin-bottom: 8px;
padding-bottom: 8px;
`;
const DomainPrefix = styled.span`
+1 -1
View File
@@ -15,7 +15,7 @@ export const Wrapper = styled.div`
export const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 12px 0;
margin: 8px 0;
`;
export const HeaderInput = styled(Flex)`
+3 -1
View File
@@ -116,7 +116,9 @@ function AppSidebar() {
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts}
{documents.totalDrafts > 25
? "25+"
: documents.totalDrafts}
</Drafts>
) : null}
</Flex>
+17 -2
View File
@@ -8,6 +8,7 @@ import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMenuContext from "~/hooks/useMenuContext";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import AccountMenu from "~/menus/AccountMenu";
@@ -39,6 +40,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobile = useMobile();
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;
@@ -189,6 +191,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
$isMobile={isMobile}
className={className}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
@@ -256,6 +259,7 @@ type ContainerProps = {
$isHovering: boolean;
$collapsed: boolean;
$hidden: boolean;
$isMobile: boolean;
};
const hoverStyles = (props: ContainerProps) => `
@@ -298,8 +302,19 @@ const Container = styled(Flex)<ContainerProps>`
& > div {
transition: opacity 150ms ease-in-out;
opacity: ${(props) =>
props.$hidden || (props.$collapsed && !props.$isHovering) ? "0" : "1"};
opacity: ${(props) => {
if (props.$hidden) {
return "0";
}
if (props.$isHovering) {
return "1";
}
if (props.$isMobile) {
return props.$mobileSidebarVisible ? "1" : "0";
} else {
return props.$collapsed ? "0" : "1";
}
}};
}
${breakpoint("tablet")`
@@ -8,7 +8,7 @@ import { useHistory } from "react-router-dom";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import DocumentReparent from "~/scenes/DocumentReparent";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
@@ -78,20 +78,12 @@ const CollectionLink: React.FC<Props> = ({
if (
prevCollection &&
prevCollection.permission === null &&
prevCollection.permission !== collection.permission &&
!document?.isDraft
) {
dialogs.openModal({
title: t("Move document"),
content: (
<DocumentReparent
item={item}
collection={collection}
onSubmit={dialogs.closeAllModals}
onCancel={dialogs.closeAllModals}
/>
),
title: t("Change permissions?"),
content: <ConfirmMoveDialog item={item} collection={collection} />,
});
} else {
await documents.move({ documentId: id, collectionId: collection.id });
@@ -6,21 +6,26 @@ import { toast } from "sonner";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import DocumentsLoader from "~/components/DocumentsLoader";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Text from "~/components/Text";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import useCollectionDocuments from "../hooks/useCollectionDocuments";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder";
import Folder from "./Folder";
import PlaceholderCollections from "./PlaceholderCollections";
import { DragObject } from "./SidebarLink";
import useCollectionDocuments from "./useCollectionDocuments";
import SidebarLink, { DragObject } from "./SidebarLink";
type Props = {
/** The collection to render the children of. */
collection: Collection;
/** Whether the children are shown in an expanded state. */
expanded: boolean;
/** Function to prefetch a document by ID. */
prefetchDocument?: (documentId: string) => Promise<Document | void>;
};
@@ -31,9 +36,8 @@ function CollectionLinkChildren({
}: Props) {
const can = usePolicy(collection);
const manualSort = collection.sort.field === "index";
const { documents } = useStores();
const { documents, dialogs, collections } = useStores();
const { t } = useTranslation();
const childDocuments = useCollectionDocuments(collection, documents.active);
// Drop to reorder document
@@ -52,11 +56,26 @@ function CollectionLinkChildren({
if (!collection) {
return;
}
void documents.move({
documentId: item.id,
collectionId: collection.id,
index: 0,
});
const prevCollection = collections.get(item.collectionId);
if (
prevCollection &&
prevCollection.permission !== collection.permission
) {
dialogs.openModal({
title: t("Change permissions?"),
content: (
<ConfirmMoveDialog item={item} collection={collection} index={0} />
),
});
} else {
void documents.move({
documentId: item.id,
collectionId: collection.id,
index: 0,
});
}
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
@@ -91,7 +110,17 @@ function CollectionLinkChildren({
index={index}
/>
))}
{childDocuments?.length === 0 && <EmptyCollectionPlaceholder />}
{childDocuments?.length === 0 && (
<SidebarLink
label={
<Text type="tertiary" size="small" italic>
{t("Empty")}
</Text>
}
onClick={() => history.push(collection.url)}
depth={2}
/>
)}
</DocumentsLoader>
</Folder>
);
@@ -19,6 +19,11 @@ import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { newNestedDocumentPath } from "~/utils/routeHelpers";
import {
useDragDocument,
useDropToReorderDocument,
useDropToReparentDocument,
} from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
@@ -26,11 +31,6 @@ import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import {
useDragDocument,
useDropToReorderDocument,
useDropToReparentDocument,
} from "./useDragAndDrop";
type Props = {
node: NavigationNode;
@@ -1,22 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "~/components/Text";
const EmptyCollectionPlaceholder = () => {
const { t } = useTranslation();
return (
<Empty type="tertiary" size="small">
{t("Empty")}
</Empty>
);
};
const Empty = styled(Text)`
margin-left: 46px;
margin-bottom: 0;
line-height: 34px;
font-style: italic;
`;
export default EmptyCollectionPlaceholder;
@@ -10,7 +10,7 @@ import {
match,
} from "react-router";
import { Link } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import scrollIntoView from "scroll-into-view-if-needed";
import history from "~/utils/history";
const resolveToLocation = (
@@ -11,6 +11,7 @@ import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
import useStores from "~/hooks/useStores";
import { useDropToReorderUserMembership } from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import GroupLink from "./GroupLink";
import Header from "./Header";
@@ -19,7 +20,6 @@ import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useDropToReorderUserMembership } from "./useDragAndDrop";
function SharedWithMe() {
const { userMemberships, groupMemberships } = useStores();
@@ -11,19 +11,19 @@ import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import {
useDragMembership,
useDropToReorderUserMembership,
useDropToReparentDocument,
} from "../hooks/useDragAndDrop";
import { useLocationState } from "../hooks/useLocationState";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import {
useDragMembership,
useDropToReorderUserMembership,
useDropToReparentDocument,
} from "./useDragAndDrop";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
type Props = {
membership: UserMembership | GroupMembership;
@@ -7,6 +7,10 @@ import DelayedMount from "~/components/DelayedMount";
import Flex from "~/components/Flex";
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
import useStores from "~/hooks/useStores";
import {
useDropToCreateStar,
useDropToReorderStar,
} from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
@@ -14,7 +18,6 @@ import Relative from "./Relative";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import StarredLink from "./StarredLink";
import { useDropToCreateStar, useDropToReorderStar } from "./useDragAndDrop";
const STARRED_PAGINATION_LIMIT = 10;
@@ -10,7 +10,13 @@ import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import {
useDragStar,
useDropToCreateStar,
useDropToReorderStar,
} from "../hooks/useDragAndDrop";
import { useLocationState } from "../hooks/useLocationState";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DocumentLink from "./DocumentLink";
@@ -22,12 +28,6 @@ import SidebarContext, {
useSidebarContext,
} from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import {
useDragStar,
useDropToCreateStar,
useDropToReorderStar,
} from "./useDragAndDrop";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
type Props = {
star: Star;
@@ -116,7 +116,7 @@ function StarredLink({ star }: Props) {
? collections.get(document.collectionId)
: undefined;
const childDocuments = collection
? collection.getDocumentChildren(documentId)
? collection.getChildrenForDocument(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
@@ -12,10 +12,11 @@ import Document from "~/models/Document";
import GroupMembership from "~/models/GroupMembership";
import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { DragObject } from "./SidebarLink";
import { DragObject } from "../components/SidebarLink";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
/**
@@ -172,7 +173,8 @@ export function useDropToReparentDocument(
setExpanded: () => void,
parentRef: React.RefObject<HTMLDivElement>
) {
const { documents, policies } = useStores();
const { t } = useTranslation();
const { documents, collections, dialogs, policies } = useStores();
const hasChildDocuments = !!node?.children.length;
const document = node ? documents.get(node.id) : undefined;
const pathToNode = React.useMemo(
@@ -192,10 +194,11 @@ export function useDropToReparentDocument(
}
};
parentRef.current?.addEventListener("dragleave", resetHoverExpanding);
const element = parentRef.current;
element?.addEventListener("dragleave", resetHoverExpanding);
return () => {
parentRef.current?.removeEventListener("dragleave", resetHoverExpanding);
element?.removeEventListener("dragleave", resetHoverExpanding);
};
}, [parentRef]);
@@ -209,10 +212,32 @@ export function useDropToReparentDocument(
if (monitor.didDrop() || !node) {
return;
}
await documents.move({
documentId: item.id,
parentDocumentId: node.id,
});
const collection = documents.get(node.id)?.collection;
const prevCollection = collections.get(item.collectionId);
if (
collection &&
prevCollection &&
prevCollection.permission !== collection.permission
) {
dialogs.openModal({
title: t("Change permissions?"),
content: (
<ConfirmMoveDialog
item={item}
collection={collection}
parentDocumentId={node.id}
/>
),
});
} else {
await documents.move({
documentId: item.id,
parentDocumentId: node.id,
});
}
setExpanded();
},
canDrop: (item, monitor) =>
@@ -270,7 +295,7 @@ export function useDropToReorderDocument(
}
) {
const { t } = useTranslation();
const { documents, policies } = useStores();
const { documents, collections, dialogs, policies } = useStores();
return useDrop<
DragObject,
@@ -279,11 +304,19 @@ export function useDropToReorderDocument(
>({
accept: "document",
canDrop: (item: DragObject) => {
if (item.id === node.id) {
if (item.id === node.id || !policies.abilities(item.id)?.move) {
return false;
}
return policies.abilities(item.id)?.move;
const params = getMoveParams(item);
if (params?.collectionId) {
return policies.abilities(params.collectionId)?.updateDocument;
}
if (params?.parentDocumentId) {
return policies.abilities(params.parentDocumentId)?.update;
}
return true;
},
drop: async (item) => {
if (!collection?.isManualSort && item.collectionId === collection?.id) {
@@ -296,8 +329,28 @@ export function useDropToReorderDocument(
}
const params = getMoveParams(item);
if (params) {
void documents.move(params);
const prevCollection = collections.get(item.collectionId);
if (
collection &&
prevCollection &&
prevCollection.permission !== collection.permission
) {
dialogs.openModal({
title: t("Change permissions?"),
content: (
<ConfirmMoveDialog
item={item}
collection={collection}
{...params}
/>
),
});
} else {
void documents.move(params);
}
}
},
collect: (monitor) => ({
+1 -1
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import scrollIntoView from "scroll-into-view-if-needed";
import useQuery from "~/hooks/useQuery";
import lazyWithRetry from "~/utils/lazyWithRetry";
import type { Props } from "./Table";
+8
View File
@@ -12,8 +12,12 @@ type Props = {
selectable?: boolean;
/** The font weight of the text */
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
/** Whether the text should be monospaced */
monospace?: boolean;
};
/**
@@ -56,6 +60,10 @@ const Text = styled.span<Props>`
: "inherit"};
`}
font-style: ${(props) => (props.italic ? "italic" : "normal")};
font-family: ${(props) =>
props.monospace ? props.theme.fontFamilyMono : "inherit"};
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
+2 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import { s, ellipsis } from "@shared/styles";
@@ -22,7 +22,7 @@ function LinkSearchResult({
const ref = React.useCallback(
(node: HTMLElement | null) => {
if (selected && node) {
void scrollIntoView(node, {
scrollIntoView(node, {
scrollMode: "if-needed",
block: "center",
boundary: (parent) =>
@@ -1,5 +1,5 @@
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import MenuItem from "~/components/ContextMenu/MenuItem";
import { usePortalContext } from "~/components/Portal";
@@ -31,7 +31,7 @@ function SuggestionsMenuItem({
const ref = React.useCallback(
(node) => {
if (selected && node) {
void scrollIntoView(node, {
scrollIntoView(node, {
scrollMode: "if-needed",
block: "nearest",
boundary: (parent) =>
+2 -2
View File
@@ -4,7 +4,7 @@ import { Node } from "prosemirror-model";
import { Command, Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import scrollIntoView from "scroll-into-view-if-needed";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import FindAndReplace from "../components/FindAndReplace";
@@ -184,7 +184,7 @@ export default class FindAndReplaceExtension extends Extension {
`.${this.options.resultCurrentClassName}`
);
if (element) {
void scrollIntoView(element, {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
+9
View File
@@ -5,6 +5,7 @@ import {
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
HorizontalRuleIcon,
OrderedListIcon,
PageBreakIcon,
@@ -63,6 +64,14 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
shortcut: "^ ⇧ 3",
attrs: { level: 3 },
},
{
name: "heading",
title: dictionary.h4,
keywords: "h4 heading4",
icon: <Heading4Icon />,
shortcut: "^ ⇧ 4",
attrs: { level: 4 },
},
{
name: "separator",
},
+1
View File
@@ -43,6 +43,7 @@ export default function useDictionary() {
h1: t("Big heading"),
h2: t("Medium heading"),
h3: t("Small heading"),
h4: t("Extra small heading"),
heading: t("Heading"),
hr: t("Divider"),
image: t("Image"),
+19
View File
@@ -0,0 +1,19 @@
import { useDocumentContext } from "~/components/DocumentContext";
import useIdle from "./useIdle";
const activityEvents = [
"click",
"mousemove",
"DOMMouseScroll",
"mousewheel",
"mousedown",
"touchstart",
"touchmove",
"focus",
];
export default function useEditingFocus() {
const { editor } = useDocumentContext();
const isIdle = useIdle(3000, activityEvents);
return isIdle && !!editor?.view.hasFocus();
}
+12 -5
View File
@@ -3,7 +3,7 @@ import { getCookie, removeCookie, setCookie } from "tiny-cookie";
import usePersistedState from "~/hooks/usePersistedState";
import Logger from "~/utils/Logger";
import history from "~/utils/history";
import { isValidPostLoginRedirect } from "~/utils/urls";
import { isAllowedLoginRedirect } from "~/utils/urls";
/**
* Hook to set locally and return the document or collection that the user last visited. This is
@@ -20,7 +20,9 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
const setPathAsLastVisitedPath = React.useCallback(
(path: string) => {
path !== lastVisitedPath && setLastVisitedPath(path);
if (isAllowedLoginRedirect(path) && path !== lastVisitedPath) {
setLastVisitedPath(path);
}
},
[lastVisitedPath, setLastVisitedPath]
);
@@ -36,7 +38,7 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
export function setPostLoginPath(path: string) {
const key = "postLoginRedirectPath";
if (isValidPostLoginRedirect(path)) {
if (isAllowedLoginRedirect(path)) {
setCookie(key, path, { expires: 1 });
try {
@@ -61,7 +63,7 @@ export function usePostLoginPath() {
try {
path = sessionStorage.getItem(key) || getCookie(key);
} catch (e) {
// If the session storage is inaccessible, we can't do anything about it.
// Expected error if the session storage is full or inaccessible.
}
if (path) {
@@ -70,11 +72,16 @@ export function usePostLoginPath() {
// Remove the cookie once the app has been navigated to the post login path. We dont
// do this immediately as React StrictMode will render multiple times.
const cleanup = history.listen(() => {
try {
sessionStorage.removeItem(key);
} catch (e) {
// Expected error if the session storage is full or inaccessible.
}
removeCookie(key);
cleanup?.();
});
if (isValidPostLoginRedirect(path)) {
if (isAllowedLoginRedirect(path)) {
return path;
}
}
+26 -9
View File
@@ -4,6 +4,7 @@ import {
ImportIcon,
ExportIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
ManualSortIcon,
InputIcon,
} from "outline-icons";
@@ -127,12 +128,12 @@ function CollectionMenu({
);
const handleChangeSort = React.useCallback(
(field: string) => {
(field: string, direction = "asc") => {
menu.hide();
return collection.save({
sort: {
field,
direction: "asc",
direction,
},
});
},
@@ -144,7 +145,8 @@ function CollectionMenu({
activeCollectionId: collection.id,
});
const alphabeticalSort = collection.sort.field === "title";
const sortAlphabetical = collection.sort.field === "title";
const sortDir = collection.sort.direction;
const can = usePolicy(collection);
const canUserInTeam = usePolicy(team);
const items: MenuItem[] = React.useMemo(
@@ -185,19 +187,33 @@ function CollectionMenu({
type: "submenu",
title: t("Sort in sidebar"),
visible: can.update,
icon: alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />,
icon: sortAlphabetical ? (
sortDir === "asc" ? (
<AlphabeticalSortIcon />
) : (
<AlphabeticalReverseSortIcon />
)
) : (
<ManualSortIcon />
),
items: [
{
type: "button",
title: t("Alphabetical sort"),
onClick: () => handleChangeSort("title"),
selected: alphabeticalSort,
title: t("A-Z sort"),
onClick: () => handleChangeSort("title", "asc"),
selected: sortAlphabetical && sortDir === "asc",
},
{
type: "button",
title: t("Z-A sort"),
onClick: () => handleChangeSort("title", "desc"),
selected: sortAlphabetical && sortDir === "desc",
},
{
type: "button",
title: t("Manual sort"),
onClick: () => handleChangeSort("index"),
selected: !alphabeticalSort,
selected: !sortAlphabetical,
},
],
},
@@ -216,6 +232,7 @@ function CollectionMenu({
],
[
t,
onRename,
collection,
can.createDocument,
can.update,
@@ -223,7 +240,7 @@ function CollectionMenu({
handleNewDocument,
handleImportDocument,
context,
alphabeticalSort,
sortAlphabetical,
canUserInTeam.createExport,
handleExport,
handleChangeSort,
+3 -9
View File
@@ -6,17 +6,11 @@ import { MenuButton, useMenuState } from "reakit/Menu";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { useDocumentContext } from "~/components/DocumentContext";
import { MenuItem } from "~/types";
type Props = {
headings: {
title: string;
level: number;
id: string;
}[];
};
function TableOfContentsMenu({ headings }: Props) {
function TableOfContentsMenu() {
const { headings } = useDocumentContext();
const menu = useMenuState({
modal: true,
unstable_preventOverflow: true,
+19 -19
View File
@@ -1,44 +1,44 @@
import { isPast } from "date-fns";
import { computed, observable } from "mobx";
import Model from "./base/Model";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
class ApiKey extends Model {
class ApiKey extends ParanoidModel {
static modelName = "ApiKey";
@Field
@observable
id: string;
/**
* The user chosen name of the API key.
*/
/** The user chosen name of the API key. */
@Field
@observable
name: string;
/**
* An optional datetime that the API key expires.
*/
/** An optional datetime that the API key expires. */
@Field
@observable
expiresAt?: string;
/**
* An optional datetime that the API key was last used at.
*/
/** An optional datetime that the API key was last used at. */
@observable
lastActiveAt?: string;
secret: string;
/** The plain text value of the API key, only available on creation. */
value: string;
/**
* Whether the API key has an expiry in the past.
*/
/** A preview of the last 4 characters of the API key. */
last4: string;
/** Whether the API key has an expiry in the past. */
@computed
get isExpired() {
return this.expiresAt ? isPast(new Date(this.expiresAt)) : false;
}
@computed
get obfuscatedValue() {
if (this.createdAt < new Date("2022-12-03").toISOString()) {
return `...${this.last4}`;
}
return `ol...${this.last4}`;
}
}
export default ApiKey;
+37 -59
View File
@@ -1,9 +1,10 @@
import invariant from "invariant";
import { action, computed, observable, reaction, runInAction } from "mobx";
import { action, computed, observable, runInAction } from "mobx";
import {
CollectionPermission,
FileOperationFormat,
NavigationNode,
type NavigationNode,
NavigationNodeType,
type ProsemirrorData,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
@@ -21,43 +22,27 @@ export default class Collection extends ParanoidModel {
store: CollectionsStore;
@observable
isSaving: boolean;
isFetching = false;
@Field
@observable
id: string;
/**
* The name of the collection.
*/
/** The name of the collection. */
@Field
@observable
name: string;
/** Collection description in Prosemirror format. */
@Field
@observable.shallow
data: ProsemirrorData;
/**
* An icon (or) emoji to use as the collection icon.
*/
/** An icon (or) emoji to use as the collection icon. */
@Field
@observable
icon: string;
/**
* The color to use for the collection icon and other highlights.
*/
/** The color to use for the collection icon and other highlights. */
@Field
@observable
color?: string | null;
/**
* The default permission for workspace users.
*/
/** The default permission for workspace users. */
@Field
@observable
permission?: CollectionPermission;
@@ -70,16 +55,12 @@ export default class Collection extends ParanoidModel {
@observable
sharing: boolean;
/**
* The sort index for the collection.
*/
/** The sort index for the collection. */
@Field
@observable
index: string;
/**
* The sort field and direction for documents in the collection.
*/
/** The sort field and direction for documents in the collection. */
@Field
@observable
sort: {
@@ -87,33 +68,19 @@ export default class Collection extends ParanoidModel {
direction: "asc" | "desc";
};
/** The child documents of the collection. */
@observable
documents?: NavigationNode[];
/**
* @deprecated Use path instead.
*/
/** @deprecated Use path instead. */
@observable
url: string;
/** The ID that appears in the collection slug. */
@observable
urlId: string;
constructor(fields: Partial<Collection>, store: CollectionsStore) {
super(fields, store);
const resetDocumentPolicies = () => {
this.store.rootStore.documents
.inCollection(this.id)
.forEach((document) => {
this.store.rootStore.policies.remove(document.id);
});
};
reaction(() => this.permission, resetDocumentPolicies);
reaction(() => this.sharing, resetDocumentPolicies);
}
/** Returns whether the collection is empty, or undefined if not loaded. */
@computed
get isEmpty(): boolean | undefined {
if (!this.documents) {
@@ -137,11 +104,7 @@ export default class Collection extends ParanoidModel {
return !this.permission;
}
/**
* Check whether this collection has a description.
*
* @returns boolean
*/
/** Returns whether the collection description is not empty. */
@computed
get hasDescription(): boolean {
return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false;
@@ -167,11 +130,7 @@ export default class Collection extends ParanoidModel {
return sortNavigationNodes(this.documents, this.sort);
}
/**
* The initial letter of the collection name.
*
* @returns string
*/
/** The initial letter of the collection name as a string. */
@computed
get initial() {
return (this.name ? this.name[0] : "?").toUpperCase();
@@ -277,7 +236,7 @@ export default class Collection extends ParanoidModel {
this.index = index;
}
getDocumentChildren(documentId: string) {
getChildrenForDocument(documentId: string) {
let result: NavigationNode[] = [];
const travelNodes = (nodes: NavigationNode[]) => {
@@ -298,6 +257,19 @@ export default class Collection extends ParanoidModel {
return result;
}
@computed
get asNavigationNode(): NavigationNode {
return {
type: NavigationNodeType.Collection,
id: this.id,
title: this.name,
color: this.color ?? undefined,
icon: this.icon ?? undefined,
children: this.documents ?? [],
url: this.url,
};
}
pathToDocument(documentId: string) {
let path: NavigationNode[] | undefined = [];
const document = this.store.rootStore.documents.get(documentId);
@@ -356,7 +328,11 @@ export default class Collection extends ParanoidModel {
model: Collection,
previousAttributes: Partial<Collection>
) {
if (previousAttributes && model.sharing !== previousAttributes?.sharing) {
if (
previousAttributes &&
(model.sharing !== previousAttributes?.sharing ||
model.permission !== previousAttributes?.permission)
) {
const { documents, policies } = model.store.rootStore;
documents.inCollection(model.id).forEach((document) => {
@@ -364,4 +340,6 @@ export default class Collection extends ParanoidModel {
});
}
}
private isFetching = false;
}
+2
View File
@@ -14,6 +14,7 @@ import type {
import {
ExportContentType,
FileOperationFormat,
NavigationNodeType,
NotificationEventType,
} from "@shared/types";
import Storage from "@shared/utils/Storage";
@@ -619,6 +620,7 @@ export default class Document extends ParanoidModel {
@computed
get asNavigationNode(): NavigationNode {
return {
type: NavigationNodeType.Document,
id: this.id,
title: this.title,
color: this.color ?? undefined,
+11 -1
View File
@@ -1,5 +1,7 @@
import isEqual from "lodash/isEqual";
import { computed, observable } from "mobx";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterChange } from "./decorators/Lifecycle";
class Policy extends Model {
@@ -9,6 +11,7 @@ class Policy extends Model {
* An object containing keys representing abilities and values that are either
* a boolean or an array of membership IDs that have provided access to the ability.
*/
@Field
@observable
abilities: Record<string, boolean | string[]>;
@@ -30,9 +33,16 @@ class Policy extends Model {
}
@AfterChange
public static removeChildPolicies(model: Policy) {
public static removeChildPolicies(
model: Policy,
previousAttributes: Partial<Policy>
) {
const { documents, collections, policies } = model.store.rootStore;
if (isEqual(model.abilities, previousAttributes.abilities)) {
return;
}
const collection = collections.get(model.id);
if (collection) {
documents.inCollection(collection.id).forEach((i) => {
+5 -1
View File
@@ -141,6 +141,10 @@ export default abstract class Model {
for (const key in data) {
try {
// Some models are serialized with the initialized flag, this should be ignored.
if (key === "initialized") {
continue;
}
this[key] = data[key];
} catch (error) {
Logger.warn(`Error setting ${key} on model`, error);
@@ -150,7 +154,7 @@ export default abstract class Model {
this.isNew = false;
this.persistedAttributes = this.toAPI();
if (!this.initialized) {
if (this.initialized) {
LifecycleManager.executeHooks(
this.constructor,
"afterChange",
+5 -1
View File
@@ -71,7 +71,11 @@ function ApiKeyNew({ onSubmit }: Props) {
name,
expiresAt: expiresAt?.toISOString(),
});
toast.success(t("API key created"));
toast.success(
t(
"API key created. Please copy the value now as it will not be shown again."
)
);
onSubmit();
} catch (err) {
toast.error(err.message);
+22 -56
View File
@@ -1,81 +1,47 @@
import { observer } from "mobx-react";
import { NewDocumentIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Trans } from "react-i18next";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {
/** The collection to display the empty state for. */
collection: Collection;
};
function EmptyCollection({ collection }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const context = useActionContext();
const collectionName = collection ? collection.name : "";
return (
<Centered column>
<Text as="p" type="secondary">
<Trans
defaults="<em>{{ collectionName }}</em> doesnt contain any
<Fade>
<Centered column>
<Text as="p" type="secondary">
<Trans
defaults="<em>{{ collectionName }}</em> doesnt contain any
documents yet."
values={{
collectionName,
}}
components={{
em: <strong />,
}}
/>
<br />
{can.createDocument && (
<Trans>Get started by creating a new one!</Trans>
)}
</Text>
{can.createDocument && (
<Empty>
<Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon />} neutral>
{t("Create a document")}
</Button>
</Link>
{FeatureFlags.isEnabled(Feature.newCollectionSharing) ? null : (
<Button
action={editCollectionPermissions}
context={context}
hideOnActionDisabled
neutral
>
{t("Manage permissions")}
</Button>
)}
</Empty>
)}
</Centered>
values={{
collectionName,
}}
components={{
em: <strong />,
}}
/>
</Text>
</Centered>
</Fade>
);
}
const Centered = styled(Flex)`
text-align: center;
margin: 40vh auto 0;
max-width: 380px;
transform: translateY(-50%);
`;
const Empty = styled(Flex)`
align-items: center;
justify-content: center;
margin: 10px 0;
gap: 8px;
margin: 0 auto;
max-width: 380px;
height: 50vh;
`;
export default observer(EmptyCollection);
@@ -8,11 +8,9 @@ import { Avatar, AvatarSize } from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
type Props = {
collection: Collection;
@@ -71,11 +69,6 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
return (
<NudeButton
context={context}
action={
FeatureFlags.isEnabled(Feature.newCollectionSharing)
? undefined
: editCollectionPermissions
}
tooltip={{
content:
usersCount > 0
+117 -139
View File
@@ -17,7 +17,6 @@ import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Search from "~/scenes/Search";
import { Action } from "~/components/Actions";
import Badge from "~/components/Badge";
import CenteredContent from "~/components/CenteredContent";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
@@ -31,14 +30,12 @@ import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import Tooltip from "~/components/Tooltip";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
@@ -154,8 +151,7 @@ function CollectionScene() {
<>
<MembershipPreview collection={collection} />
<Action>
{FeatureFlags.isEnabled(Feature.newCollectionSharing) &&
can.update && <ShareButton collection={collection} />}
{can.update && <ShareButton collection={collection} />}
</Action>
<Actions collection={collection} />
</>
@@ -167,142 +163,124 @@ function CollectionScene() {
collectionId={collection.id}
>
<CenteredContent withStickyHeader>
{collection.isEmpty ? (
<Empty collection={collection} />
) : (
<>
<CollectionHeading>
<IconTitleWrapper>
{can.update ? (
<React.Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? colorPalette[0]}
initial={collection.name[0]}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
/>
</React.Suspense>
) : (
fallbackIcon
)}
</IconTitleWrapper>
{collection.name}
{collection.isPrivate &&
!FeatureFlags.isEnabled(Feature.newCollectionSharing) && (
<Tooltip
content={t(
"This collection is only visible to those given access"
)}
placement="bottom"
>
<Badge>{t("Private")}</Badge>
</Tooltip>
)}
</CollectionHeading>
<CollectionHeading>
<IconTitleWrapper>
{can.update ? (
<React.Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? colorPalette[0]}
initial={collection.name[0]}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
/>
</React.Suspense>
) : (
fallbackIcon
)}
</IconTitleWrapper>
{collection.name}
</CollectionHeading>
<PinnedDocuments
pins={pins}
canUpdate={can.update}
placeholderCount={count}
/>
<CollectionDescription collection={collection} />
<PinnedDocuments
pins={pins}
canUpdate={can.update}
placeholderCount={count}
/>
<CollectionDescription collection={collection} />
<Documents>
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab
to={collectionPath(collection.path, "alphabetical")}
exact
>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionPath(collection.path, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path, "recent")}>
<Redirect
to={collectionPath(collection.path, "published")}
/>
</Route>
<Route path={collectionPath(collection.path, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route path={collectionPath(collection.path, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
}}
showParentDocuments
/>
</Route>
</Switch>
</Documents>
</>
)}
<Documents>
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
{collection.isEmpty ? (
<Empty collection={collection} />
) : (
<Switch>
<Route path={collectionPath(collection.path, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path, "recent")}>
<Redirect to={collectionPath(collection.path, "published")} />
</Route>
<Route path={collectionPath(collection.path, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route path={collectionPath(collection.path, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
}}
showParentDocuments
/>
</Route>
</Switch>
)}
</Documents>
</CenteredContent>
</DropToImport>
</Scene>
+45 -21
View File
@@ -12,6 +12,10 @@ import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import ClickablePadding from "~/components/ClickablePadding";
import {
DocumentContextProvider,
useDocumentContext,
} from "~/components/DocumentContext";
import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared";
import { TeamContext } from "~/components/TeamContext";
@@ -104,7 +108,6 @@ function SharedDocumentScene(props: Props) {
? (searchParams.get("theme") as Theme)
: undefined;
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
const tocPosition = response?.team?.tocPosition ?? TOCPosition.Left;
React.useEffect(() => {
if (!user) {
@@ -183,32 +186,53 @@ function SharedDocumentScene(props: Props) {
</Helmet>
<TeamContext.Provider value={response.team}>
<ThemeProvider theme={theme}>
<Layout
title={response.document?.title}
sidebar={
response.sharedTree?.children.length ? (
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
) : undefined
}
>
{response.document && (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
)}
</Layout>
<ClickablePadding minHeight="20vh" />
<DocumentContextProvider>
<Layout
title={response.document?.title}
sidebar={
response.sharedTree?.children.length ? (
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
) : undefined
}
>
<SharedDocument shareId={shareId} response={response} />
</Layout>
<ClickablePadding minHeight="20vh" />
</DocumentContextProvider>
</ThemeProvider>
</TeamContext.Provider>
</>
);
}
const SharedDocument = ({
shareId,
response,
}: {
shareId?: string;
response: Response;
}) => {
const { setDocument } = useDocumentContext();
if (!response.document) {
return null;
}
const tocPosition = response.team?.tocPosition ?? TOCPosition.Left;
setDocument(response.document);
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
};
const Content = styled(Text)`
color: ${s("textSecondary")};
text-align: center;
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
@@ -64,6 +64,7 @@ function CommentThread({
recessed,
focused,
}: Props) {
const [focusedOnMount] = React.useState(focused);
const { editor } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
@@ -103,7 +104,8 @@ function CommentThread({
const handleClickThread = () => {
history.replace({
search: location.search,
// Clear any commentId from the URL when explicitly focusing a thread
search: "",
pathname: location.pathname.replace(/\/history$/, ""),
state: { commentId: thread.id },
});
@@ -117,17 +119,26 @@ function CommentThread({
React.useEffect(() => {
if (focused) {
// If the thread is already visible, scroll it into view immediately,
// otherwise wait for the sidebar to appear.
const isThreadVisible =
(topRef.current?.getBoundingClientRect().left ?? 0) < window.innerWidth;
setTimeout(
() => {
if (focusedOnMount) {
setTimeout(() => {
if (!topRef.current) {
return;
}
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
boundary: (parent) =>
// Prevents body and other parent elements from being scrolled
parent.id !== "comments",
});
}, sidebarAppearDuration);
} else {
setTimeout(() => {
if (!replyRef.current) {
return;
}
return scrollIntoView(replyRef.current, {
scrollIntoView(replyRef.current, {
scrollMode: "if-needed",
behavior: "smooth",
block: "center",
@@ -135,9 +146,8 @@ function CommentThread({
// Prevents body and other parent elements from being scrolled
parent.id !== "comments",
});
},
isThreadVisible ? 0 : sidebarAppearDuration
);
}, 0);
}
const getCommentMarkElement = () =>
window.document?.getElementById(`comment-${thread.id}`);
@@ -153,7 +163,7 @@ function CommentThread({
isMarkVisible ? 0 : sidebarAppearDuration
);
}
}, [focused, thread.id]);
}, [focused, focusedOnMount, thread.id]);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document.id}-${thread.id}`,
+6 -10
View File
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -5,25 +6,18 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { useDocumentContext } from "~/components/DocumentContext";
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
import { decodeURIComponentSafe } from "~/utils/urls";
const HEADING_OFFSET = 20;
type Props = {
/** The headings to render in the contents. */
headings: {
title: string;
level: number;
id: string;
}[];
};
export default function Contents({ headings }: Props) {
function Contents() {
const [activeSlug, setActiveSlug] = React.useState<string>();
const scrollPosition = useWindowScrollPosition({
throttle: 100,
});
const { headings } = useDocumentContext();
React.useEffect(() => {
let activeId = headings.length > 0 ? headings[0].id : undefined;
@@ -139,3 +133,5 @@ const List = styled.ol`
padding: 0;
list-style: none;
`;
export default observer(Contents);
@@ -9,6 +9,7 @@ import Revision from "~/models/Revision";
import Error402 from "~/scenes/Error402";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import { useDocumentContext } from "~/components/DocumentContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
@@ -56,6 +57,7 @@ function DataLoader({ match, children }: Props) {
const { ui, views, shares, comments, documents, revisions } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const { setDocument } = useDocumentContext();
const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params;
@@ -64,6 +66,10 @@ function DataLoader({ match, children }: Props) {
documents.getByUrl(match.params.documentSlug) ??
documents.get(match.params.documentSlug);
if (document) {
setDocument(document);
}
const revision = revisionId
? revisions.get(
revisionId === "latest"
+5 -27
View File
@@ -25,7 +25,7 @@ import {
TOCPosition,
TeamPreference,
} from "@shared/types";
import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { parseDomain } from "@shared/utils/domains";
import { determineIconType } from "@shared/utils/icon";
import RootStore from "~/stores/RootStore";
@@ -116,9 +116,6 @@ class DocumentScene extends React.Component<Props> {
@observable
title: string = this.props.document.title;
@observable
headings: Heading[] = [];
componentDidMount() {
this.updateIsDirty();
}
@@ -376,20 +373,6 @@ class DocumentScene extends React.Component<Props> {
this.isUploading = false;
};
handleChange = () => {
const { document } = this.props;
// Keep derived task list in sync
const tasks = this.editor.current?.getTasks();
const total = tasks?.length ?? 0;
const completed = tasks?.filter((t) => t.completed).length ?? 0;
document.updateTasks(total, completed);
};
onHeadingsChange = (headings: Heading[]) => {
this.headings = headings;
};
handleChangeTitle = action((value: string) => {
this.title = value;
this.props.document.title = value;
@@ -426,7 +409,6 @@ class DocumentScene extends React.Component<Props> {
const embedsDisabled =
(team && team.documentEmbeds === false) || document.embedsDisabled;
const hasHeadings = this.headings.length > 0;
const showContents =
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
const tocPos =
@@ -493,7 +475,6 @@ class DocumentScene extends React.Component<Props> {
)}
<Header
document={document}
documentHasHeadings={hasHeadings}
revision={revision}
shareId={shareId}
isDraft={document.isDraft}
@@ -507,7 +488,6 @@ class DocumentScene extends React.Component<Props> {
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceDocument}
onSave={this.onSave}
headings={this.headings}
/>
<Main fullWidth={document.fullWidth} tocPosition={tocPos}>
<React.Suspense
@@ -536,7 +516,7 @@ class DocumentScene extends React.Component<Props> {
docFullWidth={document.fullWidth}
position={tocPos}
>
<Contents headings={this.headings} />
<Contents />
</ContentsContainer>
)}
<MeasuredContainer
@@ -567,8 +547,6 @@ class DocumentScene extends React.Component<Props> {
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.handleChangeTitle}
onChangeIcon={this.handleChangeIcon}
onChange={this.handleChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
@@ -632,7 +610,7 @@ const Main = styled.div<MainProps>`
? tocPosition === TOCPosition.Left
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
: `1fr minmax(0, ${`calc(46em + 76px)`}) 1fr`};
: `1fr minmax(0, ${`calc(46em + 88px)`}) 1fr`};
`};
${breakpoint("desktopLarge")`
@@ -641,7 +619,7 @@ const Main = styled.div<MainProps>`
? tocPosition === TOCPosition.Left
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
: `1fr minmax(0, ${`calc(52em + 76px)`}) 1fr`};
: `1fr minmax(0, ${`calc(52em + 88px)`}) 1fr`};
`};
`;
@@ -670,7 +648,7 @@ type EditorContainerProps = {
const EditorContainer = styled.div<EditorContainerProps>`
// Adds space to the gutter to make room for icon & heading annotations
padding: 0 40px;
padding: 0 44px;
${breakpoint("tablet")`
grid-row: 1;
+2 -1
View File
@@ -175,7 +175,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[comments]
);
const { setEditor } = useDocumentContext();
const { setEditor, updateState: updateDocState } = useDocumentContext();
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
@@ -241,6 +241,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
? handleRemoveComment
: undefined
}
onChange={updateDocState}
extensions={extensions}
editorStyle={editorStyle}
{...rest}
+7 -16
View File
@@ -20,10 +20,7 @@ import Badge from "~/components/Badge";
import Button from "~/components/Button";
import Collaborators from "~/components/Collaborators";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import {
useDocumentContext,
useEditingFocus,
} from "~/components/DocumentContext";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import Header from "~/components/Header";
import Icon from "~/components/Icon";
@@ -35,6 +32,7 @@ import { restoreRevision } from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useEditingFocus from "~/hooks/useEditingFocus";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
@@ -51,7 +49,6 @@ import ShareButton from "./ShareButton";
type Props = {
document: Document;
documentHasHeadings: boolean;
revision: Revision | undefined;
sharedTree: NavigationNode | undefined;
shareId: string | null | undefined;
@@ -67,16 +64,10 @@ type Props = {
publish?: boolean;
autosave?: boolean;
}) => void;
headings: {
title: string;
level: number;
id: string;
}[];
};
function DocumentHeader({
document,
documentHasHeadings,
revision,
shareId,
isEditing,
@@ -88,7 +79,6 @@ function DocumentHeader({
sharedTree,
onSelectTemplate,
onSave,
headings,
}: Props) {
const { t } = useTranslation();
const { ui } = useStores();
@@ -100,6 +90,7 @@ function DocumentHeader({
const isRevision = !!revision;
const isEditingFocus = useEditingFocus();
const { editor } = useDocumentContext();
const { hasHeadings } = useDocumentContext();
// We cache this value for as long as the component is mounted so that if you
// apply a template there is still the option to replace it until the user
@@ -129,7 +120,7 @@ function DocumentHeader({
content={
showContents
? t("Hide contents")
: documentHasHeadings
: hasHeadings
? t("Show contents")
: `${t("Show contents")} (${t("available when headings are added")})`
}
@@ -210,14 +201,14 @@ function DocumentHeader({
hasSidebar={sharedTree && sharedTree.children?.length > 0}
left={
isMobile ? (
<TableOfContentsMenu headings={headings} />
<TableOfContentsMenu />
) : (
<PublicBreadcrumb
documentId={document.id}
shareId={shareId}
sharedTree={sharedTree}
>
{documentHasHeadings ? toc : null}
{hasHeadings ? toc : null}
</PublicBreadcrumb>
)
}
@@ -238,7 +229,7 @@ function DocumentHeader({
hasSidebar
left={
isMobile ? (
<TableOfContentsMenu headings={headings} />
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{toc} <Star document={document} color={theme.textSecondary} />
@@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { useEditingFocus } from "~/components/DocumentContext";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useEditingFocus from "~/hooks/useEditingFocus";
import useStores from "~/hooks/useStores";
function KeyboardShortcutsButton() {
@@ -27,7 +27,7 @@ function References({ document }: Props) {
? collections.get(document.collectionId)
: undefined;
const children = collection
? collection.getDocumentChildren(document.id)
? collection.getChildrenForDocument(document.id)
: [];
const showBacklinks = !!backlinks.length;
const showChildDocuments = !!children.length;
+1 -1
View File
@@ -31,7 +31,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
? collections.get(document.collectionId)
: undefined;
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
? collection.getChildrenForDocument(document.id).length
: 0;
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
-96
View File
@@ -1,96 +0,0 @@
import { observer } from "mobx-react";
import { useState } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { CollectionPermission, NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
item:
| {
active: boolean | null | undefined;
children: Array<NavigationNode>;
collectionId: string;
depth: number;
id: string;
title: string;
url: string;
}
| {
id: string;
collectionId: string;
title: string;
};
collection: Collection;
onCancel: () => void;
onSubmit: () => void;
};
function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
const [isSaving, setIsSaving] = useState(false);
const { documents, collections } = useStores();
const { t } = useTranslation();
const prevCollection = collections.get(item.collectionId);
const accessMapping = {
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
null: t("no access"),
};
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await documents.move({
documentId: item.id,
collectionId: collection.id,
});
toast.message(t("Document moved"));
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[documents, item.id, collection.id, t, onSubmit]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
<Trans
defaults="Heads up moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}."
values={{
title: item.title,
prevCollectionName: prevCollection?.name,
newCollectionName: collection.name,
prevPermission:
accessMapping[prevCollection?.permission || "null"],
newPermission: accessMapping[collection.permission || "null"],
}}
components={{
em: <strong />,
}}
/>
</Text>
<Button type="submit">
{isSaving ? `${t("Moving")}` : t("Move document")}
</Button>{" "}
<Button type="button" onClick={onCancel} neutral>
{t("Cancel")}
</Button>
</form>
</Flex>
);
}
export default observer(DocumentReparent);
@@ -52,11 +52,21 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
subtitle={subtitle}
actions={
<Flex align="center" gap={8}>
<CopyToClipboard text={apiKey.secret} onCopy={handleCopy}>
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
{isCopied ? t("Copied") : t("Copy")}
</Button>
</CopyToClipboard>
{apiKey.value && (
<CopyToClipboard text={apiKey.value} onCopy={handleCopy}>
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
{isCopied ? t("Copied") : t("Copy")}
</Button>
</CopyToClipboard>
)}
<Text
type="tertiary"
size="xsmall"
style={{ marginRight: 8 }}
monospace
>
{apiKey.obfuscatedValue}
</Text>
<ApiKeyMenu apiKey={apiKey} />
</Flex>
}
+5 -96
View File
@@ -1,38 +1,15 @@
import invariant from "invariant";
import concat from "lodash/concat";
import find from "lodash/find";
import isEmpty from "lodash/isEmpty";
import last from "lodash/last";
import sortBy from "lodash/sortBy";
import { computed, action } from "mobx";
import {
CollectionPermission,
FileOperationFormat,
NavigationNode,
} from "@shared/types";
import { CollectionPermission, FileOperationFormat } from "@shared/types";
import Collection from "~/models/Collection";
import { Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
enum DocumentPathItemType {
Collection = "collection",
Document = "document",
}
export type DocumentPathItem = {
type: DocumentPathItemType;
id: string;
collectionId: string;
title: string;
url: string;
};
export type DocumentPath = DocumentPathItem & {
path: DocumentPathItem[];
};
export default class CollectionsStore extends Store<Collection> {
constructor(rootStore: RootStore) {
super(rootStore, Collection);
@@ -95,55 +72,6 @@ export default class CollectionsStore extends Store<Collection> {
);
}
/**
* List of paths to each of the documents, where paths are composed of id and title/name pairs
*/
@computed
get pathsToDocuments(): DocumentPath[] {
const results: DocumentPathItem[][] = [];
const travelDocuments = (
documentList: NavigationNode[],
collectionId: string,
path: DocumentPathItem[]
) =>
documentList.forEach((document: NavigationNode) => {
const { id, title, url } = document;
const node = {
type: DocumentPathItemType.Document,
id,
collectionId,
title,
url,
};
results.push(concat(path, node));
travelDocuments(document.children, collectionId, concat(path, [node]));
});
if (this.isLoaded) {
this.data.forEach((collection) => {
const { id, name, path } = collection;
const node = {
type: DocumentPathItemType.Collection,
id,
collectionId: id,
title: name,
url: path,
};
results.push([node]);
if (collection.documents) {
travelDocuments(collection.documents, id, [node]);
}
});
}
return results.map((result) => {
const tail = last(result) as DocumentPathItem;
return { ...tail, path: result };
});
}
@action
import = async (
attachmentId: string,
@@ -191,15 +119,6 @@ export default class CollectionsStore extends Store<Collection> {
return model;
}
@computed
get publicCollections() {
return this.orderedData.filter(
(collection) =>
collection.permission &&
Object.values(CollectionPermission).includes(collection.permission)
);
}
star = async (collection: Collection, index?: string) => {
await this.rootStore.stars.create({
collectionId: collection.id,
@@ -209,24 +128,14 @@ export default class CollectionsStore extends Store<Collection> {
unstar = async (collection: Collection) => {
const star = this.rootStore.stars.orderedData.find(
(star) => star.collectionId === collection.id
(s) => s.collectionId === collection.id
);
await star?.delete();
};
getPathForDocument(documentId: string): DocumentPath | undefined {
return this.pathsToDocuments.find((path) => path.id === documentId);
}
titleForDocument(documentPath: string): string | undefined {
const path = this.pathsToDocuments.find(
(path) => path.url === documentPath
);
if (path) {
return path.title;
}
return;
@computed
get navigationNodes() {
return this.orderedData.map((collection) => collection.asNavigationNode);
}
getByUrl(url: string): Collection | null | undefined {
+2 -2
View File
@@ -367,8 +367,8 @@ export default class DocumentsStore extends Store<Document> {
this.fetchNamedPage("starred", options);
@action
fetchDrafts = (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("drafts", options);
fetchDrafts = (options: PaginationParams = {}): Promise<Document[]> =>
this.fetchNamedPage("drafts", { limit: 100, ...options });
@action
fetchOwned = (options?: PaginationParams): Promise<Document[]> =>
+2 -9
View File
@@ -103,6 +103,8 @@ export type Action = {
shortcut?: string[];
keywords?: string;
dangerous?: boolean;
/** Higher number is higher in results, default is 0. */
priority?: number;
iconInContextMenu?: boolean;
icon?: React.ReactElement | React.FC;
placeholder?: ((context: ActionContext) => string) | string;
@@ -140,15 +142,6 @@ export type FetchOptions = {
force?: boolean;
};
export type NavigationNode = {
id: string;
title: string;
emoji?: string | null;
url: string;
children: NavigationNode[];
isDraft?: boolean;
};
export type CollectionSort = {
field: string;
direction: "asc" | "desc";
+3 -3
View File
@@ -47,10 +47,10 @@ export function redirectTo(url: string) {
}
/**
* Check if the path is a valid redirect after login
* Check if the path is a valid path for redirect after login.
*
* @param path
* @returns boolean indicating if the path is a valid redirect
*/
export const isValidPostLoginRedirect = (path: string) =>
!["/", "/create", "/home", "/logout"].includes(path);
export const isAllowedLoginRedirect = (path: string) =>
!["/", "/create", "/home", "/logout", "/auth/"].includes(path);
+10 -9
View File
@@ -17,6 +17,7 @@
"prepare": "husky install",
"postinstall": "yarn patch-package",
"install-local-ssl": "node ./server/scripts/install-local-ssl.js",
"release": "node ./server/scripts/release.js",
"heroku-postbuild": "yarn build && yarn db:migrate",
"db:create-migration": "sequelize migration:create",
"db:create": "sequelize db:create",
@@ -94,7 +95,7 @@
"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.12.2",
"bull": "^4.16.3",
"chalk": "^4.1.0",
"class-validator": "^0.14.1",
"command-score": "^0.1.2",
@@ -158,7 +159,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^6.9.14",
"octokit": "^3.2.1",
"outline-icons": "^3.6.0",
"outline-icons": "^3.8.0",
"oy-vey": "^0.12.1",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
@@ -183,7 +184,7 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.4.0",
"prosemirror-transform": "^1.10.0",
"prosemirror-view": "^1.34.2",
"prosemirror-view": "^1.34.3",
"query-string": "^7.1.3",
"randomstring": "1.3.0",
"rate-limiter-flexible": "^2.4.2",
@@ -220,9 +221,9 @@
"sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
"slugify": "^1.6.6",
"smooth-scroll-into-view-if-needed": "^1.1.33",
"scroll-into-view-if-needed": "^3.1.0",
"socket.io": "^4.7.5",
"socket.io-client": "^4.7.5",
"socket.io-client": "^4.8.0",
"socket.io-redis": "^6.1.1",
"sonner": "^1.0.3",
"stoppable": "^1.1.0",
@@ -255,7 +256,7 @@
"@relative-ci/agent": "^4.2.9",
"@testing-library/react": "^12.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.0",
"@types/body-scroll-lock": "^3.1.2",
"@types/crypto-js": "^4.2.2",
"@types/diff": "^5.0.9",
"@types/dotenv": "^8.2.0",
@@ -268,7 +269,7 @@
"@types/glob": "^8.0.1",
"@types/google.analytics": "^0.0.46",
"@types/invariant": "^2.2.37",
"@types/jest": "^29.5.12",
"@types/jest": "^29.5.13",
"@types/jsonwebtoken": "^8.5.9",
"@types/katex": "^0.16.7",
"@types/koa": "^2.15.0",
@@ -328,7 +329,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.87",
"discord-api-types": "^0.37.101",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.6.3",
@@ -366,5 +367,5 @@
"qs": "6.9.7",
"rollup": "^4.5.1"
},
"version": "0.79.1"
"version": "0.80.2"
}
+10 -3
View File
@@ -93,22 +93,29 @@ if (
}
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const { domain } = parseEmail(profile.email);
// Only a single OIDC provider is supported find the existing, if any.
const authenticationProvider = team
? await AuthenticationProvider.findOne({
? (await AuthenticationProvider.findOne({
where: {
name: "oidc",
teamId: team.id,
providerId: domain,
},
})) ??
(await AuthenticationProvider.findOne({
where: {
name: "oidc",
teamId: team.id,
},
})
}))
: undefined;
// Derive a providerId from the OIDC location if there is no existing provider.
const oidcURL = new URL(env.OIDC_AUTH_URI!);
const providerId =
authenticationProvider?.providerId ?? oidcURL.hostname;
const { domain } = parseEmail(profile.email);
if (!domain) {
throw OIDCMalformedUserInfoError();
+2 -4
View File
@@ -111,16 +111,14 @@ export default class PersistenceExtension implements Extension {
return;
}
const collaboratorIds = Array.from(documentCollaboratorIds.values());
const sessionCollaboratorIds = Array.from(documentCollaboratorIds.values());
this.documentCollaboratorIds.delete(documentName);
try {
await documentCollaborativeUpdater({
documentId,
ydoc: document,
// TODO: Right now we're attributing all changes to the last editor,
// It would be nice in the future to have multiple editors per revision.
userId: collaboratorIds.pop(),
sessionCollaboratorIds,
isLastConnection: clientsCount === 0,
});
} catch (err) {
@@ -10,20 +10,20 @@ import { Document, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
type Props = {
/** The document ID to update */
/** The document ID to update. */
documentId: string;
/** Current collaobrative state */
/** Current collaobrative state. */
ydoc: Y.Doc;
/** The user ID that is performing the update, if known */
userId?: string;
/** Whether the last connection to the document left */
/** The user IDs that have modified the document since it was last persisted. */
sessionCollaboratorIds: string[];
/** Whether the last connection to the document left. */
isLastConnection: boolean;
};
export default async function documentCollaborativeUpdater({
documentId,
ydoc,
userId,
sessionCollaboratorIds,
isLastConnection,
}: Props) {
return sequelize.transaction(async (transaction) => {
@@ -47,7 +47,9 @@ export default async function documentCollaborativeUpdater({
const node = Node.fromJSON(schema, content);
const text = serializer.serialize(node, undefined);
const isUnchanged = isEqual(document.content, content);
const lastModifiedById = userId ?? document.lastModifiedById;
const lastModifiedById =
sessionCollaboratorIds[sessionCollaboratorIds.length - 1] ??
document.lastModifiedById;
if (isUnchanged) {
return;
@@ -61,7 +63,11 @@ export default async function documentCollaborativeUpdater({
// extract collaborators from doc user data
const pud = new Y.PermanentUserData(ydoc);
const pudIds = Array.from(pud.clients.values());
const collaboratorIds = uniq([...document.collaboratorIds, ...pudIds]);
const collaboratorIds = uniq([
...document.collaboratorIds,
...sessionCollaboratorIds,
...pudIds,
]);
await document.update(
{
+33
View File
@@ -48,4 +48,37 @@ describe("documentUpdater", () => {
expect(document.lastModifiedById).not.toEqual(user.id);
});
it("should update document content when changing text", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
document = await sequelize.transaction(async (transaction) =>
documentUpdater({
text: "Changed",
document,
user,
ip,
transaction,
})
);
expect(document.text).toEqual("Changed");
expect(document.content).toEqual({
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Changed",
},
],
},
],
});
});
});
+1 -1
View File
@@ -233,7 +233,7 @@ class Logger {
}
if (isArray(input)) {
return input.map(this.sanitize) as any as T;
return input.map((item) => this.sanitize(item, level + 1)) as any as T;
}
if (isObject(input)) {
+8 -6
View File
@@ -1,7 +1,11 @@
import { DefaultState } from "koa";
import randomstring from "randomstring";
import ApiKey from "@server/models/ApiKey";
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
import {
buildUser,
buildTeam,
buildAdmin,
buildApiKey,
} from "@server/test/factories";
import auth from "./authentication";
describe("Authentication middleware", () => {
@@ -51,14 +55,12 @@ describe("Authentication middleware", () => {
const state = {} as DefaultState;
const user = await buildUser();
const authMiddleware = auth();
const key = await ApiKey.create({
userId: user.id,
});
const key = await buildApiKey({ userId: user.id });
await authMiddleware(
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => `Bearer ${key.secret}`),
get: jest.fn(() => `Bearer ${key.value}`),
},
state,
cache: {},
+1 -5
View File
@@ -70,11 +70,7 @@ export default function auth(options: AuthenticationOptions = {}) {
let apiKey;
try {
apiKey = await ApiKey.findOne({
where: {
secret: token,
},
});
apiKey = await ApiKey.findByToken(token);
} catch (err) {
throw AuthenticationError("Invalid API key");
}
@@ -0,0 +1,35 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("apiKeys", "hash", {
type: Sequelize.STRING,
allowNull: true,
unique: true,
}, { transaction });
await queryInterface.addColumn("apiKeys", "last4", {
type: Sequelize.STRING(4),
allowNull: true,
}, { transaction });
await queryInterface.changeColumn("apiKeys", "secret", {
type: Sequelize.STRING,
allowNull: true,
}, { transaction });
});
},
async down (queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("apiKeys", "hash", { transaction });
await queryInterface.removeColumn("apiKeys", "last4", { transaction });
await queryInterface.changeColumn("apiKeys", "secret", {
type: Sequelize.STRING,
allowNull: false,
}, { transaction });
});
}
};
@@ -0,0 +1,29 @@
"use strict";
const { execFileSync } = require("child_process");
const path = require("path");
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up() {
if (
process.env.NODE_ENV === "test" ||
process.env.DEPLOYMENT === "hosted"
) {
return;
}
const scriptName = path.basename(__filename);
const scriptPath = path.join(
process.cwd(),
"build",
`server/scripts/${scriptName}`
);
execFileSync("node", [scriptPath], { stdio: "inherit" });
},
async down() {
// noop
},
};
+15 -10
View File
@@ -5,10 +5,8 @@ import ApiKey from "./ApiKey";
describe("#ApiKey", () => {
describe("match", () => {
test("should match an API secret", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
expect(ApiKey.match(apiKey?.secret)).toBe(true);
const apiKey = await buildApiKey();
expect(ApiKey.match(apiKey.value!)).toBe(true);
expect(ApiKey.match(`${randomstring.generate(38)}`)).toBe(true);
});
@@ -20,17 +18,13 @@ describe("#ApiKey", () => {
describe("lastActiveAt", () => {
test("should update lastActiveAt", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
const apiKey = await buildApiKey();
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
});
test("should not update lastActiveAt within 5 minutes", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
const apiKey = await buildApiKey();
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
@@ -39,4 +33,15 @@ describe("#ApiKey", () => {
expect(apiKey.lastActiveAt).toEqual(lastActiveAt);
});
});
describe("findByToken", () => {
test("should find by hash", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
const found = await ApiKey.findByToken(apiKey.value!);
expect(found?.id).toEqual(apiKey.id);
expect(found?.last4).toEqual(apiKey.value!.slice(-4));
});
});
});
+72 -6
View File
@@ -1,6 +1,7 @@
import crypto from "crypto";
import { subMinutes } from "date-fns";
import randomstring from "randomstring";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import { InferAttributes, InferCreationAttributes, Op } from "sequelize";
import {
Column,
Table,
@@ -9,6 +10,9 @@ import {
BelongsTo,
ForeignKey,
IsDate,
DataType,
AfterFind,
BeforeSave,
} from "sequelize-typescript";
import { ApiKeyValidation } from "@shared/validations";
import User from "./User";
@@ -32,10 +36,24 @@ class ApiKey extends ParanoidModel<
@Column
name: string;
/** @deprecated The plain text value of the API key, removed soon. */
@Unique
@Column
secret: string;
/** The cached plain text value. Only available when creating the API key */
@Column(DataType.VIRTUAL)
value: string | null;
/** The hashed value of the API key */
@Unique
@Column
hash: string;
/** The last 4 characters of the API key */
@Column
last4: string;
@IsDate
@Column
expiresAt: Date | null;
@@ -46,13 +64,43 @@ class ApiKey extends ParanoidModel<
// hooks
@BeforeValidate
static async generateSecret(model: ApiKey) {
if (!model.secret) {
model.secret = `${ApiKey.prefix}${randomstring.generate(38)}`;
@AfterFind
public static async afterFindHook(models: ApiKey | ApiKey[]) {
const modelsArray = Array.isArray(models) ? models : [models];
for (const model of modelsArray) {
if (model?.secret) {
model.last4 = model.secret.slice(-4);
}
}
}
@BeforeValidate
public static async generateSecret(model: ApiKey) {
if (!model.hash) {
const secret = `${ApiKey.prefix}${randomstring.generate(38)}`;
model.value = model.secret || secret;
model.hash = this.hash(model.value);
}
}
@BeforeSave
public static async updateLast4(model: ApiKey) {
const value = model.value || model.secret;
if (value) {
model.last4 = value.slice(-4);
}
}
/**
* Generates a hashed API key for the given input key.
*
* @param key The input string to hash
* @returns The hashed API key
*/
public static hash(key: string) {
return crypto.createHash("sha256").update(key).digest("hex");
}
/**
* Validates that the input touch could be an API key, this does not check
* that the key exists in the database.
@@ -60,10 +108,26 @@ class ApiKey extends ParanoidModel<
* @param text The text to validate
* @returns True if likely an API key
*/
static match(text: string) {
public static match(text: string) {
// cannot guarantee prefix here as older keys do not include it.
return !!text.replace(ApiKey.prefix, "").match(/^[\w]{38}$/);
}
/**
* Finds an API key by the given input string. This will check both the
* secret and hash fields.
*
* @param input The input string to search for
* @returns The API key if found
*/
public static findByToken(input: string) {
return this.findOne({
where: {
[Op.or]: [{ secret: input }, { hash: this.hash(input) }],
},
});
}
// associations
@BelongsTo(() => User, "userId")
@@ -73,6 +137,8 @@ class ApiKey extends ParanoidModel<
@Column
userId: string;
// methods
updateActiveAt = async () => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
+10 -11
View File
@@ -23,7 +23,6 @@ import {
BelongsTo,
Column,
Default,
PrimaryKey,
Table,
BeforeValidate,
BeforeCreate,
@@ -39,6 +38,7 @@ import {
IsDate,
AllowNull,
BelongsToMany,
Unique,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type {
@@ -268,7 +268,7 @@ class Document extends ParanoidModel<
max: 10,
msg: `urlId must be 10 characters`,
})
@PrimaryKey
@Unique
@Column
urlId: string;
@@ -807,15 +807,14 @@ class Document extends ParanoidModel<
* @param options FindOptions
* @returns A promise that resolve to a list of users
*/
collaborators = async (options?: FindOptions<User>): Promise<User[]> => {
const users = await Promise.all(
this.collaboratorIds.map((collaboratorId) =>
User.findByPk(collaboratorId, options)
)
);
return compact(users);
};
collaborators = async (options?: FindOptions<User>): Promise<User[]> =>
await User.findAll({
...options,
where: {
...options?.where,
id: this.collaboratorIds,
},
});
/**
* Find all of the child documents for this document
+1 -5
View File
@@ -1,6 +1,5 @@
import {
updateYFragment,
yDocToProsemirror,
yDocToProsemirrorJSON,
} from "@getoutline/y-prosemirror";
import { JSDOM } from "jsdom";
@@ -442,6 +441,7 @@ export class DocumentHelper {
) {
document.text = append ? document.text + text : text;
const doc = parser.parse(document.text);
document.content = doc.toJSON();
if (document.state) {
const ydoc = new Y.Doc();
@@ -456,13 +456,9 @@ export class DocumentHelper {
updateYFragment(type.doc, type, doc, new Map());
const state = Y.encodeStateAsUpdate(ydoc);
const node = yDocToProsemirror(schema, ydoc);
document.content = node.toJSON();
document.state = Buffer.from(state);
document.changed("state", true);
} else if (doc) {
document.content = doc.toJSON();
}
return document;
+9 -8
View File
@@ -1,13 +1,14 @@
import ApiKey from "@server/models/ApiKey";
export default function presentApiKey(key: ApiKey) {
export default function presentApiKey(apiKey: ApiKey) {
return {
id: key.id,
name: key.name,
secret: key.secret,
createdAt: key.createdAt,
updatedAt: key.updatedAt,
expiresAt: key.expiresAt,
lastActiveAt: key.lastActiveAt,
id: apiKey.id,
name: apiKey.name,
value: apiKey.value,
last4: apiKey.last4,
createdAt: apiKey.createdAt,
updatedAt: apiKey.updatedAt,
expiresAt: apiKey.expiresAt,
lastActiveAt: apiKey.lastActiveAt,
};
}
@@ -5,6 +5,7 @@ import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { sequelize } from "@server/storage/database";
import { CommentEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/policies";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class CommentCreatedNotificationsTask extends BaseTask<CommentEvent> {
@@ -52,7 +53,8 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
recipient.id !== mention.actorId &&
recipient.subscribedToEventType(
NotificationEventType.MentionedInComment
)
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.MentionedInComment,
@@ -2,6 +2,7 @@ import { NotificationEventType } from "@shared/types";
import { Comment, Document, Notification, User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { CommentEvent, CommentUpdateEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/policies";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEvent> {
@@ -41,7 +42,8 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
recipient.id !== mention.actorId &&
recipient.subscribedToEventType(
NotificationEventType.MentionedInComment
)
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.MentionedInComment,
@@ -49,6 +51,7 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
actorId: mention.actorId,
teamId: document.teamId,
documentId: document.id,
commentId: comment.id,
});
}
}
@@ -4,6 +4,7 @@ import { Document, Notification, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { DocumentEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/policies";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class DocumentPublishedNotificationsTask extends BaseTask<DocumentEvent> {
@@ -33,7 +34,8 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
recipient.id !== mention.actorId &&
recipient.subscribedToEventType(
NotificationEventType.MentionedInDocument
)
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.MentionedInDocument,
+1 -1
View File
@@ -171,7 +171,7 @@ export default class ImportMarkdownZipTask extends ImportTask {
document.text = document.text
.replace(new RegExp(escapeRegExp(encodedPath), "g"), reference)
.replace(
new RegExp(`/?${escapeRegExp(normalizedAttachmentPath)}`, "g"),
new RegExp(`\.?/?${escapeRegExp(normalizedAttachmentPath)}`, "g"),
reference
);
}
@@ -8,8 +8,8 @@ import Logger from "@server/logging/Logger";
import { Document, Revision, Notification, User, View } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { authorize } from "@server/policies";
import { RevisionEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/policies";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionEvent> {
@@ -54,7 +54,7 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
recipient.subscribedToEventType(
NotificationEventType.MentionedInDocument
) &&
(await this.canAccess(recipient, document))
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.MentionedInDocument,
@@ -151,18 +151,6 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
return true;
};
private canAccess = async (user: User, model: Document) => {
try {
const document = await Document.findByPk(model.id, {
userId: user.id,
});
authorize(user, "read", document);
return true;
} catch (err) {
return false;
}
};
public get options() {
return {
priority: TaskPriority.Background,
+1 -1
View File
@@ -580,7 +580,7 @@ router.post(
router.post(
"collections.export",
rateLimiter(RateLimiterStrategy.TenPerHour),
rateLimiter(RateLimiterStrategy.FiftyPerHour),
auth(),
validate(T.CollectionsExportSchema),
transaction(),

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