Compare commits

..

58 Commits

Author SHA1 Message Date
Salihu 6f06eda36b minor fixes 2026-02-26 22:48:29 +01:00
Salihu 2dcfe4be0c minor fixes 2026-02-26 18:16:59 +01:00
Salihu 8b56b47eb0 fix mobile menu closing functionality 2026-02-24 18:50:02 +01:00
Salihu b20f70da42 add comment 2026-02-24 14:10:41 +01:00
Salihu 315992d55b some changes 2026-02-23 22:52:21 +01:00
Salihu 8427778c46 nested submenu 2026-02-22 22:16:36 +01:00
Salihu fd4dab23f2 nested submenu 2026-02-22 22:13:37 +01:00
Salihu edcdb6f8c0 minor fix 2026-02-22 16:19:56 +01:00
Salihu 41a5097240 revert unnecessary lint changes 2026-02-22 15:53:58 +01:00
Salihu bc248dc190 Merge remote-tracking branch 'upstream/main' into feat/inline-menu 2026-02-22 15:28:07 +01:00
Tom Moor 496b89c7f8 chore: Remove gitbeaker dep on client (#11517)
Add dupe detection to gitlab install
2026-02-22 00:38:10 -05:00
Tom Moor 46dd13fc7f Update integrations directory to color icons (#11516) 2026-02-22 02:37:04 +00:00
Salihu f3eec09125 Merge remote-tracking branch 'upstream/main' into feat/inline-menu 2026-02-22 00:38:25 +01:00
Tom Moor ac29295dd2 fix: Tighten touch device detection (#11515) 2026-02-21 18:27:00 -05:00
Apoorv Mishra 0e8fde3bb1 Perf: apply initial decorations early on for toggle blocks (#11493)
* draft

* Revert "draft"

This reverts commit 911c2996be.

* fix: that simple, huh
2026-02-21 18:26:52 -05:00
Salihu cad670f19c feat: GitLab integration (#10861)
Co-authored-by: Tom Moor <tom@getoutline.com>
closes #6795
2026-02-21 17:52:27 -05:00
Salihu afb849ac98 improve menu positioning 2026-02-21 21:58:13 +01:00
Tom Moor 00ef17b913 fix: Flaky i18n test (#11514) 2026-02-21 12:35:01 -05:00
Tom Moor 05381ff101 Add move_document tool (#11510) 2026-02-20 21:39:14 -05:00
Tom Moor 519fd024f9 Add Datadog tracing to MCP tool handlers (#11509)
Wraps all MCP tool and resource handlers with Datadog APM spans so that
each invocation is visible in traces under the `outline-mcp` service.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:08:46 -05:00
Tom Moor 7be893f9a3 Refactor templates (#11027)
closes #8674
2026-02-20 18:53:00 -05:00
Tom Moor 52448714d9 fix: documents.import no longer allows direct upload (#11506) 2026-02-20 12:18:22 -05:00
Salihu 9b67d55f76 add submenu 2026-02-20 16:33:15 +01:00
Tom Moor 6e92313f73 Refactor ActionContextProvider to allow more model coverage (#11503) 2026-02-19 23:10:13 -05:00
Tom Moor dfd969084b fix: Sticky header transparent background (#11501)
fix: Custom header color incorrect text color
2026-02-19 21:15:07 -05:00
Tom Moor 758d2b62f5 fix: Overly greedy background -> highlight (#11500) 2026-02-20 01:44:39 +00:00
Tom Moor b90ff98cef fix: Read-only collection editor does not remount correctly on nav (#11499) 2026-02-20 01:28:54 +00:00
Tom Moor 23642fbd85 fix: Ignore browser cache for diagram extension (#11498)
closes #11496
2026-02-19 20:03:30 -05:00
Tom Moor 3fa429977a fix: Find and replace option immediately closes when opening on mobile (#11497)
* fix: Index out of bounds

* fix: Find and replace auto dismissal

* fix: Lost focus after vaul close
2026-02-19 19:18:03 -05:00
Copilot 8ddebb920e Add child documents list to markdown export for shared documents (#11495)
* Initial plan

* Add child documents list to markdown export for shared documents

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Apply prettier formatting to app.ts

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* fix tree context

* test: Account for document share

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-19 18:45:13 -05:00
Tom Moor 7ff6f1defb feat: Add webhooks for file attachments (#11494) 2026-02-19 17:28:50 -05:00
Tom Moor f2016bb1ca fix: Pagination on search (#11489) 2026-02-19 17:28:35 -05:00
Tom Moor ba5e4dddbc Add missing shortcut (#11492) 2026-02-19 17:28:19 -05:00
Tom Moor bb8f73cb8d fix: Default scopes not provided in OAuthAuthorize 2026-02-18 22:19:05 -05:00
Tom Moor 4aeea4f73c mcp: Add draft and publish (#11488)
* mcp: Add draft and publish

* refactor

* touch
2026-02-18 21:23:27 -05:00
Copilot 2e0bc66ad1 Fix React Doctor error-level issues (#11483)
* Initial plan

* Fix React Doctor errors: aria-selected, key props, alt attributes, layout animation, nested component, reduced motion

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Fix remaining React Doctor errors: refactor useTrackLastVisitedPath to avoid useEffect

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Revert useMeasure change

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-18 19:47:56 -05:00
Tom Moor c4d861e0ae fix: Overflowing long redirect url (#11486) 2026-02-19 00:39:22 +00:00
Apoorv Mishra f02520444e Toggle toggle block's state upon clicking its head (#11469)
* fix: toggle block upon clicking toggle head in read-only mode

* fix: show pointer

* fix: prevent default

* fix: simplify
2026-02-17 18:59:17 -05:00
Tom Moor 6695ae1f3e Reduce popularity boost in search results (#11481) 2026-02-17 18:51:46 -05:00
Tom Moor 924db0a3fd fix: Handle invalid claudeai scope (#11484)
* fix: Handle invalid 'claudeai' scope

* Add docs link
2026-02-17 18:51:36 -05:00
Tom Moor c9fe7b3d5c Ensure full urls are returned from MCP (#11482)
* Ensure full urls are returned from MCP

towards #11474

* Fix pathToUrl using path.join for URLs and add test coverage

path.join collapsed https:// to https:/ — use URL constructor instead.
Added assertions verifying full URLs are returned for collections and
documents across list, create, update, and resource endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add scopes_supported

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:05:43 -05:00
Tom Moor 1937043aed feat: MCP Server (#11464)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:14:18 -05:00
Tom Moor 957648a588 feat: OAuth dynamic client registration (#11462)
* feat: DCR first pass

* Add cleanup task, management endpoints

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* wip

* Combine migrations

* Self review

* fix: Guard OAuth policies

* fix: Application access list not updating on deletion

* feat: Add OAUTH_DISABLE_DCR env var to disable dynamic client registration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Validate max length of redirect URIs in DCR schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Self review

* Use withCtx methods for correct event creation

* Remove incorrect scopes_supported

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:30:19 -05:00
Copilot 5c01909909 Add Fortran language support to code blocks (#11471)
* Initial plan

* Add Fortran language support to code blocks

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-16 10:49:24 -05:00
Tom Moor 84d6ed01e3 chore: Remove 'Features' settings 2026-02-16 10:31:47 -05:00
Tom Moor c758f0d93a chore: Upgrade Zod to version 4 (#11465) 2026-02-15 22:54:50 -05:00
Tom Moor c54194f97a fix: Unobserved components (#11460)
* fix: Unobserved components

* mas

* More missing observers
2026-02-15 15:14:53 -05:00
Tom Moor a860cfc9ec v1.5.0 2026-02-15 12:54:42 -05:00
Tom Moor 08d58f7a6d fix: Small race conditions in diagrams.net integration (#11458) 2026-02-15 12:45:40 -05:00
Tom Moor 45a19d52cf Update documents.import to accept attachmentId (#11457)
* Update documents.import to accept attachmentId

* Add toast
2026-02-15 11:47:08 -05:00
Tom Moor de69a4e671 chore: Cleanup collection create dialog (#11454) 2026-02-14 17:39:53 -05:00
Tom Moor 7824f6b363 feat: Allow creating new doc before/after (#11453) 2026-02-14 17:24:52 -05:00
Tom Moor f6709520fa Add missing tooltips (#11452) 2026-02-14 21:44:45 +00:00
Salihu b792945d01 table mens should be inline menus 2026-02-13 21:54:19 +01:00
Salihu 7c8ba7d2c1 render inline menu just outside the table 2026-02-10 15:49:48 +01:00
Salihu 54a90b05a8 separate inline menu from floating toolbar 2026-02-10 00:43:46 +01:00
Salihu 3e38164366 minor fixes 2026-01-07 17:41:57 +01:00
Salihu f28ce8f0cd inline menu 2026-01-06 23:26:59 +01:00
286 changed files with 12884 additions and 2650 deletions
+5
View File
@@ -212,6 +212,11 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The GitLab integration allows previewing issue and merge request links
# DOCS:
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
+3
View File
@@ -18,6 +18,9 @@ GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
GITLAB_CLIENT_ID=123
GITLAB_CLIENT_SECRET=123
OIDC_CLIENT_ID=client-id
OIDC_CLIENT_SECRET=client-secret
OIDC_AUTH_URI=http://localhost/authorize
+1
View File
@@ -20,4 +20,5 @@ data/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.yarn/releases
!.yarn/sdks
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.4.0
Licensed Work: Outline 1.5.0
The Licensed Work is (c) 2026 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-01-27
Change Date: 2030-02-15
Change License: Apache License, Version 2.0
+7 -10
View File
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
createActionWithChildren,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
@@ -152,7 +152,7 @@ export const importDocument = createAction({
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
perform: ({ getActiveModel, stores }) => {
perform: ({ t, getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
@@ -165,6 +165,7 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(file, null, collection.id, {
@@ -173,6 +174,8 @@ export const importDocument = createAction({
history.push(document.path);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -527,15 +530,9 @@ export const createTemplate = createInternalLinkAction({
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
to: ({ getActiveModel, sidebarContext }) => {
to: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newTemplatePath(collection?.id).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
return newTemplatePath(collection?.id);
},
});
+144 -97
View File
@@ -42,12 +42,11 @@ import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
import { DocumentDownload } from "~/components/DocumentDownload";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -70,6 +69,7 @@ import {
homePath,
newDocumentPath,
newNestedDocumentPath,
newSiblingDocumentPath,
searchPath,
documentPath,
urlify,
@@ -78,9 +78,15 @@ import {
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type { Action, ActionGroup, ActionSeparator } from "~/types";
import type {
Action,
ActionContext,
ActionGroup,
ActionSeparator,
} from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -132,18 +138,13 @@ export const editDocument = createInternalLinkAction({
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, documents, policies } = stores;
const { auth, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return (
!!can?.update && !!auth.user?.separateEditMode && !document?.template
);
return !!can?.update && !!auth.user?.separateEditMode;
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
@@ -216,12 +217,7 @@ export const createDocumentFromTemplate = createInternalLinkAction({
? stores.documents.get(activeDocumentId)
: undefined;
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
return false;
}
@@ -247,12 +243,41 @@ export const createDocumentFromTemplate = createInternalLinkAction({
},
});
/**
* Finds the index of a document among its siblings in the collection tree.
*
* @param stores - the root stores.
* @param document - the document to find the index of.
* @returns the index of the document among its siblings, or -1 if not found.
*/
function findDocumentSiblingIndex(
stores: ActionContext["stores"],
document: {
id: string;
collectionId?: string | null;
parentDocumentId?: string;
}
): number {
if (!document.collectionId) {
return -1;
}
const collection = stores.collections.get(document.collectionId);
if (!collection) {
return -1;
}
const siblings = document.parentDocumentId
? collection.getChildrenForDocument(document.parentDocumentId)
: collection.sortedDocuments;
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
}
export const createNestedDocument = createInternalLinkAction({
name: ({ t }) => t("New nested document"),
name: ({ t }) => t("Nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
keywords: "create nested",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
@@ -270,6 +295,93 @@ export const createNestedDocument = createInternalLinkAction({
},
});
const createDocumentBefore = createInternalLinkAction({
name: ({ t }) => t("Before"),
analyticsName: "New document before",
section: ActiveDocumentSection,
keywords: "create before",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: Math.max(0, index),
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
const createDocumentAfter = createInternalLinkAction({
name: ({ t }) => t("After"),
analyticsName: "New document after",
section: ActiveDocumentSection,
keywords: "create after",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: index + 1,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createNewDocument = createActionWithChildren({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -346,7 +458,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId || document?.template) {
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
@@ -891,7 +1003,7 @@ export const importDocument = createAction({
return false;
},
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
@@ -900,6 +1012,7 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(
@@ -913,6 +1026,8 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -930,7 +1045,7 @@ export const createTemplateFromDocument = createAction({
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
if (!document?.isActive) {
return false;
}
return !!(
@@ -982,46 +1097,8 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
@@ -1059,8 +1136,7 @@ export const moveDocument = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
if (!document) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
@@ -1068,25 +1144,6 @@ export const moveDocument = createAction({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
@@ -1145,10 +1202,7 @@ export const restoreDocument = createAction({
: undefined;
const can = stores.policies.abilities(document.id);
return (
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !!collection?.isActive && !!(can.restore || can.unarchive);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
@@ -1185,10 +1239,7 @@ export const restoreDocumentToCollection = createActionWithChildren({
? stores.collections.get(document.collectionId)
: undefined;
return (
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !collection?.isActive && !!(can.restore || can.unarchive);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
@@ -1365,6 +1416,7 @@ export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
shortcut: [`Meta+Shift+I`],
icon: <GraphIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1372,12 +1424,7 @@ export const openDocumentInsights = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
return (
!!activeDocumentId &&
can.listViews &&
!document?.isTemplate &&
!document?.isDeleted
);
return !!activeDocumentId && can.listViews && !document?.isDeleted;
},
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
@@ -1456,6 +1503,7 @@ export const rootDocumentActions = [
archiveDocument,
createDocument,
createDraftDocument,
createNewDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
@@ -1477,7 +1525,6 @@ export const rootDocumentActions = [
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
+224
View File
@@ -0,0 +1,224 @@
import copy from "copy-to-clipboard";
import {
CaseSensitiveIcon,
CollectionIcon,
CopyIcon,
MoveIcon,
NewDocumentIcon,
PlusIcon,
PrintIcon,
TrashIcon,
} from "outline-icons";
import { Trans } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { newDocumentPath, newTemplatePath, urlify } from "~/utils/routeHelpers";
import { ActiveTemplateSection, TemplateSection } from "../sections";
import Template from "~/models/Template";
import { AvatarSize } from "~/components/Avatar";
import TeamLogo from "~/components/TeamLogo";
export const createTemplate = createInternalLinkAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: TemplateSection,
icon: <PlusIcon />,
keywords: "new create template",
visible: ({ currentTeamId, stores }) =>
!!stores.policies.abilities(currentTeamId!).createTemplate,
to: newTemplatePath(),
});
export const deleteTemplate = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete template",
section: ActiveTemplateSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: t("template"),
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await template.delete();
toast.success(t("Template deleted"));
}}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
values={{
templateName: template.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
},
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: ActiveTemplateSection,
icon: ({ stores }) => {
const { team } = stores.auth;
return <TeamLogo model={team} size={AvatarSize.Small} />;
},
visible: ({ getActiveModel }) => {
const template = getActiveModel(Template);
return !!template?.collectionId;
},
perform: async ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
try {
await template.save({ collectionId: null });
toast.success(t("Template moved"));
stores.dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldn't move the template, try again?"));
}
},
});
export const moveTemplateToCollection = createAction({
name: ({ t }) => t("Move to collection"),
analyticsName: "Move template to collection",
section: ActiveTemplateSection,
icon: <CollectionIcon />,
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Move template"),
content: <TemplateMove template={template} />,
});
},
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move template",
section: ActiveTemplateSection,
icon: <MoveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.move),
children: [moveTemplateToWorkspace, moveTemplateToCollection],
});
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document from template",
section: ActiveTemplateSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, getActiveModel, stores }) => {
const template = getActiveModel(Template);
if (!template || !currentTeamId) {
return false;
}
if (template.collectionId) {
return !!stores.policies.abilities(template.collectionId).createDocument;
}
return !!stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
const template = getActiveModel(Template);
if (!template) {
return "";
}
const collectionId = template?.collectionId ?? activeCollectionId;
const [pathname, search] = newDocumentPath(collectionId, {
templateId: template.id,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const copyTemplateLink = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy template link",
section: ActiveTemplateSection,
icon: <CopyIcon />,
iconInContextMenu: false,
perform: ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
copy(urlify(template.path));
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyTemplateAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
analyticsName: "Copy template as text",
section: ActiveTemplateSection,
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
perform: async ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
copy(ProsemirrorHelper.toPlainText(template));
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyTemplate = createActionWithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy template",
section: ActiveTemplateSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyTemplateLink, copyTemplateAsPlainText],
});
export const printTemplate = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
analyticsName: "Print template",
section: ActiveTemplateSection,
icon: <PrintIcon />,
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
perform: () => {
queueMicrotask(window.print);
},
});
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
+9
View File
@@ -24,6 +24,15 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
ActiveDocumentSection.priority = 0.9;
export const TemplateSection = ({ t }: ActionContext) => t("Template");
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
const activeTemplate = stores.templates.active;
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
};
ActiveTemplateSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
+2 -1
View File
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
@@ -109,4 +110,4 @@ const Image = styled.img<{ size: number }>`
height: ${(props) => props.size}px;
`;
export default Avatar;
export default observer(Avatar);
+2 -1
View File
@@ -1,4 +1,5 @@
import { GoToIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -121,4 +122,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
}
`;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
+112
View File
@@ -0,0 +1,112 @@
import * as RadixCollapsible from "@radix-ui/react-collapsible";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
interface CollapsibleProps {
/** The label displayed on the trigger button. */
label: React.ReactNode;
/** The content to show/hide inside the collapsible panel. */
children: React.ReactNode;
/** Whether the collapsible is open by default. */
defaultOpen?: boolean;
/** Controlled open state. */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: (open: boolean) => void;
/** Additional class name for the root element. */
className?: string;
}
/**
* An accessible collapsible section built on Radix UI Collapsible.
* Renders a trigger button with a disclosure chevron and animated content panel.
*
* @param props - component props.
* @returns the collapsible component.
*/
export function Collapsible({
label,
children,
defaultOpen = false,
open,
onOpenChange,
className,
}: CollapsibleProps) {
return (
<RadixCollapsible.Root
defaultOpen={defaultOpen}
open={open}
onOpenChange={onOpenChange}
className={className}
>
<StyledTrigger>
<StyledExpandedIcon aria-hidden="true" />
{label}
</StyledTrigger>
<StyledContent>{children}</StyledContent>
</RadixCollapsible.Root>
);
}
const StyledExpandedIcon = styled(ExpandedIcon)`
flex-shrink: 0;
transition: transform 150ms ease-out;
margin-left: -4px;
`;
const StyledTrigger = styled(RadixCollapsible.Trigger)`
display: flex;
align-items: center;
background: none;
border: none;
padding: 0 0 8px 0;
cursor: var(--pointer);
color: ${s("textTertiary")};
font-size: 14pxte
&:hover {
color: ${s("textSecondary")};
}
&[data-state="closed"] {
${StyledExpandedIcon} {
transform: rotate(-90deg);
}
}
`;
const StyledContent = styled(RadixCollapsible.Content)`
overflow: hidden;
&[data-state="open"] {
animation: slideDown 200ms ease-out;
}
&[data-state="closed"] {
animation: slideUp 200ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes slideUp {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
`;
+34 -27
View File
@@ -13,6 +13,7 @@ import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Input from "~/components/Input";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
@@ -144,7 +145,7 @@ export const CollectionForm = observer(function CollectionForm_({
<HStack>
<Input
type="text"
placeholder={t("Name")}
label={t("Name")}
{...register("name", {
required: true,
maxLength: CollectionValidation.maxNameLength,
@@ -189,38 +190,44 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
<Collapsible label={t("Advanced options")}>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t(
"Allow commenting on documents within this collection."
)}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
/>
</Collapsible>
)}
<HStack justify="flex-end">
@@ -11,15 +11,15 @@ import useStores from "~/hooks/useStores";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { documents } = useStores();
const { templates } = useStores();
useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
void templates.fetchAll();
}, [templates]);
const actions = useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
templates.alphabetical.map((template) =>
createInternalLinkAction({
name: template.titleWithDefault,
analyticsName: "New document",
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
},
})
),
[documents.templatesAlphabetical]
[templates.alphabetical]
);
const newFromTemplate = useMemo(
+105 -60
View File
@@ -1,8 +1,15 @@
import { HomeIcon } from "outline-icons";
import {
CollectionIcon as CollectionIconComponent,
HomeIcon,
PrivateCollectionIcon,
} from "outline-icons";
import { observer } from "mobx-react";
import { getLuminance } from "polished";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import useStores from "~/hooks/useStores";
@@ -12,74 +19,112 @@ type DefaultCollectionInputSelectProps = {
defaultCollectionId: string | null;
};
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
const DefaultCollectionInputSelect = observer(
({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections, ui } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
}
}
}
}
void fetchData();
}, [fetchError, t, fetching, collections]);
void fetchData();
}, [fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
if (fetching) {
return null;
}
const isDark = ui.resolvedTheme === "dark";
// Eagerly resolve collection icon properties within this observer context
// to avoid MobX warnings when Radix Select clones elements for the trigger.
const options: Option[] = collections.nonPrivate.reduce(
(acc, collection) => {
const collectionIcon = collection.icon;
const rawColor = collection.color ?? colorPalette[0];
let icon: React.ReactElement;
if (!collectionIcon || collectionIcon === "collection") {
const color =
isDark && rawColor !== "currentColor"
? getLuminance(rawColor) > 0.09
? rawColor
: "currentColor"
: rawColor;
const Component = collection.isPrivate
? PrivateCollectionIcon
: CollectionIconComponent;
icon = <Component color={color} />;
} else {
let color = rawColor;
if (color !== "currentColor") {
if (isDark) {
color = getLuminance(color) > 0.09 ? color : "currentColor";
} else {
color = getLuminance(color) < 0.9 ? color : "currentColor";
}
}
icon = (
<Icon
value={collectionIcon}
color={color}
initial={collection.initial}
forceColor
/>
);
}
return [
...acc,
{
type: "item",
type: "item" as const,
label: collection.name,
value: collection.id,
icon: <CollectionIcon collection={collection} />,
icon,
},
],
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
),
[collections.nonPrivate, t]
);
];
},
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
);
if (fetching) {
return null;
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
}
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
};
);
export default DefaultCollectionInputSelect;
+2 -9
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -11,7 +11,7 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { archivePath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
@@ -67,13 +67,6 @@ function DocumentBreadcrumb(
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkAction({
name: collection?.name,
section: ActiveDocumentSection,
@@ -0,0 +1,17 @@
import styled from "styled-components";
import Flex from "../Flex";
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
flex-shrink: 0;
`;
@@ -5,13 +5,13 @@ import { toast } from "sonner";
import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import Switch from "./Switch";
import Text from "./Text";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
/** The original document to duplicate */
@@ -37,13 +37,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
}, [policies, collectionTrees]);
const copy = async () => {
if (!selectedPath) {
@@ -80,34 +75,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
@@ -117,7 +110,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
) : (
t("Select a location to copy")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath || copying} onClick={copy}>
{copying ? `${t("Copying")}` : t("Copy")}
</Button>
@@ -19,8 +19,8 @@ import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
@@ -38,9 +38,17 @@ type Props = {
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
/** Whether to show child documents */
showDocuments?: boolean;
};
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
function DocumentExplorer({
onSubmit,
onSelect,
items,
defaultValue,
showDocuments,
}: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -141,7 +149,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -216,7 +225,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || nodes[node].type === "collection";
nodes[node].children.length > 0 || showDocuments !== false;
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -402,7 +411,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={`${t("Search collections & documents")}`}
placeholder={
showDocuments
? `${t("Search collections & documents")}`
: `${t("Search collections")}`
}
autoFocus
/>
<ListContainer>
@@ -54,6 +54,7 @@ function DocumentExplorerNode(
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
<Spacer width={width}>
{hasChildren && (
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
import { Node as SearchResult } from "./DocumentExplorerNode";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
@@ -54,6 +54,7 @@ function DocumentExplorerSearchResult({
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
{icon}
<Flex>
@@ -2,16 +2,14 @@ import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
document: Document;
@@ -44,21 +42,8 @@ function DocumentMove({ document }: Props) {
: true
);
// If the document we're moving is a template, only show collections as
// move targets.
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [
policies,
collectionTrees,
document.id,
document.parentDocumentId,
document.isTemplate,
]);
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
const move = async () => {
if (!selectedPath) {
@@ -92,7 +77,7 @@ function DocumentMove({ document }: Props) {
<FlexContainer column>
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
@@ -106,7 +91,7 @@ function DocumentMove({ document }: Props) {
) : (
t("Select a location to move")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath || moving} onClick={move}>
{moving ? `${t("Moving")}` : t("Move")}
</Button>
@@ -115,23 +100,4 @@ function DocumentMove({ document }: Props) {
);
}
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
export const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
export default observer(DocumentMove);
@@ -0,0 +1,87 @@
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
template: Template;
};
function TemplateMove({ template }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(
() =>
collectionTrees
.map((node) => ({ ...node, children: [] }))
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
),
[policies, collectionTrees]
);
const move = async () => {
if (!selectedPath) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = (selectedPath.collectionId ??
selectedPath.id) as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the template, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={move}
onSelect={selectPath}
showDocuments={false}
/>
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
);
}
export default observer(TemplateMove);
+3
View File
@@ -0,0 +1,3 @@
import DocumentExplorer from "./DocumentExplorer";
export default DocumentExplorer;
+5 -11
View File
@@ -39,7 +39,6 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -75,7 +74,6 @@ function DocumentListItem(
showCollection,
showPublished,
showDraft = true,
showTemplate,
highlight,
context,
...rest
@@ -83,7 +81,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const canStar = !document.isArchived;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
@@ -101,11 +99,10 @@ function DocumentListItem(
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
activeModels: [
document,
...(!isShared && document.collection ? [document.collection] : []),
],
}}
>
<ContextMenu
@@ -163,9 +160,6 @@ function DocumentListItem(
</Tooltip>
)}
{canStar && <StarButton document={document} />}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
+1 -2
View File
@@ -52,7 +52,6 @@ const DocumentMeta: React.FC<Props> = ({
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -142,7 +141,7 @@ const DocumentMeta: React.FC<Props> = ({
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const canShowProgressBar = isTasks;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -3,9 +3,11 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { IntegrationService } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -28,9 +30,11 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
urlObj.hostname === "linear.app"
? IntegrationService.Linear
: urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.GitLab;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -58,7 +62,18 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Flex wrap>
{labels.map((label, index) => (
@@ -3,8 +3,10 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -48,7 +50,18 @@ const HoverPreviewPullRequest = React.forwardRef(
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
</Flex>
</CardContent>
</Card>
+3
View File
@@ -42,6 +42,7 @@ export function toMenuItems(items: MenuItem[]) {
case "button":
return (
<MenuButton
id={item.id}
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -94,11 +95,13 @@ export function toMenuItems(items: MenuItem[]) {
return (
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
id={item.id}
label={item.title as string}
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent
id={item.id}
ref={parentRef}
onFocusOutside={preventCloseHandler}
>
+1 -1
View File
@@ -39,7 +39,7 @@ const Container = styled(Text)`
border-radius: 4px;
position: relative;
font-size: 14px;
margin: 1em 0 0;
margin: 1em 0;
svg {
flex-shrink: 0;
-1
View File
@@ -49,7 +49,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
/>
)}
+1 -1
View File
@@ -125,7 +125,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
}
return (
<ActionContextProvider value={{ activeDocumentId: document.id }}>
<ActionContextProvider value={{ activeModels: [document] }}>
<ContextMenu
action={contextMenuAction}
ariaLabel={t("Revision options")}
@@ -212,7 +212,9 @@ export const Suggestions = observer(
/>
)),
pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
(suggestionsWithPending.length > 0 || isEmpty) && (
<Separator key="separator" />
),
...suggestionsWithPending.map((suggestion) => (
<ListItem
keyboardNavigation
@@ -230,7 +232,9 @@ export const Suggestions = observer(
/>
)),
isEmpty && (
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
<Empty key="empty" style={{ marginTop: 22 }}>
{t("No matches")}
</Empty>
),
]}
</ArrowKeyNavigation>
@@ -127,7 +127,7 @@ const CollectionLink: React.FC<Props> = ({
});
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<ActionContextProvider value={{ activeModels: [collection] }}>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
@@ -143,7 +143,7 @@ const CollectionLink: React.FC<Props> = ({
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
$showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
@@ -416,7 +416,7 @@ function InnerDocumentLink(
return (
<ActionContextProvider
value={{
activeDocumentId: node.id,
activeModels: document ? [document] : [],
}}
>
<Relative ref={parentRef}>
@@ -451,7 +451,7 @@ function InnerDocumentLink(
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
$showActions={menuOpen}
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
@@ -170,7 +170,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
NotificationEventType.AddUserToDocument
).length > 0
}
showActions={menuOpen}
$showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
@@ -1,4 +1,5 @@
import { MoreIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { extraArea, hover, s } from "@shared/styles";
@@ -18,44 +19,46 @@ export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
children?: React.ReactNode;
};
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function SidebarButton_(
{
position = "top",
showMoreMenu,
image,
title,
children,
onClick,
...rest
}: SidebarButtonProps,
ref
) {
return (
<Container
justify="space-between"
align="center"
shrink={false}
$position={position}
>
<Button
{...rest}
onClick={onClick}
const SidebarButton = observer(
React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function SidebarButton_(
{
position = "top",
showMoreMenu,
image,
title,
children,
onClick,
...rest
}: SidebarButtonProps,
ref
) {
return (
<Container
justify="space-between"
align="center"
shrink={false}
$position={position}
as="button"
ref={ref}
role="button"
>
<Content>
{image}
{title && <Title>{title}</Title>}
</Content>
{showMoreMenu && <StyledMoreIcon />}
</Button>
{children}
</Container>
);
}
<Button
{...rest}
onClick={onClick}
$position={position}
as="button"
ref={ref}
role="button"
>
<Content>
{image}
{title && <Title>{title}</Title>}
</Content>
{showMoreMenu && <StyledMoreIcon />}
</Button>
{children}
</Container>
);
}
)
);
const StyledMoreIcon = styled(MoreIcon)`
@@ -40,7 +40,7 @@ type Props = Omit<NavLinkProps, "to"> & {
/** Whether to show an unread badge indicator */
unreadBadge?: boolean;
/** Whether to show action buttons on hover */
showActions?: boolean;
$showActions?: boolean;
/** Whether the link is disabled and non-interactive */
disabled?: boolean;
/** Whether the link is currently active */
@@ -81,7 +81,7 @@ function SidebarLink(
isActiveDrop,
isDraft,
menu,
showActions,
$showActions,
exact,
href,
depth,
@@ -183,7 +183,7 @@ function SidebarLink(
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</ContextMenu>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
{menu && <Actions $showActions={$showActions}>{menu}</Actions>}
</Link>
);
}
@@ -205,9 +205,9 @@ const Content = styled.span`
min-width: 0;
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
const Actions = styled(EventBoundary)<{ $showActions?: boolean }>`
display: inline-flex;
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
visibility: ${(props) => (props.$showActions ? "visible" : "hidden")};
position: absolute;
top: 3px;
right: 4px;
@@ -103,7 +103,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeModels: [document],
}}
>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
@@ -124,7 +124,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
$showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
+3 -2
View File
@@ -37,8 +37,9 @@ function Star({ size, document, collection, color, ...rest }: Props) {
return (
<ActionContextProvider
value={{
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
activeModels: [document, collection].filter(
(m): m is Document | Collection => !!m
),
}}
>
<NudeButton
+22 -3
View File
@@ -26,6 +26,7 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import usePrevious from "~/hooks/usePrevious";
import { transparentize } from "polished";
const HEADER_HEIGHT = 40;
@@ -234,7 +235,13 @@ function Table<TData>({
</TR>
);
return decorateRow ? decorateRow(row.original, baseRow) : baseRow;
return decorateRow ? (
<React.Fragment key={row.id}>
{decorateRow(row.original, baseRow)}
</React.Fragment>
) : (
baseRow
);
})}
</TBody>
{showPlaceholder && (
@@ -330,7 +337,8 @@ const THead = styled.div<{ $topPos: number }>`
color: ${s("textSecondary")};
font-weight: 500;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid
${(props) => transparentize(0.3, props.theme.divider)};
background: ${s("background")};
`;
@@ -344,12 +352,17 @@ const TR = styled.div<{ $columns: string }>`
display: grid;
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid
${(props) => transparentize(0.3, props.theme.divider)};
overflow: hidden;
&:last-child {
border-bottom: 0;
}
&:hover ${NudeButton}[aria-haspopup="menu"] {
opacity: 1;
}
`;
const TH = styled.span`
@@ -395,11 +408,17 @@ const TD = styled.span`
${NudeButton}[aria-haspopup="menu"] {
vertical-align: middle;
opacity: 0;
transition: opacity 100ms ease-in-out;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
&[aria-expanded="true"] {
opacity: 1;
}
}
`;
+30
View File
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import { TemplateForm } from "./TemplateForm";
import type Template from "~/models/Template";
type Props = {
template: Template;
onSubmit: () => void;
};
export const TemplateEdit = observer(function TemplateEdit_({
template,
onSubmit,
}: Props) {
const handleSubmit = useCallback(async () => {
try {
await template?.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+103
View File
@@ -0,0 +1,103 @@
import { observer } from "mobx-react";
import { InputIcon, ShapesIcon } from "outline-icons";
import React, { useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import type { ProsemirrorData } from "@shared/types";
import type Template from "~/models/Template";
import Editor from "~/scenes/Document/components/Editor";
import { DocumentContextProvider } from "~/components/DocumentContext";
import LoadingIndicator from "~/components/LoadingIndicator";
import Notice from "~/components/Notice";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
export const TemplateForm = observer(function TemplateForm_({
handleSubmit,
template,
}: {
handleSubmit: (template: Template) => void;
template: Template;
}) {
const { dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(template);
const dataRef = useRef(template.data);
const ref = useRef(null);
const [isUploading, handleStartUpload, handleStopUpload] = useBoolean();
const readOnly = !can.update && !template.isNew;
const handleChangeTitle = (title: string) => {
template.title = title;
};
const handleChangeIcon = (icon: string, color: string) => {
template.icon = icon;
template.color = color;
};
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
dataRef.current = value(false);
template.data = dataRef.current;
};
const handleSave = (options: { autosave?: boolean }) => {
if (options.autosave) {
return;
}
handleSubmit(template);
};
const handleCancel = () => {
dialogs.closeAllModals();
};
if (!template) {
return null;
}
return (
<DocumentContextProvider>
<React.Suspense fallback={null}>
{isUploading && <LoadingIndicator />}
<Notice
icon={<ShapesIcon />}
description={
<Trans>
Highlight some text and use the <PlaceholderIcon /> control to add
placeholders that can be filled out when creating new documents
</Trans>
}
>
{t("Youre editing a template")}
</Notice>
<Editor
id={template.id}
ref={ref}
isDraft={false}
document={template}
value={readOnly ? template.data : undefined}
defaultValue={template.data}
onFileUploadStart={handleStartUpload}
onFileUploadStop={handleStopUpload}
onChangeTitle={handleChangeTitle}
onChangeIcon={handleChangeIcon}
onSave={handleSave}
onCancel={handleCancel}
onChange={handleChange}
readOnly={readOnly}
canUpdate={can.update}
autoFocus={template.createdAt === template.updatedAt}
template
/>
</React.Suspense>
</DocumentContextProvider>
);
});
const PlaceholderIcon = styled(InputIcon)`
position: relative;
top: 6px;
margin-top: -6px;
`;
+36
View File
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import Template from "~/models/Template";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
type Props = {
collectionId?: string | null;
onSubmit?: () => void;
};
export const TemplateNew = observer(function TemplateNew_({
collectionId,
onSubmit,
}: Props) {
const { templates } = useStores();
const [template] = useState(
new Template({ title: "", collectionId }, templates)
);
const handleSubmit = useCallback(async () => {
try {
await template.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+6 -5
View File
@@ -8,7 +8,6 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Switch from "~/components/Switch";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
@@ -18,7 +17,7 @@ type Props = {
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const { documents, templates } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
@@ -28,15 +27,17 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
);
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
const template = await templates.templatize({
id: documentId,
collectionId,
publish,
});
if (template) {
history.push(documentPath(template));
history.push(template.path);
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId, publish]);
}, [t, templates, documentId, history, collectionId, publish]);
return (
<ConfirmationDialog
+13 -6
View File
@@ -23,18 +23,23 @@ const DrawerHandle = DrawerPrimitive.Handle;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
$hidden?: boolean;
}
>((props, ref) => {
const { children, ...rest } = props;
const { children, $hidden, ...rest } = props;
const [measureRef, bounds] = useMeasure();
return (
<DrawerPrimitive.Portal>
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
{!$hidden && (
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
)}
<DrawerPrimitive.Content ref={ref} asChild>
<StyledContent
$hidden={$hidden}
animate={{
height: bounds.height,
transition: { bounce: 0, duration: 0.2 },
@@ -76,7 +81,7 @@ const DrawerTitle = React.forwardRef<
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
/** Styled components. */
const StyledContent = styled(m.div)`
const StyledContent = styled(m.div)<{ $hidden?: boolean }>`
z-index: ${depths.menu};
position: fixed;
left: 0;
@@ -90,6 +95,8 @@ const StyledContent = styled(m.div)`
border-radius: 6px;
background: ${s("menuBackground")};
${({ $hidden }) => $hidden && "display: none;"}
`;
const StyledInnerContent = styled.div`
+79 -5
View File
@@ -1,11 +1,38 @@
import { createContext, useContext, useMemo } from "react";
import type { RefObject } from "react";
import {
createContext,
useContext,
useMemo,
useState,
useRef,
useCallback,
} from "react";
type MenuVariant = "dropdown" | "context";
type MenuVariant = "dropdown" | "context" | "inline";
const MenuContext = createContext<{
type MenuContextType = {
variant: MenuVariant;
}>({
activeSubmenu: string | null;
setActiveSubmenu: (id: string | null) => void;
submenuTriggerRefs: Record<string, RefObject<HTMLDivElement>>;
addSubmenuTriggerRef: (id: string, ref: RefObject<HTMLDivElement>) => void;
submenuContentRefs: Record<string, RefObject<HTMLDivElement | null>>;
addSubmenuContentRef: (
id: string,
ref: RefObject<HTMLDivElement | null>
) => void;
mainMenuRef: React.RefObject<HTMLDivElement>;
};
const MenuContext = createContext<MenuContextType>({
variant: "dropdown",
activeSubmenu: null,
setActiveSubmenu: () => {},
submenuTriggerRefs: {},
addSubmenuTriggerRef: () => {},
submenuContentRefs: {},
addSubmenuContentRef: () => {},
mainMenuRef: { current: null },
});
export function MenuProvider({
@@ -15,7 +42,54 @@ export function MenuProvider({
variant: MenuVariant;
children: React.ReactNode;
}) {
const ctx = useMemo(() => ({ variant }), [variant]);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const [submenuTriggerRefs, setSubmenuTriggerRefs] = useState<
Record<string, RefObject<HTMLDivElement>>
>({});
const [submenuContentRefs, setSubmenuContentRefs] = useState<
Record<string, RefObject<HTMLDivElement | null>>
>({});
const mainMenuRef = useRef<HTMLDivElement>(null);
const addSubmenuTriggerRef = useCallback(
(key: string, ref: RefObject<HTMLDivElement>) => {
setSubmenuTriggerRefs((prevRefs) => ({
...prevRefs,
[key]: ref,
}));
},
[setSubmenuTriggerRefs]
);
const addSubmenuContentRef = useCallback(
(key: string, ref: RefObject<HTMLDivElement | null>) => {
setSubmenuContentRefs((prevRefs) => ({
...prevRefs,
[key]: ref,
}));
},
[setSubmenuContentRefs]
);
const ctx = useMemo(
() => ({
variant,
activeSubmenu,
setActiveSubmenu,
submenuTriggerRefs,
addSubmenuTriggerRef,
submenuContentRefs,
addSubmenuContentRef,
mainMenuRef,
}),
[
variant,
activeSubmenu,
mainMenuRef,
submenuTriggerRefs,
addSubmenuTriggerRef,
submenuContentRefs,
addSubmenuContentRef,
]
);
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
}
+418 -25
View File
@@ -3,18 +3,31 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as Components from "../components/Menu";
import type { LocationDescriptor } from "history";
import * as React from "react";
import styled from "styled-components";
import Tooltip from "~/components/Tooltip";
import { CheckmarkIcon } from "outline-icons";
import { useMenuContext } from "./MenuContext";
import useMobile from "~/hooks/useMobile";
import { Drawer, DrawerContent } from "../Drawer";
import Scrollable from "~/components/Scrollable";
import { Portal as ReactPortal } from "~/components/Portal";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import { MenuType } from "@shared/editor/types";
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
import { useEditor } from "~/editor/components/EditorContext";
import type { EditorView } from "prosemirror-view";
type MenuProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Root
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
const Menu = ({ children, ...rest }: MenuProps) => {
const { variant } = useMenuContext();
if (variant === MenuType.inline) {
return <>{children}</>;
}
const Root =
variant === "dropdown"
? DropdownMenuPrimitive.Root
@@ -31,6 +44,10 @@ type SubMenuProps = React.ComponentPropsWithoutRef<
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
const { variant } = useMenuContext();
if (variant === MenuType.inline) {
return <div>{children}</div>;
}
const Sub =
variant === "dropdown"
? DropdownMenuPrimitive.Sub
@@ -68,16 +85,77 @@ MenuTrigger.displayName = "MenuTrigger";
type ContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
pos?: {
top: number;
left: number;
};
};
const MenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
| React.ElementRef<typeof ContextMenuPrimitive.Content>
| HTMLDivElement,
ContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { variant, mainMenuRef, activeSubmenu } = useMenuContext();
const isMobile = useMobile();
const { view } = useEditor();
const { children, ...rest } = props;
if (variant === MenuType.inline) {
const contentProps = {
maxHeightVar: "--radix-dropdown-menu-content-available-height",
transformOriginVar: "--radix-dropdown-menu-content-transform-origin",
};
const { pos } = props;
return isMobile ? (
<Drawer
open={true}
modal={false}
onOpenChange={(open) => {
if (!open) {
closeMenu(view);
}
}}
>
<DrawerContent $hidden={!!activeSubmenu} {...rest}>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DrawerContent>
</Drawer>
) : (
<ReactPortal>
<InlineMenuContentWrapper
ref={(node) => {
// Set the main menu ref for submenu positioning
if (mainMenuRef) {
(
mainMenuRef as React.MutableRefObject<HTMLElement | null>
).current = node;
}
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}
}}
{...contentProps}
{...rest}
hiddenScrollbars
style={{
top: pos?.top,
left: pos?.left,
}}
>
{children}
</InlineMenuContentWrapper>
</ReactPortal>
);
}
const Portal =
variant === "dropdown"
? DropdownMenuPrimitive.Portal
@@ -120,11 +198,45 @@ type SubMenuTriggerProps = BaseItemProps &
const SubMenuTrigger = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>
| HTMLDivElement,
SubMenuTriggerProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, ...rest } = props;
const { variant, setActiveSubmenu, addSubmenuTriggerRef } = useMenuContext();
const { label, icon, disabled, id, ...rest } = props;
const triggerRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
React.useEffect(() => {
if (id && triggerRef.current) {
addSubmenuTriggerRef(id, triggerRef);
}
}, [triggerRef, id, addSubmenuTriggerRef]);
if (variant === MenuType.inline) {
return (
<Components.MenuSubTrigger
ref={triggerRef}
disabled={disabled}
onClick={() => {
if (!disabled && id && isMobile) {
setActiveSubmenu(id);
}
}}
onMouseEnter={() => {
if (!disabled && id && !isMobile) {
setActiveSubmenu(id);
}
}}
>
{icon}
<Components.MenuLabel style={{ marginRight: 20 }}>
{label}
</Components.MenuLabel>
<Components.MenuDisclosure />
</Components.MenuSubTrigger>
);
}
const Trigger =
variant === "dropdown"
@@ -143,6 +255,12 @@ const SubMenuTrigger = React.forwardRef<
});
SubMenuTrigger.displayName = "SubMenuTrigger";
const MARGIN_RIGHT_FOR_UX = 20; // Margin for better UX
const NESTED_OFFSET_LEFT = 95; // Offset for nested submenu when it renders on the left
const TOP_OFFSET_LEFT = 75; // Offset for top submenu when it renders on the left
const NESTED_OFFSET_RIGHT = 75; // Offset for nested submenu when it renders on the right
const TOP_OFFSET_RIGHT = 65; // Offset for top submenu when it renders on the right
type SubMenuContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubContent
> &
@@ -150,11 +268,166 @@ type SubMenuContentProps = React.ComponentPropsWithoutRef<
const SubMenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>
| HTMLDivElement,
SubMenuContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const submenuRef = React.useRef<HTMLDivElement | null>(null);
const {
variant,
activeSubmenu,
submenuTriggerRefs,
submenuContentRefs,
addSubmenuContentRef,
mainMenuRef,
setActiveSubmenu,
} = useMenuContext();
const { children, id, ...rest } = props;
const [position, setPosition] = React.useState({ top: 0, left: 0 });
const isMobile = useMobile();
React.useEffect(() => {
if (id) {
addSubmenuContentRef(id, submenuRef);
}
}, [id, addSubmenuContentRef]);
const handleClickOutside = React.useCallback(
(event: MouseEvent | TouchEvent) => {
const isInsideDescendant =
id &&
Object.entries(submenuContentRefs).some(
([refId, contentRef]) =>
refId !== id &&
refId.startsWith(id + "-") &&
contentRef.current?.contains(event.target as Node)
);
if (isInsideDescendant) {
return;
}
// Walk up the id hierarchy to find the deepest ancestor submenu containing the click.
let targetSubmenu: string | null = null;
if (id) {
const parts = id.split("-");
for (let len = parts.length - 1; len >= 2; len--) {
const ancestorId = parts.slice(0, len).join("-");
const ancestorRef = submenuContentRefs[ancestorId];
if (ancestorRef?.current?.contains(event.target as Node)) {
targetSubmenu = ancestorId;
break;
}
}
}
setActiveSubmenu(targetSubmenu);
},
[id, submenuContentRefs, setActiveSubmenu]
);
// the submenu drawer handles its own click outside logic
useOnClickOutside(submenuRef, isMobile ? undefined : handleClickOutside);
React.useEffect(() => {
const trigger = submenuTriggerRefs[id ?? ""];
if (trigger?.current) {
const triggerRect = trigger.current.getBoundingClientRect();
const parentId = id ? getParentSubmenuId(id) : null;
const anchorRect = (
parentId ? submenuContentRefs[parentId]?.current : mainMenuRef.current
)?.getBoundingClientRect();
const subMenuRect = submenuRef.current?.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const spaceOnRight = viewportWidth - triggerRect.right;
const anchorWidth = anchorRect?.width;
const submenuWidth = subMenuRect?.width;
const offsetLeft = parentId ? NESTED_OFFSET_LEFT : TOP_OFFSET_LEFT;
const offsetRight = parentId ? NESTED_OFFSET_RIGHT : TOP_OFFSET_RIGHT;
let left = triggerRect.left - offsetLeft;
// Check if there's enough space on the right
if (
submenuWidth &&
anchorWidth &&
spaceOnRight < submenuWidth + MARGIN_RIGHT_FOR_UX
) {
left = triggerRect.left - submenuWidth - anchorWidth - offsetRight;
}
setPosition({
top: triggerRect.top,
left,
});
}
}, [
variant,
activeSubmenu,
submenuTriggerRefs,
mainMenuRef,
id,
submenuContentRefs,
]);
if (variant === MenuType.inline) {
const isVisible =
activeSubmenu === id ||
(id !== undefined && activeSubmenu?.startsWith(id + "-"));
if (!isVisible) {
return null;
}
const contentProps = {
maxHeightVar: "--inline-menu-max-height",
transformOriginVar: "--inline-menu-transform-origin",
};
if (isMobile) {
if (activeSubmenu !== id) {
return <>{children}</>;
}
return (
<SubMenuDrawer
setActiveSubmenu={setActiveSubmenu}
submenuRef={submenuRef}
forwardedRef={ref}
{...rest}
>
{children}
</SubMenuDrawer>
);
}
return (
<ReactPortal>
<InlineMenuContentWrapper
ref={(node) => {
submenuRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}
}}
{...contentProps}
{...rest}
hiddenScrollbars
style={{
top: position.top,
left: position.left,
zIndex: 1001,
}}
>
{children}
</InlineMenuContentWrapper>
</ReactPortal>
);
}
const Portal =
variant === "dropdown"
@@ -203,7 +476,8 @@ type MenuGroupProps = {
const MenuGroup = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
| React.ElementRef<typeof ContextMenuPrimitive.Group>
| HTMLDivElement,
MenuGroupProps
>((props, ref) => {
const { variant } = useMenuContext();
@@ -224,6 +498,7 @@ const MenuGroup = React.forwardRef<
MenuGroup.displayName = "MenuGroup";
type BaseItemProps = {
id?: string;
label: string;
icon?: React.ReactElement;
disabled?: boolean;
@@ -248,7 +523,9 @@ const MenuButton = React.forwardRef<
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuButtonProps
>((props, ref) => {
const { variant } = useMenuContext();
const { variant, activeSubmenu, setActiveSubmenu } = useMenuContext();
const { view } = useEditor();
const [active, setActive] = React.useState(false);
const {
label,
icon,
@@ -260,28 +537,63 @@ const MenuButton = React.forwardRef<
...rest
} = props;
const buttonContent = (
<>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
{selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon size={18} /> : null}
</Components.SelectedIconWrapper>
)}
</>
);
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
const button = (
<Item ref={ref} disabled={disabled} {...rest} asChild>
const handleMouseEnter = React.useCallback(() => {
setActive(true);
if (props.id) {
// Close any nested submenu that is deeper than this button's parent level.
const parentId = getParentSubmenuId(props.id);
if (activeSubmenu && activeSubmenu !== parentId) {
setActiveSubmenu(parentId);
}
} else if (activeSubmenu) {
setActiveSubmenu(null);
}
}, [setActive, props.id, activeSubmenu, setActiveSubmenu]);
const button =
variant === MenuType.inline ? (
<Components.MenuButton
ref={ref as React.Ref<HTMLButtonElement>}
disabled={disabled}
$dangerous={dangerous}
onClick={onClick}
$active={active}
onClick={(e) => {
onClick(e);
closeMenu(view);
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setActive(false)}
>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
{selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon size={18} /> : null}
</Components.SelectedIconWrapper>
)}
{buttonContent}
</Components.MenuButton>
</Item>
);
) : (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuButton
disabled={disabled}
$dangerous={dangerous}
onClick={onClick}
>
{buttonContent}
</Components.MenuButton>
</Item>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
@@ -375,11 +687,16 @@ type MenuSeparatorProps = React.ComponentPropsWithoutRef<
const MenuSeparator = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
| React.ElementRef<typeof ContextMenuPrimitive.Separator>
| HTMLDivElement,
MenuSeparatorProps
>((props, ref) => {
const { variant } = useMenuContext();
if (variant === MenuType.inline) {
return <Components.MenuSeparator ref={ref as React.Ref<HTMLHRElement>} />;
}
const Separator =
variant === "dropdown"
? DropdownMenuPrimitive.Separator
@@ -419,6 +736,82 @@ const MenuLabel = React.forwardRef<
});
MenuLabel.displayName = "MenuLabel";
const DRAWER_ANIMATION_DURATION_MS = 300;
type SubMenuDrawerProps = React.HTMLAttributes<HTMLDivElement> & {
setActiveSubmenu: (id: string | null) => void;
submenuRef: React.MutableRefObject<HTMLDivElement | null>;
forwardedRef: React.ForwardedRef<HTMLDivElement>;
children: React.ReactNode;
};
const SubMenuDrawer = ({
setActiveSubmenu,
submenuRef,
forwardedRef,
children,
...rest
}: SubMenuDrawerProps) => {
const [isOpen, setIsOpen] = React.useState(true);
const { view } = useEditor();
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (!open) {
setIsOpen(false);
// Let slide-down animation play out before tearing down the tree.
setTimeout(() => {
setActiveSubmenu(null);
closeMenu(view);
}, DRAWER_ANIMATION_DURATION_MS);
}
},
[setActiveSubmenu, view]
);
useOnClickOutside(submenuRef, () => handleOpenChange(false));
return (
<Drawer open={isOpen} modal={false} onOpenChange={handleOpenChange}>
<DrawerContent
ref={(node) => {
submenuRef.current = node;
if (typeof forwardedRef === "function") {
forwardedRef(node);
} else if (forwardedRef) {
(
forwardedRef as React.MutableRefObject<HTMLDivElement | null>
).current = node;
}
}}
{...rest}
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DrawerContent>
</Drawer>
);
};
const getParentSubmenuId = (id: string): string | null => {
const parts = id.split("-");
return parts.length > 2 ? parts.slice(0, -1).join("-") : null;
};
const closeMenu = (view: EditorView) => {
collapseSelection()(view.state, view.dispatch);
};
const InlineMenuContentWrapper = styled(Components.MenuContent)`
position: absolute;
height: fit-content;
z-index: 1000;
`;
// Styled scrollable for mobile drawer content
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export {
Menu,
MenuTrigger,
@@ -107,6 +107,25 @@ export const MenuExternalLink = styled.a`
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
${BaseMenuItemCSS}
${(props) =>
!props.disabled &&
`
&:hover {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.$dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg:not([data-fixed-color]) {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
}
`}
`;
export const MenuSeparator = styled.hr`
+2 -1
View File
@@ -1,4 +1,5 @@
import capitalize from "lodash/capitalize";
import { observer } from "mobx-react";
import { useCallback, useMemo, useEffect } from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
@@ -76,4 +77,4 @@ const EmojiMenu = (props: Props) => {
);
};
export default EmojiMenu;
export default observer(EmojiMenu);
+4
View File
@@ -375,6 +375,10 @@ export default function FindAndReplace({
minWidth={420}
scrollable={false}
onPointerDownOutside={() => setLocalOpen(false)}
onFocusOutside={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
style={{ marginRight: 16, marginTop: 60 }}
>
<Content column>
+16 -10
View File
@@ -36,14 +36,16 @@ const defaultPosition = {
visible: false,
};
function usePosition({
export function usePosition({
menuRef,
active,
align = "center",
inline = false,
}: {
menuRef: React.RefObject<HTMLDivElement>;
active?: boolean;
align?: Props["align"];
inline?: boolean;
}) {
const { view } = useEditor();
const { selection } = view.state;
@@ -120,13 +122,14 @@ function usePosition({
selection instanceof ColumnSelection && selection.isColSelection();
const isRowSelection =
selection instanceof RowSelection && selection.isRowSelection();
let colWidth = 0;
if (isTableSelected(view.state)) {
const rect = selectedRect(view.state);
const table = view.domAtPos(rect.tableStart);
const bounds = (table.node as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top - 16;
selectionBounds.left = bounds.left - 10;
selectionBounds.top = bounds.top - (inline ? 160 : 16);
selectionBounds.left = bounds.left;
selectionBounds.right = bounds.left - 10;
} else if (isColSelection) {
const rect = selectedRect(view.state);
@@ -136,6 +139,7 @@ function usePosition({
);
if (element instanceof HTMLElement) {
const bounds = element.getBoundingClientRect();
colWidth = bounds.width;
selectionBounds.top = bounds.top - 16;
selectionBounds.left = bounds.left;
selectionBounds.right = bounds.right;
@@ -148,8 +152,8 @@ function usePosition({
);
if (element instanceof HTMLElement) {
const bounds = element.getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.left - 10;
selectionBounds.top = bounds.top + (inline ? 55 : 0);
selectionBounds.left = bounds.left - (inline ? 410 : 10);
selectionBounds.right = bounds.left - 10;
}
}
@@ -198,11 +202,13 @@ function usePosition({
),
Math.max(
Math.max(offsetParent.x, margin),
align === "center"
? centerOfSelection - menuWidth / 2
: align === "start"
? selectionBounds.left
: selectionBounds.right
isColSelection && colWidth < 300
? selectionBounds.right + margin
: align === "center"
? centerOfSelection - menuWidth / 2
: align === "start"
? selectionBounds.left
: selectionBounds.right
)
);
const top = Math.max(
+110
View File
@@ -0,0 +1,110 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Portal } from "~/components/Portal";
import { Menu } from "~/components/primitives/Menu";
import type { MenuItem } from "@shared/editor/types";
import { MenuContent } from "~/components/primitives/Menu";
import { toMenuItems } from "~/components/Menu/transformer";
import EventBoundary from "@shared/components/EventBoundary";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { mapMenuItems } from "./ToolbarMenu";
import { useEditor } from "./EditorContext";
import { useTranslation } from "react-i18next";
import { usePosition } from "./FloatingToolbar";
import useMobile from "~/hooks/useMobile";
type Props = {
items: MenuItem[];
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
};
/*
* Renders an inline menu in the floating toolbar, which does not require a trigger.
*/
const InlineMenu: React.FC<Props> = ({ items, containerRef }) => {
const { t } = useTranslation();
const { commands, view } = useEditor();
const fallbackRef = useRef<HTMLDivElement | null>(null);
const menuRef = containerRef || fallbackRef;
const isMobile = useMobile();
const [pos, setPos] = useState({ top: 0, left: 0 });
const position = usePosition({
menuRef,
active: true,
inline: true,
});
useEffect(() => {
const viewportWidth = window.innerWidth;
const menuRect = menuRef.current?.getBoundingClientRect();
let left = position.left;
if (menuRef.current && menuRect) {
const spaceOnRight = viewportWidth - left;
if (spaceOnRight < menuRect.right) {
left = left - spaceOnRight; // double the space on the right
}
}
setPos((prevPos) => {
if (prevPos.top !== position.top || prevPos.left !== left) {
return {
top: position.top,
left,
};
}
return prevPos;
});
}, [menuRef, position]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
const mappedItems = useMemo(
() =>
items.map((item) => {
const children =
typeof item.children === "function" ? item.children() : item.children;
return {
...item,
children: children
? mapMenuItems(children, commands, view.state)
: [],
};
}),
[items, commands, view.state]
);
const content = (
<MenuProvider variant="inline">
<Menu>
<MenuContent
pos={pos}
align="end"
aria-label={t("Options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
<EventBoundary>
{mappedItems.map((item) => (
<React.Fragment key={item.id}>
{toMenuItems(item.children || [])}
</React.Fragment>
))}
</EventBoundary>
</MenuContent>
</Menu>
</MenuProvider>
);
return isMobile ? content : <Portal>{content}</Portal>;
};
export default InlineMenu;
+153 -157
View File
@@ -44,7 +44,6 @@ type Props = Omit<
function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const [items, setItems] = useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections, groups } = useStores();
const actorId = auth.currentUserId;
@@ -76,164 +75,161 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
useEffect(() => {
if (actorId && !loading) {
const items: MentionItem[] = users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={user}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
),
title: user.name,
section: UserSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.User,
modelId: user.id,
actorId,
label: user.name,
},
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
subtitle: t("{{ count }} members", { count: group.memberCount }),
section: GroupSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(doc) =>
({
name: "mention",
icon: doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collectionId ? (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
) : undefined,
section: DocumentsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
label: doc.title,
},
}) as MentionItem
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
initial={collection.initial}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
}) as MentionItem
)
)
.concat([
{
name: "link",
icon: <PlusIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a new doc"),
visible: !!search && !isEmail(search),
priority: -1,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
},
} as MentionItem,
]);
setItems(items);
setLoaded(true);
}
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
groups,
collections,
]);
}, [actorId, loading]);
// Computed in the render body so MobX observer can track store access
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
// runs outside the reactive context and triggered MobX warnings.
const items: MentionItem[] =
actorId && !loading
? users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={user}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
),
title: user.name,
section: UserSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.User,
modelId: user.id,
actorId,
label: user.name,
},
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
subtitle: t("{{ count }} members", {
count: group.memberCount,
}),
section: GroupSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(doc) =>
({
name: "mention",
icon: doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collectionId ? (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
) : undefined,
section: DocumentsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
label: doc.title,
},
}) as MentionItem
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
initial={collection.initial}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
}) as MentionItem
)
)
.concat([
{
name: "link",
icon: <PlusIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a new doc"),
visible: !!search && !isEmail(search),
priority: -1,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
},
} as MentionItem,
])
: [];
const handleSelect = useCallback(
async (item: MentionItem) => {
+31 -1
View File
@@ -15,7 +15,7 @@ import {
getRowIndex,
isTableSelected,
} from "@shared/editor/queries/table";
import type { MenuItem } from "@shared/editor/types";
import { MenuType, type MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
@@ -40,6 +40,9 @@ import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
import { isModKey } from "@shared/utils/keyboard";
import InlineMenu from "./InlineMenu";
import styled from "styled-components";
import { depths } from "@shared/styles";
type Props = {
/** Whether the text direction is right-to-left */
@@ -269,6 +272,8 @@ export function SelectionToolbar(props: Props) {
items = getFormattingMenuItems(state, isTemplate, dictionary);
}
const isInline = items[0].type === MenuType.inline;
// Some extensions may be disabled, remove corresponding items
items = items.filter((item) => {
if (item.name === "separator") {
@@ -315,6 +320,14 @@ export function SelectionToolbar(props: Props) {
setActiveToolbar(null);
};
if (isInline && items.length) {
return (
<InlineMenuWrapper ref={menuRef}>
<InlineMenu items={items} containerRef={menuRef} />
</InlineMenuWrapper>
);
}
return (
<FloatingToolbar
align={align}
@@ -359,3 +372,20 @@ export function SelectionToolbar(props: Props) {
</FloatingToolbar>
);
}
const InlineMenuWrapper = styled.div`
position: absolute;
z-index: ${depths.editorToolbar};
line-height: 0;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
* {
box-sizing: border-box;
}
@media print {
display: none;
}
`;
+75 -60
View File
@@ -48,67 +48,10 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
return [];
}
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
}
if (commands[menuItem.name]) {
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (menuItem.onClick) {
menuItem.onClick();
}
};
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
if ("content" in child) {
return {
type: "custom",
visible: child.visible,
content: child.content,
};
}
const resolvedChildren = resolveChildren(child.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(c) => "preventCloseCondition" in c
);
return {
type: "submenu",
title: child.label,
icon: child.icon,
visible: child.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapChildren(resolvedChildren),
};
}
return {
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
});
const resolvedItemChildren = resolveChildren(item.children);
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
return resolvedItemChildren
? mapMenuItems(resolvedItemChildren, commands, state)
: [];
}, [isOpen, commands]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
@@ -220,6 +163,78 @@ function ToolbarMenu(props: Props) {
);
}
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
export const mapMenuItems = (
children: MenuItem[],
commands: Record<string, Function>,
state: any,
parentId = "0"
): TMenuItem[] => {
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
}
if (commands[menuItem.name]) {
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (menuItem.onClick) {
menuItem.onClick();
}
};
return children.map((child, idx) => {
const id = `${parentId}-${idx}`;
if (child.name === "separator") {
return { id, type: "separator", visible: child.visible };
}
if ("content" in child) {
return {
id,
type: "custom",
visible: child.visible,
content: child.content,
};
}
const resolvedChildren = resolveChildren(child.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(c) => "preventCloseCondition" in c
);
return {
id,
type: "submenu",
title: child.label || child.tooltip,
icon: child.icon,
visible: child.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapMenuItems(resolvedChildren, commands, state, id),
};
}
return {
id,
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected: child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
});
};
const FlexibleWrapper = styled.div`
color: ${s("textSecondary")};
overflow: hidden;
+9 -1
View File
@@ -102,6 +102,10 @@ export default class FindAndReplaceExtension extends Extension {
// have changed underneath us since the last search.
this.search(state.doc);
if (this.currentResultIndex >= this.results.length) {
return false;
}
const result = this.results[this.currentResultIndex];
if (!result) {
@@ -220,6 +224,10 @@ export default class FindAndReplaceExtension extends Extension {
* Expand any folded toggle blocks that contain the current match.
*/
private expandFoldedTogglesForCurrentMatch() {
if (this.currentResultIndex >= this.results.length) {
return;
}
const result = this.results[this.currentResultIndex];
if (!result) {
return;
@@ -272,7 +280,7 @@ export default class FindAndReplaceExtension extends Extension {
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
const nextIndex = index + 1;
if (!this.results[nextIndex]) {
if (nextIndex >= this.results.length) {
return undefined;
}
+37 -31
View File
@@ -7,7 +7,7 @@ import {
import type { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import type { MenuItem } from "@shared/editor/types";
import { TableLayout } from "@shared/editor/types";
import { MenuType, TableLayout } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
export default function tableMenuItems(
@@ -26,36 +26,42 @@ export default function tableMenuItems(
return [
{
name: "setTableAttr",
tooltip: isFullWidth
? dictionary.alignDefaultWidth
: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
active: () => isFullWidth,
},
{
name: "distributeColumns",
tooltip: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "deleteTable",
tooltip: dictionary.deleteTable,
icon: <TrashIcon />,
},
{
name: "separator",
},
{
name: "exportTable",
tooltip: dictionary.exportAsCSV,
label: "CSV",
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
type: MenuType.inline,
children: [
{
name: "setTableAttr",
label: isFullWidth
? dictionary.alignDefaultWidth
: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
attrs: isFullWidth
? { layout: null }
: { layout: TableLayout.fullWidth },
},
{
name: "distributeColumns",
label: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "exportTable",
label: dictionary.exportAsCSV,
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
},
{
name: "separator",
},
{
name: "deleteTable",
dangerous: true,
label: dictionary.deleteTable,
icon: <TrashIcon />,
},
],
},
];
}
+130 -108
View File
@@ -5,7 +5,6 @@ import {
AlignCenterIcon,
InsertLeftIcon,
InsertRightIcon,
MoreIcon,
PaletteIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
@@ -24,7 +23,11 @@ import {
isMultipleCellSelection,
tableHasRowspan,
} from "@shared/editor/queries/table";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import {
MenuType,
type MenuItem,
type NodeAttrMark,
} from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
@@ -88,119 +91,138 @@ export default function tableColMenuItems(
return [
{
name: "setColumnAttr",
tooltip: dictionary.alignLeft,
icon: <AlignLeftIcon />,
attrs: { index, alignment: "left" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "left",
}),
},
{
name: "setColumnAttr",
tooltip: dictionary.alignCenter,
icon: <AlignCenterIcon />,
attrs: { index, alignment: "center" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "center",
}),
},
{
name: "setColumnAttr",
tooltip: dictionary.alignRight,
icon: <AlignRightIcon />,
attrs: { index, alignment: "right" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "right",
}),
},
{
name: "separator",
},
{
name: "sortTable",
tooltip: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "sortTable",
tooltip: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <SortDescendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "separator",
},
{
tooltip: dictionary.background,
icon:
colColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : colColors.size === 1 ? (
<CircleIcon color={colColors.values().next().value} />
) : (
<PaletteIcon />
),
type: MenuType.inline,
children: [
...[
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleColumnBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => colColors.size === 1 && colColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
name: "setColumnAttr",
label: dictionary.align,
icon: <AlignCenterIcon />,
attrs: { index, alignment: "left" },
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleColumnBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
name: "setColumnAttr",
label: dictionary.alignLeft,
icon: <AlignLeftIcon />,
attrs: { index, alignment: "left" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "left",
}),
},
{
name: "setColumnAttr",
label: dictionary.alignCenter,
icon: <AlignCenterIcon />,
attrs: { index, alignment: "center" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "center",
}),
},
{
name: "setColumnAttr",
label: dictionary.alignRight,
icon: <AlignRightIcon />,
attrs: { index, alignment: "right" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "right",
}),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
{
name: "separator",
},
{
name: "sortTable",
label: dictionary.sort,
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
children: [
{
name: "sortTable",
label: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "sortTable",
label: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <SortDescendingIcon />,
disabled: tableHasRowspan(state),
},
],
},
{
name: "separator",
},
{
label: dictionary.background,
icon:
colColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : colColors.size === 1 ? (
<CircleIcon color={colColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleColumnBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => colColors.size === 1 && colColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleColumnBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
{
name: "separator",
},
{
name: "toggleHeaderColumn",
label: dictionary.toggleHeader,
+57 -54
View File
@@ -2,7 +2,6 @@ import {
TrashIcon,
InsertAboveIcon,
InsertBelowIcon,
MoreIcon,
PaletteIcon,
TableHeaderRowIcon,
TableSplitCellsIcon,
@@ -15,7 +14,11 @@ import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import {
MenuType,
type MenuItem,
type NodeAttrMark,
} from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
@@ -77,66 +80,66 @@ export default function tableRowMenuItems(
return [
{
tooltip: dictionary.background,
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : rowColors.size === 1 ? (
<CircleIcon color={rowColors.values().next().value} />
) : (
<PaletteIcon />
),
type: MenuType.inline,
children: [
...[
{
name: "toggleRowBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleRowBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
label: dictionary.background,
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : rowColors.size === 1 ? (
<CircleIcon color={rowColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleRowBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
],
...TableCell.presetColors.map((preset) => ({
name: "toggleRowBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleRowBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleRowBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleRowBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
{
name: "toggleHeaderRow",
label: dictionary.toggleHeader,
+53 -35
View File
@@ -7,14 +7,25 @@ import useStores from "~/hooks/useStores";
import type Model from "~/models/base/Model";
import type Policy from "~/models/Policy";
import type { ActionContext as ActionContextType } from "~/types";
import type { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
export const ActionContext = createContext<ActionContextType | undefined>(
undefined
);
interface ActionContextProviderValue {
/** Models to add to the active models context for this subtree. */
activeModels?: Model[];
isMenu?: boolean;
isCommandBar?: boolean;
isButton?: boolean;
sidebarContext?: SidebarContextType;
event?: Event;
}
type ActionContextProviderProps = {
children: ReactNode;
value?: Partial<ActionContextType>;
value?: ActionContextProviderValue;
};
/**
@@ -23,15 +34,15 @@ type ActionContextProviderProps = {
*
* @example
* ```tsx
* // Override context for a command bar
* <ActionContextProvider value={{ isCommandBar: true }}>
* <CommandBar />
* // Override active models for a collection menu
* <ActionContextProvider value={{ activeModels: [collection] }}>
* <CollectionMenu />
* </ActionContextProvider>
*
* // Nested overrides
* <ActionContextProvider value={{ activeCollectionId: "collection-1" }}>
* <ActionContextProvider value={{ activeModels: [collection] }}>
* <CollectionView />
* <ActionContextProvider value={{ activeDocumentId: "doc-1" }}>
* <ActionContextProvider value={{ activeModels: [document] }}>
* <DocumentView />
* </ActionContextProvider>
* </ActionContextProvider>
@@ -45,6 +56,7 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const { activeModels: valueModels, ...overrides } = value;
// Create the base context if we don't have a parent context
const baseContext: ActionContextType = parentContext ?? {
@@ -56,7 +68,6 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
// New API
getActiveModels: <T extends Model>(
modelClass: new (...args: any[]) => T
): T[] => stores.ui.getActiveModels<T>(modelClass),
@@ -83,33 +94,18 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
t,
};
// Merge the parent context with the provided overrides
const activeCollectionId =
value.activeCollectionId ?? baseContext.activeCollectionId;
const activeDocumentId =
value.activeDocumentId ?? baseContext.activeDocumentId;
const getActiveModels = <T extends Model>(
modelClass: new (...args: any[]) => T
): T[] => {
// @ts-expect-error modelName
if (activeCollectionId && modelClass.modelName === "Collection") {
const model = stores.collections.get(activeCollectionId);
if (model) {
return [model as unknown as T];
}
}
// @ts-expect-error modelName
if (activeDocumentId && modelClass.modelName === "Document") {
const model = stores.documents.get(activeDocumentId);
if (model) {
return [model as unknown as T];
}
}
return baseContext.getActiveModels(modelClass);
};
// Override model accessors when models are provided in value
const getActiveModels =
valueModels && valueModels.length > 0
? <T extends Model>(modelClass: new (...args: any[]) => T): T[] => {
const matching = valueModels.filter(
(model): model is T => model instanceof modelClass
);
return matching.length > 0
? matching
: baseContext.getActiveModels(modelClass);
}
: baseContext.getActiveModels;
const getActiveModel = <T extends Model>(
modelClass: new (...args: any[]) => T
@@ -122,12 +118,34 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined);
const allActiveModels =
valueModels && valueModels.length > 0
? new Set([...baseContext.activeModels, ...valueModels])
: baseContext.activeModels;
const isModelActive = (model: Model): boolean => allActiveModels.has(model);
// Derive legacy IDs from value models, falling back to base context
const activeCollectionId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Collection"
)?.id ?? baseContext.activeCollectionId;
const activeDocumentId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Document"
)?.id ?? baseContext.activeDocumentId;
const contextValue: ActionContextType = {
...baseContext,
...value,
...overrides,
activeCollectionId,
activeDocumentId,
getActiveModels,
getActiveModel,
getActivePolicies,
isModelActive,
activeModels: allActiveModels,
};
return (
+2
View File
@@ -19,6 +19,7 @@ export default function useDictionary() {
moveColumnRight: t("Move right"),
addRowAfter: t("Insert after"),
addRowBefore: t("Insert before"),
align: t("Align"),
alignCenter: t("Align center"),
alignLeft: t("Align left"),
alignRight: t("Align right"),
@@ -93,6 +94,7 @@ export default function useDictionary() {
strikethrough: t("Strikethrough"),
strong: t("Bold"),
subheading: t("Subheading"),
sort: t("Sort"),
sortAsc: t("Sort ascending"),
sortDesc: t("Sort descending"),
table: t("Table"),
+5 -9
View File
@@ -11,7 +11,7 @@ import {
unstarDocument,
editDocument,
shareDocument,
createNestedDocument,
createNewDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
@@ -19,10 +19,8 @@ import {
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory,
pinDocument,
createDocumentFromTemplate,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
@@ -36,7 +34,7 @@ import {
} from "~/actions/definitions/documents";
import { ActiveDocumentSection } from "~/actions/sections";
import useMobile from "./useMobile";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import usePolicy from "./usePolicy";
import useCurrentUser from "./useCurrentUser";
import { useTemplateMenuActions } from "./useTemplateMenuActions";
@@ -50,7 +48,7 @@ type Props = {
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
};
export function useDocumentMenuAction({
@@ -94,18 +92,16 @@ export function useDocumentMenuAction({
perform: () => requestAnimationFrame(() => onRename?.()),
}),
shareDocument,
createNestedDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
publishDocument,
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory({ actions: templateMenuActions }),
importDocument,
createNewDocument,
pinDocument,
createDocumentFromTemplate,
ActionSeparator,
openDocumentComments,
openDocumentHistory,
+4
View File
@@ -45,6 +45,8 @@ export default function useImportDocument(
}
for (const file of files) {
const toastId = toast.loading(`${t("Uploading")}`);
try {
const doc = await documents.import(file, documentId, cId, {
publish: true,
@@ -55,6 +57,8 @@ export default function useImportDocument(
}
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
}
} catch (err) {
+19 -2
View File
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { getCookie, removeCookie, setCookie } from "tiny-cookie";
import usePersistedState from "~/hooks/usePersistedState";
import usePersistedState, { setPersistedState } from "~/hooks/usePersistedState";
import Logger from "~/utils/Logger";
import history from "~/utils/history";
import { isAllowedLoginRedirect } from "~/utils/urls";
@@ -30,6 +30,23 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
return [lastVisitedPath, setPathAsLastVisitedPath] as const;
}
/**
* Hook that automatically tracks the current path as the last visited path.
* This uses a ref to track the previous path and updates localStorage directly
* without using useEffect to avoid React Doctor warnings.
*
* @param currentPath The current path to track.
*/
export function useTrackLastVisitedPath(currentPath: string): void {
const prevPathRef = useRef<string>();
// Update localStorage directly if path has changed
if (prevPathRef.current !== currentPath && isAllowedLoginRedirect(currentPath)) {
prevPathRef.current = currentPath;
setPersistedState("lastVisitedPath", currentPath);
}
}
/**
* Sets the path that the user visited before being asked to login.
*
+8
View File
@@ -92,6 +92,14 @@ export default function usePaginatedRequest<T = unknown>(
setData(undefined);
setPage(0);
setOffset(0);
setPaginatedReq(
() => () =>
requestFn({
...params,
offset: 0,
limit: fetchLimit,
})
);
}, [requestFn]);
return { data, next, loading, error, page, offset, end };
+8 -6
View File
@@ -1,4 +1,3 @@
import type { Icon } from "outline-icons";
import {
EmailIcon,
ProfileIcon,
@@ -9,7 +8,7 @@ import {
GlobeIcon,
ShieldIcon,
TeamIcon,
BeakerIcon,
SparklesIcon,
SettingsIcon,
ExportIcon,
ImportIcon,
@@ -19,7 +18,6 @@ import {
SmileyIcon,
BuildingBlocksIcon,
} from "outline-icons";
import type { ComponentProps } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
@@ -54,7 +52,11 @@ const CustomEmojis = lazy(() => import("~/scenes/Settings/CustomEmojis"));
export type ConfigItem = {
name: string;
path: string;
icon: React.FC<ComponentProps<typeof Icon>>;
icon: React.FC<{
size?: number;
fill?: string;
monochrome?: boolean;
}>;
component: React.ComponentType;
description?: string;
preload?: () => void;
@@ -142,13 +144,13 @@ const useSettingsConfig = () => {
icon: ShieldIcon,
},
{
name: t("Features"),
name: t("AI"),
path: settingsPath("features"),
component: Features.Component,
preload: Features.preload,
enabled: can.update,
group: t("Workspace"),
icon: BeakerIcon,
icon: SparklesIcon,
},
{
name: t("Members"),
+14 -7
View File
@@ -3,7 +3,7 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import { ActionSeparator, createAction, createActionGroup } from "~/actions";
import { DocumentsSection } from "~/actions/sections";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -15,7 +15,7 @@ type Props = {
/** The document to which the templates will be applied */
documentId: string;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
};
/**
@@ -34,12 +34,12 @@ export function useTemplateMenuActions({
onSelectTemplate,
}: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { documents, templates: templatesStore } = useStores();
const { t } = useTranslation();
const document = documents.get(documentId);
const templateToAction = useCallback(
(template: Document): Action =>
(template: Template): Action =>
createAction({
name: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
@@ -66,8 +66,8 @@ export function useTemplateMenuActions({
return [];
}
const templates = documents.templates.filter(
(template) => template.publishedAt
const templates = templatesStore.orderedData.filter(
(template) => template.isActive
);
const collectionTemplatesActions = templates
@@ -82,6 +82,13 @@ export function useTemplateMenuActions({
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToAction);
if (
!collectionTemplatesActions.length &&
!workspaceTemplatesActions.length
) {
return [];
}
return [
...collectionTemplatesActions,
ActionSeparator,
@@ -90,5 +97,5 @@ export function useTemplateMenuActions({
actions: workspaceTemplatesActions,
}),
];
}, []);
}, [document?.collectionId, templateToAction, t]);
}
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react";
import { DuplicateIcon, EditIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import type Template from "~/models/Template";
import { ActionSeparator, createAction } from "~/actions";
import {
copyTemplate,
deleteTemplate,
moveTemplate,
} from "~/actions/definitions/templates";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for template management operations.
*
* @param template - the template to build actions for, or null to skip.
* @param onEdit - optional callback to handle editing the template.
* @returns action with children for use in menus.
*/
export function useTemplateSettingsActions(
template: Template | null,
onEdit?: () => void
) {
const { t } = useTranslation();
const { templates } = useStores();
const can = usePolicy(template ?? ({} as Template));
const section = "Template";
const actions = React.useMemo(
() =>
!template
? []
: [
createAction({
name: `${t("Edit")}`,
visible: !!can.update && !!onEdit,
icon: <EditIcon />,
section,
perform: () => onEdit?.(),
}),
createAction({
name: t("Duplicate"),
visible: !!can.duplicate,
icon: <DuplicateIcon />,
section,
perform: () => templates.duplicate(template),
}),
moveTemplate,
ActionSeparator,
copyTemplate,
ActionSeparator,
deleteTemplate,
],
[can.update, can.duplicate, onEdit, t, template, templates]
);
return useMenuAction(actions);
}
+1 -1
View File
@@ -41,7 +41,7 @@ if (env.SENTRY_DSN) {
configureMobx({
// TODO: Enable these options and fix any resulting warnings
// enforceActions: env.isDevelopment ? "always" : "never",
// computedRequiresReaction: true,
computedRequiresReaction: true,
isolateGlobalState: true,
});
+1 -1
View File
@@ -53,7 +53,7 @@ function CollectionMenu({
});
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<ActionContextProvider value={{ activeModels: [collection] }}>
<DropdownMenu
action={rootAction}
align={align}
+6 -6
View File
@@ -7,6 +7,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { SubscriptionType, UserPreference } from "@shared/types";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import Switch from "~/components/Switch";
@@ -33,7 +34,7 @@ type Props = {
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Invoked when menu is opened */
@@ -198,11 +199,10 @@ function DocumentMenu({
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
activeModels: [
document,
...(!isShared && document.collection ? [document.collection] : []),
],
}}
>
<DropdownMenu
+2 -1
View File
@@ -17,6 +17,7 @@ import { useMenuAction } from "~/hooks/useMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { newTemplatePath } from "~/utils/routeHelpers";
import { AvatarSize } from "~/components/Avatar";
function NewTemplateMenu() {
const { t } = useTranslation();
@@ -44,7 +45,7 @@ function NewTemplateMenu() {
createInternalLinkAction({
name: t("Save in workspace"),
section: DocumentSection,
icon: <TeamLogo model={team} />,
icon: <TeamLogo model={team} size={AvatarSize.Small} />,
visible: can.createTemplate,
to: newTemplatePath(),
}),
+1 -1
View File
@@ -33,7 +33,7 @@ function RevisionMenu({ document, revisionId }: Props) {
const rootAction = useMenuAction(actions);
return (
<ActionContextProvider value={{ activeDocumentId: document.id }}>
<ActionContextProvider value={{ activeModels: [document] }}>
<DropdownMenu
action={rootAction}
align="end"
+32
View File
@@ -0,0 +1,32 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type Template from "~/models/Template";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useTemplateSettingsActions } from "~/hooks/useTemplateSettingsActions";
type Props = {
template: Template;
onEdit?: () => void;
};
function TemplateMenu({ template, onEdit }: Props) {
const { t } = useTranslation();
const rootAction = useTemplateSettingsActions(template, onEdit);
return (
<ActionContextProvider value={{ activeModels: [template] }}>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Template options")}
>
<OverflowMenuButton />
</DropdownMenu>
</ActionContextProvider>
);
}
export default observer(TemplateMenu);
+2 -1
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { useMenuAction } from "~/hooks/useMenuAction";
@@ -13,7 +14,7 @@ type Props = {
/** Whether to render the button as a compact icon */
isCompact?: boolean;
/** Callback to handle when a template is selected */
onSelectTemplate: (template: Document) => void;
onSelectTemplate: (template: Template) => void;
};
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
+3 -30
View File
@@ -21,7 +21,6 @@ import type DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import type { Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import type Notification from "./Notification";
import type View from "./View";
@@ -150,12 +149,6 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
color?: string | null;
/**
* Whether this is a template.
*/
@observable
template: boolean;
/**
* Whether the document layout is displayed full page width.
*/
@@ -280,8 +273,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get path(): string {
const prefix =
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
const prefix = "/doc";
if (!this.title) {
return `${prefix}/untitled-${this.urlId}`;
@@ -293,7 +285,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get noun(): string {
return this.template ? t("template") : t("document");
return t("document");
}
@computed
@@ -392,11 +384,6 @@ export default class Document extends ArchivableModel implements Searchable {
return !!this.deletedAt;
}
@computed
get isTemplate(): boolean {
return !!this.template;
}
@computed
get isDraft(): boolean {
return !this.publishedAt;
@@ -462,11 +449,6 @@ export default class Document extends ArchivableModel implements Searchable {
return path.map((item) => item.asNavigationNode);
}
@computed
get isWorkspaceTemplate() {
return this.template && !this.collectionId;
}
get titleWithDefault(): string {
return this.title || i18n.t("Untitled");
}
@@ -580,15 +562,6 @@ export default class Document extends ArchivableModel implements Searchable {
this.lastViewedAt = view.lastViewedAt;
};
@action
templatize = ({
collectionId,
publish,
}: {
collectionId: string | null;
publish: boolean;
}) => this.store.templatize({ id: this.id, collectionId, publish });
@action
save = async (
fields?: Properties<typeof this>,
@@ -655,7 +628,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get isActive(): boolean {
return !this.isDeleted && !this.isTemplate && !this.isArchived;
return !this.isDeleted && !this.isArchived;
}
@computed
+155
View File
@@ -0,0 +1,155 @@
import { addDays } from "date-fns";
import i18n from "i18next";
import { computed, observable } from "mobx";
import type { ProsemirrorData } from "@shared/types";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
import type TemplatesStore from "~/stores/TemplatesStore";
import User from "~/models/User";
import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import type { Searchable } from "./interfaces/Searchable";
export default class Template extends ParanoidModel implements Searchable {
static modelName = "Template";
store: TemplatesStore;
@Field
@observable.shallow
data: ProsemirrorData;
@computed
get searchContent(): string {
return this.title;
}
@computed
get searchSuppressed(): boolean {
return this.isDeleted;
}
/**
* The id of the collection that this template belongs to, if any.
*/
@Field
@observable
collectionId?: string | null;
/**
* The collection that this template belongs to.
*/
@Relation(() => Collection, { onDelete: "cascade" })
collection?: Collection;
/**
* The title of the template.
*/
@Field
@observable
title: string;
/**
* An icon (or) emoji to use as the template icon.
*/
@Field
@observable
icon?: string | null;
/**
* The color to use for the template icon.
*/
@Field
@observable
color?: string | null;
/**
* Whether the template layout is displayed full page width.
*/
@Field
@observable
fullWidth: boolean;
/**
* The likely language of the template, in ISO 639-1 format.
*/
@Field
@observable
language: string | undefined;
@Relation(() => User)
createdBy: User | undefined;
@Relation(() => User)
updatedBy: User | undefined;
@observable
urlId: string;
/**
* Returns the direction of the template text, either "rtl" or "ltr"
*/
@computed
get dir(): "rtl" | "ltr" {
return this.rtl ? "rtl" : "ltr";
}
/**
* Returns true if the template text is right-to-left
*/
@computed
get rtl() {
return isRTL(this.title);
}
@computed
get path(): string {
if (!this.title) {
return `${settingsPath("templates")}/untitled-${this.urlId}`;
}
const slugifiedTitle = slugify(this.title);
return `${settingsPath("templates")}/${slugifiedTitle}-${this.urlId}`;
}
@computed
get isDeleted(): boolean {
return !!this.deletedAt;
}
@computed
get hasEmptyTitle(): boolean {
return this.title === "";
}
@computed
get isWorkspaceTemplate(): boolean {
return !this.collectionId;
}
@computed
get permanentlyDeletedAt(): string | undefined {
if (!this.deletedAt) {
return undefined;
}
return addDays(new Date(this.deletedAt), 30).toString();
}
get titleWithDefault(): string {
return this.title || i18n.t("Untitled");
}
@computed
get initial(): string {
return (this.titleWithDefault?.charAt(0) ?? "?").toUpperCase();
}
@computed
get isActive(): boolean {
return !this.isDeleted;
}
}
+7 -3
View File
@@ -1,17 +1,21 @@
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions, withComments } from "@shared/editor/nodes";
import type { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import type Document from "../Document";
import { Schema } from "prosemirror-model";
import { Node } from "prosemirror-model";
interface HasData {
data: ProsemirrorData;
}
export class ProsemirrorHelper {
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
* @returns The markdown representation of the document as a string.
*/
static toMarkdown = (document: Document) => {
static toMarkdown = (document: HasData) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const serializer = extensionManager.serializer();
const schema = new Schema({
@@ -35,7 +39,7 @@ export class ProsemirrorHelper {
*
* @returns The plain text representation of the document as a string.
*/
static toPlainText = (document: Document) => {
static toPlainText = (document: HasData) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
+2 -2
View File
@@ -1,12 +1,12 @@
import { action, observable } from "mobx";
import { client } from "~/utils/ApiClient";
import User from "../User";
import ParanoidModel from "../base/ParanoidModel";
import Field from "../decorators/Field";
import Relation from "../decorators/Relation";
import type OAuthClient from "./OAuthClient";
import Model from "../base/Model";
class OAuthAuthentication extends ParanoidModel {
class OAuthAuthentication extends Model {
static modelName = "OAuthAuthentication";
/** A list of scopes that this authentication has access to */
+12 -12
View File
@@ -1,16 +1,16 @@
import type { RouteComponentProps } from "react-router-dom";
import { Switch } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import Error404 from "~/scenes/Errors/Error404";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers";
import { settingsPath } from "~/utils/routeHelpers";
import { observer } from "mobx-react";
const Application = lazy(() => import("~/scenes/Settings/Application"));
const Document = lazy(() => import("~/scenes/Document"));
const Template = lazy(() => import("~/scenes/Settings/Template"));
const TemplateNew = lazy(() => import("~/scenes/Settings/TemplateNew"));
export default function SettingsRoutes() {
function SettingsRoutes() {
const configs = useSettingsConfig();
return (
@@ -26,22 +26,22 @@ export default function SettingsRoutes() {
{/* TODO: Refactor these exceptions into config? */}
<Route
exact
path={`${settingsPath("applications")}/:id`}
path={settingsPath("applications", ":id")}
component={Application}
/>
<Route
exact
path={`${settingsPath("templates")}/${matchDocumentSlug}`}
component={Document}
path={settingsPath("templates", "new")}
component={TemplateNew}
/>
<Route
exact
path={`${settingsPath("templates")}/new`}
component={(props: RouteComponentProps) => (
<DocumentNew {...props} template />
)}
path={settingsPath("templates", ":id")}
component={Template}
/>
<Route component={Error404} />
</Switch>
);
}
export default observer(SettingsRoutes);
+2 -6
View File
@@ -26,7 +26,7 @@ import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { useTrackLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
@@ -60,7 +60,7 @@ const CollectionScene = observer(function CollectionScene_() {
const { documents, collections, shares, ui } = useStores();
const [error, setError] = useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
useTrackLastVisitedPath(currentPath);
const sidebarContext = useLocationSidebarContext();
const isEditRoute = match.path === matchCollectionEdit;
@@ -80,10 +80,6 @@ const CollectionScene = observer(function CollectionScene_() {
}
);
useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
useEffect(() => {
if (collection?.name) {
const canonicalUrl = updateCollectionPath(match.url, collection);
@@ -167,7 +167,7 @@ function DataLoader({ match, children }: Props) {
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!missingPolicy && !can.update && isEditRoute && !document.template) {
if (!missingPolicy && !can.update && isEditRoute) {
history.push(document.url);
return;
}
+7 -10
View File
@@ -23,9 +23,10 @@ import { TextHelper } from "@shared/utils/TextHelper";
import { determineIconType } from "@shared/utils/icon";
import { isModKey } from "@shared/utils/keyboard";
import type RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import type Document from "~/models/Document";
import Template from "~/models/Template";
import type Revision from "~/models/Revision";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
import DocumentPublish from "~/scenes/DocumentPublish";
import ErrorBoundary from "~/components/ErrorBoundary";
import LoadingIndicator from "~/components/LoadingIndicator";
@@ -140,7 +141,7 @@ class DocumentScene extends React.Component<Props> {
* @param template The template to use
* @param selection The selection to replace, if any
*/
replaceSelection = (template: Document | Revision, selection?: Selection) => {
replaceSelection = (template: Template | Revision, selection?: Selection) => {
const editorRef = this.editor.current;
if (!editorRef) {
@@ -163,7 +164,7 @@ class DocumentScene extends React.Component<Props> {
this.isEditorDirty = true;
if (template instanceof Document) {
if (template instanceof Template) {
this.props.document.templateId = template.id;
this.props.document.fullWidth = template.fullWidth;
}
@@ -417,7 +418,7 @@ class DocumentScene extends React.Component<Props> {
void this.onSave();
});
handleSelectTemplate = async (template: Document | Revision) => {
handleSelectTemplate = async (template: Template | Revision) => {
const editorRef = this.editor.current;
if (!editorRef) {
return;
@@ -466,10 +467,7 @@ class DocumentScene extends React.Component<Props> {
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
TOCPosition.Left);
const showContents =
tocPos &&
(isShare
? ui.tocVisible !== false
: !document.isTemplate && ui.tocVisible === true);
tocPos && (isShare ? ui.tocVisible !== false : ui.tocVisible === true);
const tocOffset =
tocPos === TOCPosition.Left
? EditorStyleHelper.tocWidth / -2
@@ -597,7 +595,6 @@ class DocumentScene extends React.Component<Props> {
ref={this.editor}
multiplayer={multiplayerEditor}
isDraft={document.isDraft}
template={document.isTemplate}
document={document}
value={readOnly ? document.data : undefined}
defaultValue={document.data}
@@ -8,6 +8,7 @@ import styled from "styled-components";
import { TeamPreference } from "@shared/types";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
import type Template from "~/models/Template";
import { openDocumentInsights } from "~/actions/definitions/documents";
import DocumentMeta, { Separator } from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
@@ -21,7 +22,7 @@ import NudeButton from "~/components/NudeButton";
type Props = {
/* The document to display meta data for */
document: Document;
document: Document | Template;
revision?: Revision;
to?: LocationDescriptor;
rtl?: boolean;
@@ -44,13 +45,19 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const commentingEnabled = !!team.getPreference(TeamPreference.Commenting);
return (
<Meta document={document} revision={revision} to={to} replace {...rest}>
<Meta
document={document as Document}
revision={revision}
to={to}
replace
{...rest}
>
{commentingEnabled && can.comment && (
<>
<Separator />
<CommentLink
to={{
pathname: documentPath(document),
pathname: documentPath(document as Document),
state: { sidebarContext },
}}
onClick={() => ui.toggleComments()}
@@ -62,10 +69,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
</CommentLink>
</>
)}
{totalViewers &&
can.listViews &&
!document.isDraft &&
!document.isTemplate ? (
{totalViewers && can.listViews && !(document as Document).isDraft ? (
<Wrapper>
<Separator />
<InsightsButton action={openDocumentInsights}>
+8 -7
View File
@@ -10,6 +10,7 @@ import { TeamPreference } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Comment from "~/models/Comment";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import type { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import type { Props as EditorProps } from "~/components/Editor";
@@ -43,7 +44,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
onChangeTitle: (title: string) => void;
onChangeIcon: (icon: string | null, color: string | null) => void;
id: string;
document: Document;
document: Document | Template;
isDraft: boolean;
multiplayer?: boolean;
onSave: (options: {
@@ -51,7 +52,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
autosave?: boolean;
publish?: boolean;
}) => void;
children: React.ReactNode;
children?: React.ReactNode;
};
/**
@@ -213,23 +214,23 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
{t("Last updated")} <Time dateTime={document.updatedAt} addSuffix />
</SharedMeta>
) : null
) : (
) : !rest.template ? (
<DocumentMeta
document={document}
document={document as Document}
to={
shareId
? undefined
: {
pathname:
match.path === matchDocumentHistory
? documentPath(document)
: documentHistoryPath(document),
? documentPath(document as Document)
: documentHistoryPath(document as Document),
state: { sidebarContext },
}
}
rtl={direction === "rtl"}
/>
)}
) : null}
<EditorComponent
ref={mergeRefs([ref, handleRefChanged])}
lang={getLangFor(document.language)}
+23 -31
View File
@@ -9,6 +9,7 @@ import useMeasure from "react-use-measure";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
import type Template from "~/models/Template";
import { Action, Separator } from "~/components/Actions";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
@@ -20,7 +21,6 @@ import Header from "~/components/Header";
import Star from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents";
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
import { restoreRevision } from "~/actions/definitions/revisions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -53,7 +53,7 @@ type Props = {
isPublishing: boolean;
publishingIsDisabled: boolean;
savingIsDisabled: boolean;
onSelectTemplate: (template: Document) => void;
onSelectTemplate: (template: Template) => void;
onSave: (options: {
done?: boolean;
publish?: boolean;
@@ -116,12 +116,10 @@ function DocumentHeader({
}, [ui, isShare]);
const can = usePolicy(document);
const { isDeleted, isTemplate } = document;
const isTemplateEditable = can.update && isTemplate;
const { isDeleted } = document;
const canToggleEmbeds = team?.documentEmbeds;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
const toc = (
<Tooltip
@@ -229,12 +227,12 @@ function DocumentHeader({
isMobile ? (
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{document.isTemplate ? null : (
<>
{toc} <Star document={document} color={theme.textSecondary} />
</>
)}
<DocumentBreadcrumb document={document as Document}>
{toc}{" "}
<Star
document={document as Document}
color={theme.textSecondary}
/>
</DocumentBreadcrumb>
)
}
@@ -260,24 +258,21 @@ function DocumentHeader({
limit={isCompact ? 3 : undefined}
/>
)}
{(isEditing || !user?.separateEditMode) &&
!isTemplate &&
isNew &&
can.update && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && !isRevision && !isTemplate && can.update && (
{(isEditing || !user?.separateEditMode) && isNew && can.update && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document as Document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && !isRevision && can.update && (
<Action>
<ShareButton document={document} />
</Action>
)}
{(isEditing || isTemplateEditable) && (
{isEditing && (
<Action>
<Tooltip
content={isDraft ? t("Save draft") : t("Done editing")}
@@ -285,8 +280,7 @@ function DocumentHeader({
placement="bottom"
>
<Button
action={isTemplate ? navigateToTemplateSettings : undefined}
onClick={isTemplate ? undefined : handleSave}
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
hideIcon
@@ -340,9 +334,7 @@ function DocumentHeader({
hideOnActionDisabled
hideIcon
>
{document.collectionId || document.isWorkspaceTemplate
? t("Publish")
: `${t("Publish")}`}
{t("Publish")}
</Button>
</Action>
)}
+3 -28
View File
@@ -1,7 +1,6 @@
import { differenceInDays } from "date-fns";
import { TrashIcon, ArchiveIcon, ShapesIcon, InputIcon } from "outline-icons";
import { TrashIcon, ArchiveIcon } from "outline-icons";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import type Document from "~/models/Document";
import ErrorBoundary from "~/components/ErrorBoundary";
import Notice from "~/components/Notice";
@@ -25,7 +24,7 @@ function Days(props: { dateTime: string }) {
);
}
export default function Notices({ document, readOnly }: Props) {
export default function Notices({ document }: Props) {
const { t } = useTranslation();
function permanentlyDeletedDescription() {
@@ -41,12 +40,7 @@ export default function Notices({ document, readOnly }: Props) {
? new Date().toISOString()
: document.permanentlyDeletedAt;
return document.template ? (
<Trans>
This template will be permanently deleted in{" "}
<Days dateTime={permanentlyDeletedAt} /> unless restored.
</Trans>
) : (
return (
<Trans>
This document will be permanently deleted in{" "}
<Days dateTime={permanentlyDeletedAt} /> unless restored.
@@ -56,19 +50,6 @@ export default function Notices({ document, readOnly }: Props) {
return (
<ErrorBoundary>
{document.isTemplate && !readOnly && (
<Notice
icon={<ShapesIcon />}
description={
<Trans>
Highlight some text and use the <PlaceholderIcon /> control to add
placeholders that can be filled out when creating new documents
</Trans>
}
>
{t("Youre editing a template")}
</Notice>
)}
{document.archivedAt && !document.deletedAt && (
<Notice icon={<ArchiveIcon />}>
{t("Archived by {{userName}}", {
@@ -93,9 +74,3 @@ export default function Notices({ document, readOnly }: Props) {
</ErrorBoundary>
);
}
const PlaceholderIcon = styled(InputIcon)`
position: relative;
top: 6px;
margin-top: -6px;
`;
+2 -6
View File
@@ -3,7 +3,7 @@ import type { StaticContext } from "react-router";
import { useHistory } from "react-router";
import type { RouteComponentProps } from "react-router-dom";
import type { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { useTrackLastVisitedPath } from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores";
import DataLoader from "./components/DataLoader";
import Document from "./components/Document";
@@ -28,11 +28,7 @@ export default function DocumentScene(props: Props) {
const history = useHistory();
const { documentSlug, revisionId } = props.match.params;
const currentPath = props.location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
useTrackLastVisitedPath(currentPath);
useEffect(() => () => ui.clearActiveDocument(), [ui]);
+4 -26
View File
@@ -8,12 +8,7 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import {
collectionPath,
documentPath,
homePath,
settingsPath,
} from "~/utils/routeHelpers";
import { collectionPath, documentPath, homePath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -27,8 +22,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const canArchive =
!document.isDraft && !document.isArchived && !document.template;
const canArchive = !document.isDraft && !document.isArchived;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -64,13 +58,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
}
}
// If template, redirect to the template settings.
// Otherwise redirect to the collection (or) home.
const path = document.template
? settingsPath("templates")
: collection
? collectionPath(collection)
: homePath();
const path = collection ? collectionPath(collection) : homePath();
history.push(path);
}
@@ -104,17 +92,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{document.isTemplate ? (
<Trans
defaults="Are you sure you want to delete the <em>{{ documentTitle }}</em> template?"
values={{
documentTitle: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
) : nestedDocumentsCount < 1 ? (
{nestedDocumentsCount < 1 ? (
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
+8 -9
View File
@@ -13,12 +13,7 @@ import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { documentEditPath, documentPath } from "~/utils/routeHelpers";
type Props = {
// If true, the document will be created as a template.
template?: boolean;
};
function DocumentNew({ template }: Props) {
function DocumentNew() {
const history = useHistory();
const location = useLocation();
const query = useQuery();
@@ -31,6 +26,7 @@ function DocumentNew({ template }: Props) {
useEffect(() => {
async function createDocument() {
const index = parseInt(query.get("index") || "0", 10);
const parentDocumentId = query.get("parentDocumentId") ?? undefined;
const parentDocument = parentDocumentId
? documents.get(parentDocumentId)
@@ -41,6 +37,7 @@ function DocumentNew({ template }: Props) {
if (id) {
collection = await collections.fetch(id);
}
const document = await documents.create(
{
collectionId: collection?.id,
@@ -49,11 +46,13 @@ function DocumentNew({ template }: Props) {
parentDocument?.fullWidth ||
user.getPreference(UserPreference.FullWidthDocuments),
templateId: query.get("templateId") ?? undefined,
template,
title: query.get("title") ?? "",
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: collection?.id || parentDocumentId ? true : undefined }
{
publish: collection?.id || parentDocumentId ? true : undefined,
index,
}
);
if (parentDocumentId) {
@@ -67,7 +66,7 @@ function DocumentNew({ template }: Props) {
}
history.replace(
template || !user.separateEditMode
!user.separateEditMode
? documentPath(document)
: documentEditPath(document),
location.state
+65 -16
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { isLoopbackUri } from "~/utils/urls";
import Flex from "@shared/components/Flex";
import { s } from "@shared/styles";
import { parseDomain } from "@shared/utils/domains";
@@ -17,7 +18,11 @@ import { useLoggedInSessions } from "~/hooks/useLoggedInSessions";
import useQuery from "~/hooks/useQuery";
import useRequest from "~/hooks/useRequest";
import { client } from "~/utils/ApiClient";
import { BadRequestError, NotFoundError } from "~/utils/errors";
import {
AuthorizationError,
BadRequestError,
NotFoundError,
} from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import { detectLanguage } from "~/utils/language";
import Login from "./Login";
@@ -48,6 +53,23 @@ export default function OAuthAuthorize() {
return <Login />;
}
function inputScopes(scope?: string): string[] {
const defaultScopes = ["read", "write"];
// Some clients don't send the scope parameter if it's empty, so we default to "read write".
if (!scope) {
return defaultScopes;
}
// Handle invalid "claudeai" scope sent by Claude:
// https://github.com/modelcontextprotocol/modelcontextprotocol/issues/653
if (scope === "claudeai") {
return defaultScopes;
}
return scope.split(" ").filter(Boolean);
}
/**
* Authorize component is responsible for handling the OAuth authorization process.
* It retrieves the OAuth client information, displays the authorization request,
@@ -58,6 +80,7 @@ function Authorize() {
const params = useQuery();
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isReady, setIsReady] = useState(false);
const timeoutRef = useRef<number>();
const {
client_id: clientId,
@@ -68,7 +91,7 @@ function Authorize() {
state,
scope,
} = Object.fromEntries(params);
const [scopes] = useState(() => scope?.split(" ") ?? []);
const [scopes] = useState(() => inputScopes(scope));
const { error: clientError, data: response } = useRequest<{
data: OAuthClient;
}>(() => client.post("/oauthClients.info", { clientId, redirectUri }), true);
@@ -92,20 +115,20 @@ function Authorize() {
timeoutRef.current = window.setTimeout(() => setIsSubmitting(false), 5000);
};
useEffect(
() => () => {
useEffect(() => {
const readyTimeout = window.setTimeout(() => setIsReady(true), 1000);
return () => {
window.clearTimeout(readyTimeout);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
},
[]
);
};
}, []);
const missingParams = [
!clientId && "client_id",
!redirectUri && "redirect_uri",
!responseType && "response_type",
!scope && "scope",
!state && "state",
].filter(Boolean);
@@ -128,6 +151,13 @@ function Authorize() {
)}
<Pre>{redirectUri}</Pre>
</Text>
) : clientError instanceof AuthorizationError ? (
<Text as="p" type="secondary">
{t(
"The OAuth client could not be loaded, please check your workspace subdomain is correct"
)}
<Pre>{clientError.message}</Pre>
</Text>
) : (
<Text as="p" type="secondary">
{t("Required OAuth parameters are missing")}
@@ -188,7 +218,7 @@ function Authorize() {
/>
</Text>
)}
<Text type="tertiary" as="p">
<Text type="secondary" as="p">
{t(
"{{ appName }} will be able to access your account and perform the following actions",
{
@@ -198,12 +228,31 @@ function Authorize() {
:
</Text>
<ul style={{ width: "100%", paddingLeft: "1em", marginTop: 0 }}>
{OAuthScopeHelper.normalizeScopes(scopes, t).map((item) => (
<li key={item}>
<Text type="secondary">{item}</Text>
</li>
))}
{OAuthScopeHelper.normalizeScopes(scopes.length ? scopes : [], t).map(
(item) => (
<li key={item}>
<Text type="secondary">{item}</Text>
</li>
)
)}
</ul>
<Text type="tertiary" as="p" style={{ wordBreak: "break-all" }}>
{isLoopbackUri(redirectUri) ? (
<Trans>
You will be redirected to a local application after authorizing.
</Trans>
) : (
<Trans
defaults="You will be redirected to <em>{{ redirectUri }}</em> after authorizing. Make sure you trust this URL."
values={{
redirectUri,
}}
components={{
em: <strong />,
}}
/>
)}
</Text>
<Form
method="POST"
action="/oauth/authorize"
@@ -218,7 +267,7 @@ function Authorize() {
value={responseType ?? ""}
/>
<input type="hidden" name="state" value={state ?? ""} />
<input type="hidden" name="scope" value={scope ?? ""} />
<input type="hidden" name="scope" value={scopes.join(" ")} />
{codeChallenge && (
<input type="hidden" name="code_challenge" value={codeChallenge} />
)}
@@ -233,7 +282,7 @@ function Authorize() {
<Button type="button" onClick={handleCancel} neutral>
{t("Cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={!isReady || isSubmitting}>
{t("Authorize")}
</Button>
</Flex>
+4
View File
@@ -45,6 +45,10 @@ export class OAuthScopeHelper {
const [namespace, method] = scope.replace("/api/", "").split(/[:\.]/g);
const readableMethod =
methodToReadable[method as keyof typeof methodToReadable] ?? method;
if (!readableMethod) {
return scope;
}
const translatedNamespace =
translatedNamespaces[namespace as keyof typeof translatedNamespaces] ??
namespace;
+10 -6
View File
@@ -27,7 +27,7 @@ import env from "~/env";
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import type { SearchResult } from "~/types";
import type { PaginationParams, SearchResult } from "~/types";
import { searchPath } from "~/utils/routeHelpers";
import { decodeURIComponentSafe } from "~/utils/urls";
import CollectionFilter from "./components/CollectionFilter";
@@ -122,10 +122,15 @@ function Search() {
}
if (isSearchable) {
return async () =>
titleFilter
? await documents.searchTitles(filters)
: await documents.search(filters);
return async (params?: PaginationParams) => {
const paginationParams = {
offset: params?.offset,
limit: params?.limit,
};
return titleFilter
? await documents.searchTitles({ ...filters, ...paginationParams })
: await documents.search({ ...filters, ...paginationParams });
};
}
return () => Promise.resolve([] as SearchResult[]);
@@ -349,7 +354,6 @@ function Search() {
highlight={query}
context={result.context}
showCollection
showTemplate
/>
))
: null
+47 -1
View File
@@ -164,6 +164,24 @@ function Details() {
setDefaultCollectionId(selectedValue);
}, []);
const handleSeamlessEditChange = React.useCallback(
async (checked: boolean) => {
team.setPreference(TeamPreference.SeamlessEdit, !checked);
await team.save();
toast.success(t("Settings saved"));
},
[team, t]
);
const handleCommentingChange = React.useCallback(
async (checked: boolean) => {
team.setPreference(TeamPreference.Commenting, checked);
await team.save();
toast.success(t("Settings saved"));
},
[team, t]
);
const isValid = form.current?.checkValidity();
const newTheme = React.useMemo(
@@ -334,7 +352,6 @@ function Details() {
/>
</SettingRow>
<SettingRow
border={false}
label={t("Start view")}
name="defaultCollectionId"
description={t(
@@ -346,6 +363,35 @@ function Details() {
defaultCollectionId={defaultCollectionId}
/>
</SettingRow>
<SettingRow
name={TeamPreference.SeamlessEdit}
label={t("Separate editing")}
description={t(
"When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences."
)}
>
<Switch
id={TeamPreference.SeamlessEdit}
name={TeamPreference.SeamlessEdit}
checked={!team.getPreference(TeamPreference.SeamlessEdit)}
onChange={handleSeamlessEditChange}
/>
</SettingRow>
<SettingRow
border={false}
name={TeamPreference.Commenting}
label={t("Commenting")}
description={t(
"When enabled team members can add comments to documents."
)}
>
<Switch
id={TeamPreference.Commenting}
name={TeamPreference.Commenting}
checked={team.getPreference(TeamPreference.Commenting)}
onChange={handleCommentingChange}
/>
</SettingRow>
<ActionRow>
<Button type="submit" disabled={team.isSaving || !isValid}>
+74 -37
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import { BeakerIcon } from "outline-icons";
import { CopyIcon, SparklesIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { TeamPreference } from "@shared/types";
import Heading from "~/components/Heading";
@@ -10,65 +10,102 @@ import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import SettingRow from "./components/SettingRow";
import Input from "~/components/Input";
import Tooltip from "~/components/Tooltip";
import CopyToClipboard from "~/components/CopyToClipboard";
import NudeButton from "~/components/NudeButton";
import { useTheme } from "styled-components";
function Features() {
const team = useCurrentTeam();
const { t } = useTranslation();
const team = useCurrentTeam();
const theme = useTheme();
const handleSeamlessEditChange = React.useCallback(
const handleMCPChange = React.useCallback(
async (checked: boolean) => {
team.setPreference(TeamPreference.SeamlessEdit, !checked);
team.setPreference(TeamPreference.MCP, checked);
await team.save();
toast.success(t("Settings saved"));
},
[team, t]
);
const handleCommentingChange = React.useCallback(
async (checked: boolean) => {
team.setPreference(TeamPreference.Commenting, checked);
await team.save();
toast.success(t("Settings saved"));
},
[team, t]
);
const handleCopied = React.useCallback(() => {
toast.success(t("Copied to clipboard"));
}, [t]);
const mcpEndpoint = window.location.origin + "/mcp";
return (
<Scene title={t("Features")} icon={<BeakerIcon />}>
<Heading>{t("Features")}</Heading>
<Scene title={t("AI")} icon={<SparklesIcon />}>
<Heading>{t("AI")}</Heading>
<Text as="p" type="secondary">
<Trans>
Manage optional and beta features. Changing these settings will affect
the experience for all members of the workspace.
</Trans>
<Trans>Manage AI and integration features for your workspace.</Trans>
</Text>
<SettingRow
name={TeamPreference.SeamlessEdit}
label={t("Separate editing")}
description={t(
`When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.`
)}
name={TeamPreference.MCP}
label={t("MCP server")}
description={
<>
<Text type="secondary" as="p">
{t(
"Allow members to connect to this workspace with MCP to read and write data."
)}
</Text>
{team.getPreference(TeamPreference.MCP) && (
<>
<Text
type="secondary"
as="p"
style={{ marginTop: 8, marginBottom: 4 }}
>
<Trans
defaults="Use the following endpoint to connect to the MCP server from your app. Find out more about setup in <a>the docs</a>."
components={{
a: (
<Text
as="a"
weight="bold"
href="https://docs.getoutline.com/s/guide/doc/mcp-6j9jtENNKL"
target="_blank"
rel="noopener noreferrer"
/>
),
}}
/>
</Text>
<Input readOnly value={mcpEndpoint}>
<Tooltip content={t("Copy URL")} placement="top">
<CopyToClipboard text={mcpEndpoint} onCopy={handleCopied}>
<NudeButton type="button" style={{ marginRight: 3 }}>
<CopyIcon color={theme.placeholder} size={18} />
</NudeButton>
</CopyToClipboard>
</Tooltip>
</Input>
</>
)}
</>
}
>
<Switch
id={TeamPreference.SeamlessEdit}
name={TeamPreference.SeamlessEdit}
checked={!team.getPreference(TeamPreference.SeamlessEdit)}
onChange={handleSeamlessEditChange}
id={TeamPreference.MCP}
name={TeamPreference.MCP}
checked={team.getPreference(TeamPreference.MCP)}
onChange={handleMCPChange}
/>
</SettingRow>
<SettingRow
name={TeamPreference.Commenting}
label={t("Commenting")}
name="answers"
label={t("AI answers")}
description={t(
"When enabled team members can add comments to documents."
"Use AI to get direct answers to questions in search. This feature requires a paid license."
)}
border={false}
>
<Switch
id={TeamPreference.Commenting}
name={TeamPreference.Commenting}
checked={team.getPreference(TeamPreference.Commenting)}
onChange={handleCommentingChange}
/>
<Switch disabled />
</SettingRow>
</Scene>
);
+1 -1
View File
@@ -96,7 +96,7 @@ function useImportsConfig() {
items.push({
title: "Confluence",
subtitle: t("Import pages from a Confluence instance"),
icon: <img src={cdnPath("/images/confluence.png")} width={28} />,
icon: <img src={cdnPath("/images/confluence.png")} alt="" width={28} />,
action: (
<Button type="submit" disabled neutral>
{t("Enterprise")}
+125
View File
@@ -0,0 +1,125 @@
import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import { useEffect, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { Action } from "~/components/Actions";
import Breadcrumb from "~/components/Breadcrumb";
import Button from "~/components/Button";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import LoadingIndicator from "~/components/LoadingIndicator";
import Scene from "~/components/Scene";
import { TemplateForm } from "~/components/Template/TemplateForm";
import { createInternalLinkAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import TemplateMenu from "~/menus/TemplateMenu";
import { collectionPath, settingsPath } from "~/utils/routeHelpers";
import type Template from "~/models/Template";
import history from "~/utils/history";
type Props = {
template: Template;
};
const LoadingState = observer(function LoadingState() {
const { id } = useParams<{ id: string }>();
const { templates, ui } = useStores();
const template = templates.get(id);
const { request } = useRequest(() => templates.fetch(id));
useEffect(() => {
if (!template) {
void request();
}
}, [template, request]);
useEffect(() => {
if (template) {
ui.addActiveModel(template);
}
return () => {
template && ui.removeActiveModel(template);
};
}, [template, ui]);
if (!template) {
return <LoadingIndicator />;
}
return <TemplateSetting template={template} />;
});
const TemplateSetting = observer(function Template_({ template }: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const [saving, setSaving] = useState(false);
const collection = template.collectionId
? collections.get(template.collectionId)
: undefined;
const breadcrumbActions = useMemo(
() => [
createInternalLinkAction({
name: t("Templates"),
section: NavigationSection,
icon: <ShapesIcon />,
to: settingsPath("templates"),
}),
...(collection
? [
createInternalLinkAction({
name: collection.name,
section: NavigationSection,
icon: <CollectionIcon collection={collection} />,
to: collectionPath(collection),
}),
]
: []),
],
[t, collection]
);
const handleSubmit = useCallback(async () => {
if (!template.data || ProsemirrorHelper.isEmptyData(template.data)) {
toast.message(t("A template must have content"));
return;
}
setSaving(true);
try {
await template.save();
history.push(settingsPath("templates"));
} catch (error) {
toast.error(error.message);
} finally {
setSaving(false);
}
}, [template, t]);
return (
<Scene
title={template.title}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={
<>
<Action>
<Button onClick={handleSubmit} disabled={saving}>
{t("Save")}
</Button>
</Action>
<Action>
<TemplateMenu template={template} />
</Action>
</>
}
>
<TemplateForm template={template} handleSubmit={handleSubmit} />
</Scene>
);
});
export default LoadingState;
+89
View File
@@ -0,0 +1,89 @@
import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Template from "~/models/Template";
import { Action } from "~/components/Actions";
import Breadcrumb from "~/components/Breadcrumb";
import Button from "~/components/Button";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import Scene from "~/components/Scene";
import { TemplateForm } from "~/components/Template/TemplateForm";
import { createInternalLinkAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { collectionPath, settingsPath } from "~/utils/routeHelpers";
import history from "~/utils/history";
function TemplateNewScene() {
const { t } = useTranslation();
const { templates, collections } = useStores();
const params = useQuery();
const collectionId = params.get("collectionId") || undefined;
const collection = collectionId ? collections.get(collectionId) : undefined;
const [template] = useState(
() => new Template({ title: "", collectionId }, templates)
);
const [saving, setSaving] = useState(false);
const breadcrumbActions = useMemo(
() => [
createInternalLinkAction({
name: t("Templates"),
section: NavigationSection,
icon: <ShapesIcon />,
to: settingsPath("templates"),
}),
...(collection
? [
createInternalLinkAction({
name: collection.name,
section: NavigationSection,
icon: <CollectionIcon collection={collection} />,
to: collectionPath(collection),
}),
]
: []),
],
[t, collection]
);
const handleSubmit = useCallback(async () => {
if (!template.data || ProsemirrorHelper.isEmptyData(template.data)) {
toast.message(t("A template must have content"));
return;
}
setSaving(true);
try {
await template.save();
history.push(settingsPath("templates"));
} catch (error) {
toast.error(error.message);
} finally {
setSaving(false);
}
}, [template, t]);
return (
<Scene
title={t("New template")}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={
<Action>
<Button onClick={handleSubmit} disabled={saving}>
{t("Save")}
</Button>
</Action>
}
>
<TemplateForm template={template} handleSubmit={handleSubmit} />
</Scene>
);
}
export default observer(TemplateNewScene);
+124 -45
View File
@@ -1,75 +1,154 @@
import type { ColumnSort } from "@tanstack/react-table";
import deburr from "lodash/deburr";
import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import queryString from "query-string";
import { useEffect } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useEffect, useMemo, useCallback, useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import type Template from "~/models/Template";
import { Action } from "~/components/Actions";
import Empty from "~/components/Empty";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import Text from "~/components/Text";
import NewTemplateMenu from "~/menus/NewTemplateMenu";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import NewTemplateMenu from "~/menus/NewTemplateMenu";
import { settingsPath } from "~/utils/routeHelpers";
import { useTableRequest } from "~/hooks/useTableRequest";
import { StickyFilters } from "./components/StickyFilters";
import { TemplatesTable } from "./components/TemplatesTable";
function getFilteredTemplates(templates: Template[], query?: string) {
if (!query?.length) {
return templates;
}
const normalizedQuery = deburr(query.toLocaleLowerCase());
return templates.filter((template) =>
deburr(template.title).toLocaleLowerCase().includes(normalizedQuery)
);
}
function Templates() {
const { documents } = useStores();
const { t } = useTranslation();
const param = useQuery();
const { fetchTemplates, templates, templatesAlphabetical } = documents;
const sort = param.get("sort") || "recent";
const { templates } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const history = useHistory();
const location = useLocation();
const params = useQuery();
const [query, setQuery] = useState("");
const reqParams = useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
const sort: ColumnSort = useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const { data, error, loading, next } = useTableRequest({
data: getFilteredTemplates(templates.all, reqParams.query),
sort,
reqFn: templates.fetchPage,
reqParams,
});
const isEmpty = !loading && !templates.all.length;
const updateQuery = useCallback(
(value: string) => {
if (value) {
params.set("query", value);
} else {
params.delete("query");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
useEffect(() => {
void documents.fetchDrafts();
}, [documents]);
if (error) {
toast.error(t("Could not load templates"));
}
}, [t, error]);
useEffect(() => {
const timeout = setTimeout(() => updateQuery(query), 250);
return () => clearTimeout(timeout);
}, [query, updateQuery]);
return (
<Scene
icon={<ShapesIcon />}
title={t("Templates")}
icon={<ShapesIcon />}
actions={
<Action>
<NewTemplateMenu />
</Action>
<>
{can.createTemplate && (
<Action>
<NewTemplateMenu />
</Action>
)}
</>
}
wide
>
<Heading>{t("Templates")}</Heading>
<Text as="p" type="secondary">
<Trans>
You can create templates to help your team create consistent and
accurate documentation.
Templates help your team create consistent and accurate documentation.
</Trans>
</Text>
<PaginatedDocumentList
heading={
<Tabs>
<Tab to={settingsPath("templates")} exactQueryString>
{t("Recently updated")}
</Tab>
<Tab
to={{
pathname: settingsPath("templates"),
search: queryString.stringify({
sort: "alphabetical",
}),
{isEmpty ? (
<Empty>{t("No templates have been created yet")}</Empty>
) : (
<>
<StickyFilters>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<TemplatesTable
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
exactQueryString
>
{t("Alphabetical")}
</Tab>
</Tabs>
}
empty={<Empty>{t("There are no templates just yet.")}</Empty>}
fetch={fetchTemplates}
documents={sort === "alphabetical" ? templatesAlphabetical : templates}
showCollection
showDraft
/>
/>
</ConditionalFade>
</>
)}
</Scene>
);
}
@@ -21,14 +21,14 @@ import { s } from "@shared/styles";
import styled from "styled-components";
import { HStack } from "~/components/primitives/HStack";
const ROW_HEIGHT = 60;
const ROW_HEIGHT = 50;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Emoji>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function EmojiRowContextMenu({
const EmojiRowContextMenu = observer(function EmojiRowContextMenu({
emoji,
menuLabel,
children,
@@ -43,7 +43,7 @@ function EmojiRowContextMenu({
{children}
</ContextMenu>
);
}
});
const EmojisTable = observer(function EmojisTable({
canManage,

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