mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ddccc195a |
-10
@@ -119,11 +119,6 @@ SSL_CERT=
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# When behind a reverse proxy, the header to use for the client IP.
|
||||
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
@@ -217,11 +212,6 @@ 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=
|
||||
|
||||
@@ -18,9 +18,6 @@ GITHUB_CLIENT_ID=123;
|
||||
GITHUB_CLIENT_SECRET=123;
|
||||
GITHUB_APP_NAME=outline-test;
|
||||
|
||||
GITLAB_CLIENT_ID=123
|
||||
GITLAB_CLIENT_SECRET=123
|
||||
|
||||
OIDC_CLIENT_ID=client-id
|
||||
OIDC_CLIENT_SECRET=client-secret
|
||||
OIDC_AUTH_URI=http://localhost/authorize
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
|
||||
bundle-size:
|
||||
needs: [setup, types, changes]
|
||||
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -20,5 +20,4 @@ data/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
.yarn/releases
|
||||
!.yarn/sdks
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.5.0
|
||||
Licensed Work: Outline 1.4.0
|
||||
The Licensed Work is (c) 2026 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2030-02-15
|
||||
Change Date: 2030-01-27
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
createInternalLinkAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} 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: ({ t, getActiveModel, stores }) => {
|
||||
perform: ({ getActiveModel, stores }) => {
|
||||
const { documents } = stores;
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
@@ -165,7 +165,6 @@ 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, {
|
||||
@@ -174,8 +173,6 @@ export const importDocument = createAction({
|
||||
history.push(document.path);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -528,11 +525,17 @@ export const createTemplate = createInternalLinkAction({
|
||||
keywords: "new create template",
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createTemplate
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
to: ({ getActiveModel }) => {
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return newTemplatePath(collection?.id);
|
||||
const [pathname, search] = newTemplatePath(collection?.id).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -42,11 +42,12 @@ 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/DocumentExplorer/DocumentCopy";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import { DocumentDownload } from "~/components/DocumentDownload";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -69,7 +70,6 @@ import {
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
newNestedDocumentPath,
|
||||
newSiblingDocumentPath,
|
||||
searchPath,
|
||||
documentPath,
|
||||
urlify,
|
||||
@@ -78,15 +78,9 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type {
|
||||
Action,
|
||||
ActionContext,
|
||||
ActionGroup,
|
||||
ActionSeparator,
|
||||
} from "~/types";
|
||||
import type { Action, 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")
|
||||
@@ -138,13 +132,18 @@ export const editDocument = createInternalLinkAction({
|
||||
keywords: "edit",
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { auth, policies } = stores;
|
||||
const { auth, documents, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return !!can?.update && !!auth.user?.separateEditMode;
|
||||
return (
|
||||
!!can?.update && !!auth.user?.separateEditMode && !document?.template
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -201,41 +200,59 @@ export const createDraftDocument = 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;
|
||||
}
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({
|
||||
currentTeamId,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
stores,
|
||||
}) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
const siblings = document.parentDocumentId
|
||||
? collection.getChildrenForDocument(document.parentDocumentId)
|
||||
: collection.sortedDocuments;
|
||||
if (
|
||||
!currentTeamId ||
|
||||
!document?.isTemplate ||
|
||||
!!document?.isDraft ||
|
||||
!!document?.isDeleted
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
|
||||
}
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId, {
|
||||
templateId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("Nested document"),
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "create nested",
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
@@ -253,93 +270,6 @@ 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",
|
||||
@@ -416,7 +346,7 @@ export const publishDocument = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
if (document?.collectionId || document?.template) {
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
@@ -940,7 +870,7 @@ export const printDocument = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: () => {
|
||||
setTimeout(window.print, 0);
|
||||
queueMicrotask(window.print);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -961,7 +891,7 @@ export const importDocument = createAction({
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
|
||||
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
@@ -970,7 +900,6 @@ 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(
|
||||
@@ -984,8 +913,6 @@ export const importDocument = createAction({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1003,12 +930,12 @@ export const createTemplateFromDocument = createAction({
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document?.isActive) {
|
||||
if (document?.isTemplate || !document?.isActive) {
|
||||
return false;
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createTemplate
|
||||
stores.policies.abilities(activeCollectionId).updateDocument
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
@@ -1055,8 +982,46 @@ 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: ({ t }) => t("Move"),
|
||||
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");
|
||||
},
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
@@ -1094,7 +1059,8 @@ export const moveDocument = createAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
// Don't show the button if this is a non-workspace template.
|
||||
if (!document || (document.template && !document.isWorkspaceTemplate)) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
@@ -1102,6 +1068,25 @@ 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",
|
||||
@@ -1160,7 +1145,10 @@ export const restoreDocument = createAction({
|
||||
: undefined;
|
||||
const can = stores.policies.abilities(document.id);
|
||||
|
||||
return !!collection?.isActive && !!(can.restore || can.unarchive);
|
||||
return (
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
perform: async ({ t, stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1197,7 +1185,10 @@ export const restoreDocumentToCollection = createActionWithChildren({
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
return !collection?.isActive && !!(can.restore || can.unarchive);
|
||||
return (
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
children: ({ t, activeDocumentId, stores }) => {
|
||||
const { collections, documents, policies } = stores;
|
||||
@@ -1339,7 +1330,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.set({ rightSidebar: "comments" });
|
||||
stores.ui.toggleComments();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1374,7 +1365,6 @@ 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 ?? "");
|
||||
@@ -1382,7 +1372,12 @@ export const openDocumentInsights = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return !!activeDocumentId && can.listViews && !document?.isDeleted;
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.listViews &&
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1461,7 +1456,6 @@ export const rootDocumentActions = [
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNewDocument,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
@@ -1483,6 +1477,7 @@ export const rootDocumentActions = [
|
||||
searchInDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
CaseSensitiveIcon,
|
||||
CollectionIcon,
|
||||
CopyIcon,
|
||||
MoveIcon,
|
||||
NewDocumentIcon,
|
||||
PlusIcon,
|
||||
PrintIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import { Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
|
||||
import {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
settingsPath,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
|
||||
export const createTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: TemplateSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!stores.policies.abilities(currentTeamId!).createTemplate,
|
||||
to: newTemplatePath(),
|
||||
});
|
||||
|
||||
export const deleteTemplate = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.delete),
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: t("template"),
|
||||
}),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await template.delete();
|
||||
history.push(settingsPath("templates"));
|
||||
toast.success(t("Template deleted"));
|
||||
}}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
|
||||
values={{
|
||||
templateName: template.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: ActiveTemplateSection,
|
||||
icon: ({ stores }) => {
|
||||
const { team } = stores.auth;
|
||||
return <TeamLogo model={team} size={AvatarSize.Small} />;
|
||||
},
|
||||
visible: ({ getActiveModel }) => {
|
||||
const template = getActiveModel(Template);
|
||||
return !!template?.collectionId;
|
||||
},
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await template.save({ collectionId: null });
|
||||
toast.success(t("Template moved"));
|
||||
stores.dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn't move the template, try again?"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplateToCollection = createAction({
|
||||
name: ({ t }) => t("Move to collection"),
|
||||
analyticsName: "Move template to collection",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CollectionIcon />,
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move template"),
|
||||
content: <TemplateMove template={template} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.move),
|
||||
children: [moveTemplateToWorkspace, moveTemplateToCollection],
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document from template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, getActiveModel, stores }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template || !currentTeamId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (template.collectionId) {
|
||||
return !!stores.policies.abilities(template.collectionId).createDocument;
|
||||
}
|
||||
return !!stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return "";
|
||||
}
|
||||
const collectionId = template?.collectionId ?? activeCollectionId;
|
||||
|
||||
const [pathname, search] = newDocumentPath(collectionId, {
|
||||
templateId: template.id,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateLink = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy template link",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
copy(urlify(template.path));
|
||||
toast.success(t("Link copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateAsPlainText = createAction({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
analyticsName: "Copy template as text",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CaseSensitiveIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toPlainText(template));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [copyTemplateLink, copyTemplateAsPlainText],
|
||||
});
|
||||
|
||||
export const printTemplate = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
|
||||
analyticsName: "Print template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
|
||||
perform: () => {
|
||||
setTimeout(window.print, 0);
|
||||
},
|
||||
});
|
||||
|
||||
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
|
||||
@@ -24,15 +24,6 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
|
||||
ActiveDocumentSection.priority = 0.9;
|
||||
|
||||
export const TemplateSection = ({ t }: ActionContext) => t("Template");
|
||||
|
||||
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
|
||||
const activeTemplate = stores.templates.active;
|
||||
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
|
||||
};
|
||||
|
||||
ActiveTemplateSection.priority = 0.9;
|
||||
|
||||
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
|
||||
|
||||
RecentSection.priority = 1;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
matchPath,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { RightSidebarProvider } from "~/components/RightSidebarContext";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
@@ -16,6 +23,8 @@ import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
@@ -23,6 +32,12 @@ import NotificationBadge from "./NotificationBadge";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments/Comments")
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
);
|
||||
@@ -33,7 +48,9 @@ type Props = {
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const layoutRef = React.useRef<HTMLDivElement>(null);
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
@@ -75,20 +92,50 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
</Fade>
|
||||
);
|
||||
|
||||
const showHistory =
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
}) && can.listRevisions;
|
||||
const showComments =
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded &&
|
||||
!!team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentContextProvider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout
|
||||
title={team.name}
|
||||
sidebar={sidebar}
|
||||
sidebarRight={sidebarRight}
|
||||
ref={layoutRef}
|
||||
>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
@@ -110,4 +109,4 @@ const Image = styled.img<{ size: number }>`
|
||||
height: ${(props) => props.size}px;
|
||||
`;
|
||||
|
||||
export default observer(Avatar);
|
||||
export default Avatar;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -122,4 +121,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
|
||||
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
|
||||
|
||||
@@ -23,9 +23,12 @@ const Container = styled.div<Props>`
|
||||
type ContentProps = { $maxWidth?: string };
|
||||
|
||||
const Content = styled.div<ContentProps>`
|
||||
max-width: ${(props: ContentProps) =>
|
||||
props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
max-width: ${(props) => props.$maxWidth ?? "46em"};
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({
|
||||
|
||||
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import * as RadixCollapsible from "@radix-ui/react-collapsible";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
interface CollapsibleProps {
|
||||
/** The label displayed on the trigger button. */
|
||||
label: React.ReactNode;
|
||||
/** The content to show/hide inside the collapsible panel. */
|
||||
children: React.ReactNode;
|
||||
/** Whether the collapsible is open by default. */
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state. */
|
||||
open?: boolean;
|
||||
/** Callback fired when the open state changes. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Additional class name for the root element. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An accessible collapsible section built on Radix UI Collapsible.
|
||||
* Renders a trigger button with a disclosure chevron and animated content panel.
|
||||
*
|
||||
* @param props - component props.
|
||||
* @returns the collapsible component.
|
||||
*/
|
||||
export function Collapsible({
|
||||
label,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
return (
|
||||
<RadixCollapsible.Root
|
||||
defaultOpen={defaultOpen}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
className={className}
|
||||
>
|
||||
<StyledTrigger>
|
||||
<StyledExpandedIcon aria-hidden="true" />
|
||||
{label}
|
||||
</StyledTrigger>
|
||||
<StyledContent>{children}</StyledContent>
|
||||
</RadixCollapsible.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease-out;
|
||||
margin-left: -4px;
|
||||
`;
|
||||
|
||||
const StyledTrigger = styled(RadixCollapsible.Trigger)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0 8px 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14pxte
|
||||
|
||||
&:hover {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
${StyledExpandedIcon} {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(RadixCollapsible.Content)`
|
||||
overflow: hidden;
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: slideDown 200ms ease-out;
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
animation: slideUp 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -13,7 +13,6 @@ 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";
|
||||
@@ -145,7 +144,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
<HStack>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("Name")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
maxLength: CollectionValidation.maxNameLength,
|
||||
@@ -190,44 +189,38 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(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}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 { templates } = useStores();
|
||||
const { documents } = useStores();
|
||||
|
||||
useEffect(() => {
|
||||
void templates.fetchAll();
|
||||
}, [templates]);
|
||||
void documents.fetchAllTemplates();
|
||||
}, [documents]);
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
templates.alphabetical.map((template) =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
createInternalLinkAction({
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
|
||||
},
|
||||
})
|
||||
),
|
||||
[templates.alphabetical]
|
||||
[documents.templatesAlphabetical]
|
||||
);
|
||||
|
||||
const newFromTemplate = useMemo(
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import {
|
||||
CollectionIcon as CollectionIconComponent,
|
||||
HomeIcon,
|
||||
PrivateCollectionIcon,
|
||||
} from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -19,112 +12,74 @@ type DefaultCollectionInputSelectProps = {
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
|
||||
const DefaultCollectionInputSelect = observer(
|
||||
({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections, ui } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = 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]);
|
||||
|
||||
if (fetching) {
|
||||
return null;
|
||||
}
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
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 [
|
||||
const options: Option[] = React.useMemo(
|
||||
() =>
|
||||
collections.nonPrivate.reduce(
|
||||
(acc, collection) => [
|
||||
...acc,
|
||||
{
|
||||
type: "item" as const,
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
value: collection.id,
|
||||
icon,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
},
|
||||
];
|
||||
},
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
] satisfies Option[]
|
||||
);
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
] satisfies Option[]
|
||||
),
|
||||
[collections.nonPrivate, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
);
|
||||
if (fetching) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultCollectionInputSelect;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
|
||||
import { ArchiveIcon, GoToIcon, ShapesIcon, 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, trashPath } from "~/utils/routeHelpers";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
@@ -67,6 +67,13 @@ 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,
|
||||
|
||||
+38
-31
@@ -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 Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
@@ -37,8 +37,13 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
: true
|
||||
);
|
||||
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [policies, collectionTrees]);
|
||||
}, [policies, collectionTrees, document.isTemplate]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
@@ -75,32 +80,34 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
<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>
|
||||
{!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>
|
||||
)}
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<Text ellipsis type="secondary">
|
||||
<StyledText type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Copy to <em>{{ location }}</em>"
|
||||
@@ -110,7 +117,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
) : (
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</Text>
|
||||
</StyledText>
|
||||
<Button disabled={!selectedPath || copying} onClick={copy}>
|
||||
{copying ? `${t("Copying")}…` : t("Copy")}
|
||||
</Button>
|
||||
+6
-19
@@ -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 "./DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
@@ -38,17 +38,9 @@ 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,
|
||||
showDocuments,
|
||||
}: Props) {
|
||||
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -149,8 +141,7 @@ function DocumentExplorer({
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
const normalizedBaseDepth =
|
||||
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
|
||||
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -225,7 +216,7 @@ function DocumentExplorer({
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || showDocuments !== false;
|
||||
nodes[node].children.length > 0 || nodes[node].type === "collection";
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -411,11 +402,7 @@ function DocumentExplorer({
|
||||
<ListSearch
|
||||
ref={inputSearchRef}
|
||||
onChange={handleSearch}
|
||||
placeholder={
|
||||
showDocuments
|
||||
? `${t("Search collections & documents")}…`
|
||||
: `${t("Search collections")}…`
|
||||
}
|
||||
placeholder={`${t("Search collections & documents")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
<ListContainer>
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
`;
|
||||
@@ -1,87 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Template from "~/models/Template";
|
||||
import Button from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
};
|
||||
|
||||
function TemplateMove({ template }: Props) {
|
||||
const { dialogs, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
collectionTrees
|
||||
.map((node) => ({ ...node, children: [] }))
|
||||
.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
),
|
||||
[policies, collectionTrees]
|
||||
);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const collectionId = (selectedPath.collectionId ??
|
||||
selectedPath.id) as string;
|
||||
await template.save({ collectionId });
|
||||
|
||||
toast.success(t("Template moved"));
|
||||
|
||||
dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t move the template, try again?"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer
|
||||
items={items}
|
||||
onSubmit={move}
|
||||
onSelect={selectPath}
|
||||
showDocuments={false}
|
||||
/>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Move to <em>{{ location }}</em>"
|
||||
values={{
|
||||
location: selectedPath.title,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath} onClick={move}>
|
||||
{t("Move")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TemplateMove);
|
||||
@@ -1,3 +0,0 @@
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
export default DocumentExplorer;
|
||||
-1
@@ -54,7 +54,6 @@ function DocumentExplorerNode(
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
<Spacer width={width}>
|
||||
{hasChildren && (
|
||||
+1
-2
@@ -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 "./DocumentExplorerNode";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
@@ -54,7 +54,6 @@ function DocumentExplorerSearchResult({
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
{icon}
|
||||
<Flex>
|
||||
@@ -22,7 +22,6 @@ import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
@@ -40,6 +39,7 @@ type Props = {
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
@@ -59,7 +59,6 @@ function DocumentListItem(
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMobile = useMobile();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
@@ -76,6 +75,7 @@ function DocumentListItem(
|
||||
showCollection,
|
||||
showPublished,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
@@ -83,7 +83,7 @@ function DocumentListItem(
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived;
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
@@ -101,10 +101,11 @@ function DocumentListItem(
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeModels: [
|
||||
document,
|
||||
...(!isShared && document.collection ? [document.collection] : []),
|
||||
],
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ContextMenu
|
||||
@@ -161,7 +162,10 @@ function DocumentListItem(
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && !isMobile && <StarButton document={document} />}
|
||||
{canStar && <StarButton document={document} />}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
|
||||
@@ -52,6 +52,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
isDraft,
|
||||
lastViewedAt,
|
||||
isTasks,
|
||||
isTemplate,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
@@ -141,7 +142,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getChildrenForDocument(document.id).length
|
||||
: 0;
|
||||
const canShowProgressBar = isTasks;
|
||||
const canShowProgressBar = isTasks && !isTemplate;
|
||||
|
||||
const timeSinceNow = () => {
|
||||
if (isDraft || !showLastViewed) {
|
||||
|
||||
@@ -3,11 +3,9 @@ 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";
|
||||
@@ -30,11 +28,9 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
const service =
|
||||
urlObj.hostname === "linear.app"
|
||||
? IntegrationService.Linear
|
||||
: urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.GitLab;
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -62,18 +58,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
<Description>{description}</Description>
|
||||
|
||||
<Flex wrap>
|
||||
{labels.map((label, index) => (
|
||||
|
||||
@@ -3,10 +3,8 @@ 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";
|
||||
@@ -50,18 +48,7 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
<Description>{description}</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -8,7 +7,6 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import { useRightSidebarContent } from "~/components/RightSidebarContext";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
@@ -21,15 +19,16 @@ type Props = {
|
||||
title?: string;
|
||||
/** Left sidebar content. */
|
||||
sidebar?: React.ReactNode;
|
||||
/** Right sidebar content. */
|
||||
sidebarRight?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Layout = React.forwardRef(function Layout_(
|
||||
{ title, children, sidebar }: Props,
|
||||
{ title, children, sidebar, sidebarRight }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
const sidebarRight = useRightSidebarContent();
|
||||
|
||||
return (
|
||||
<Container column auto ref={ref}>
|
||||
@@ -62,7 +61,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
<AnimatePresence initial={false}>{sidebarRight}</AnimatePresence>
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import type { ActionVariant, ActionWithChildren } from "~/types";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
@@ -62,6 +61,11 @@ export const ContextMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -76,7 +80,7 @@ export const ContextMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type {
|
||||
ActionVariant,
|
||||
ActionWithChildren,
|
||||
@@ -99,6 +98,11 @@ export const DropdownMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
@@ -125,7 +129,7 @@ export const DropdownMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
|
||||
@@ -39,7 +39,7 @@ const Container = styled(Text)`
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
margin: 1em 0;
|
||||
margin: 1em 0 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -103,7 +103,6 @@ const StyledLink = styled(Link)`
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
pointer-events: none;
|
||||
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
@@ -20,55 +20,6 @@ import Tooltip from "../Tooltip";
|
||||
import NotificationListItem from "./NotificationListItem";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
|
||||
/**
|
||||
* Hook that returns filtered notifications in a stable order. The order is
|
||||
* snapshotted on first call (when the popover mounts) so that toggling
|
||||
* read/unread does not cause items to jump positions. Notifications that
|
||||
* arrive after the snapshot are prepended at the top.
|
||||
*
|
||||
* @param active - the current list of active notifications.
|
||||
* @param filter - the selected notification filter category.
|
||||
* @returns filtered notifications in snapshot order.
|
||||
*/
|
||||
function useStableOrderedNotifications(
|
||||
active: Notification[],
|
||||
filter: NotificationFilter
|
||||
) {
|
||||
const orderSnapshotRef = React.useRef<string[] | null>(null);
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (orderSnapshotRef.current === null) {
|
||||
orderSnapshotRef.current = active.map((n) => n.id);
|
||||
}
|
||||
|
||||
const filtered =
|
||||
filter === "all"
|
||||
? active
|
||||
: active.filter((notification) =>
|
||||
Notification.filterCategories[filter].includes(notification.event)
|
||||
);
|
||||
|
||||
const snapshot = orderSnapshotRef.current;
|
||||
const orderMap = new Map(snapshot.map((id, index) => [id, index]));
|
||||
const inSnapshot: Notification[] = [];
|
||||
const newItems: Notification[] = [];
|
||||
|
||||
for (const notification of filtered) {
|
||||
if (orderMap.has(notification.id)) {
|
||||
inSnapshot.push(notification);
|
||||
} else {
|
||||
newItems.push(notification);
|
||||
}
|
||||
}
|
||||
|
||||
inSnapshot.sort(
|
||||
(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)
|
||||
);
|
||||
|
||||
return [...newItems, ...inSnapshot];
|
||||
}, [active, filter]);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
@@ -98,10 +49,16 @@ function Notifications(
|
||||
[t]
|
||||
);
|
||||
|
||||
const filteredNotifications = useStableOrderedNotifications(
|
||||
notifications.active,
|
||||
filter
|
||||
);
|
||||
const filteredNotifications = React.useMemo(() => {
|
||||
if (filter === "all") {
|
||||
return notifications.active;
|
||||
}
|
||||
|
||||
const eventTypes = Notification.filterCategories[filter];
|
||||
return notifications.active.filter((notification) =>
|
||||
eventTypes.includes(notification.event)
|
||||
);
|
||||
}, [notifications.active, filter]);
|
||||
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
showParentDocuments={showParentDocuments}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showTemplate={showTemplate}
|
||||
showDraft={showDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -125,7 +125,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeModels: [document] }}>
|
||||
<ActionContextProvider value={{ activeDocumentId: document.id }}>
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
ariaLabel={t("Revision options")}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type SetSidebarFn = (content: React.ReactNode) => void;
|
||||
|
||||
const RightSidebarSetterContext = React.createContext<SetSidebarFn | null>(
|
||||
null
|
||||
);
|
||||
const RightSidebarContentContext = React.createContext<React.ReactNode>(null);
|
||||
|
||||
/**
|
||||
* Provider that holds right sidebar content state. Wrap at the layout level
|
||||
* so that scenes can set sidebar content via the setter hook.
|
||||
*/
|
||||
export function RightSidebarProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [content, setContent] = React.useState<React.ReactNode>(null);
|
||||
|
||||
return (
|
||||
<RightSidebarSetterContext.Provider value={setContent}>
|
||||
<RightSidebarContentContext.Provider value={content}>
|
||||
{children}
|
||||
</RightSidebarContentContext.Provider>
|
||||
</RightSidebarSetterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable setter function to set the right sidebar content.
|
||||
* Used by scenes (e.g. Document) to populate the sidebar.
|
||||
*/
|
||||
export function useSetRightSidebar(): SetSidebarFn {
|
||||
const setter = React.useContext(RightSidebarSetterContext);
|
||||
if (!setter) {
|
||||
throw new Error(
|
||||
"useSetRightSidebar must be used within a RightSidebarProvider"
|
||||
);
|
||||
}
|
||||
return setter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current right sidebar content. Used by Layout to render
|
||||
* the sidebar.
|
||||
*/
|
||||
export function useRightSidebarContent(): React.ReactNode {
|
||||
return React.useContext(RightSidebarContentContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Context indicating whether the Right sidebar wrapper is already rendered
|
||||
* by an ancestor. When true, SidebarLayout skips rendering its own Right
|
||||
* wrapper to avoid duplicate animated containers.
|
||||
*/
|
||||
export const RightSidebarWrappedContext = React.createContext(false);
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type { SearchResult } from "~/types";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
@@ -29,112 +28,73 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const focusRef = React.useRef<HTMLElement | null>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
>();
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
>(searchResults);
|
||||
|
||||
// Cache search results by query string to avoid redundant API calls
|
||||
const cacheRef = React.useRef(new Map<string, SearchResult[]>());
|
||||
const queryRef = React.useRef(query);
|
||||
queryRef.current = query;
|
||||
|
||||
// When the query changes, restore cached results (including empty) or keep
|
||||
// previous results visible until new results arrive to avoid layout shift
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
setSearchResults(undefined);
|
||||
return;
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
setOpen(true);
|
||||
}
|
||||
}, [searchResults, query]);
|
||||
|
||||
const cached = cacheRef.current.get(query);
|
||||
if (cached !== undefined) {
|
||||
setSearchResults(cached);
|
||||
if (cached.length) {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
// Clear search results when the query changes to prevent stale results
|
||||
React.useEffect(() => {
|
||||
setSearchResults(undefined);
|
||||
}, [query]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({
|
||||
query: searchQuery,
|
||||
offset = 0,
|
||||
...options
|
||||
}: Record<string, any>) => {
|
||||
if (!searchQuery?.length) {
|
||||
return undefined;
|
||||
async ({ query: searchQuery, ...options }) => {
|
||||
if (searchQuery?.length > 0) {
|
||||
const response = await documents.search({
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.length) {
|
||||
setSearchResults((state) => [...(state ?? []), ...response]);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Return cached results for first-page lookups
|
||||
if (offset === 0 && cacheRef.current.has(searchQuery)) {
|
||||
return cacheRef.current.get(searchQuery)!;
|
||||
}
|
||||
|
||||
// Force offset to 0 for new queries — PaginatedList's reset() sets
|
||||
// offset via setState but fetchResults still uses the stale value
|
||||
// from its closure
|
||||
if (!cacheRef.current.has(searchQuery)) {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const response = await documents.search({
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
offset,
|
||||
...options,
|
||||
});
|
||||
|
||||
// Build complete result set in cache: replace for new queries, append
|
||||
// for pagination of an existing query
|
||||
const existing = cacheRef.current.get(searchQuery);
|
||||
cacheRef.current.set(
|
||||
searchQuery,
|
||||
existing ? [...existing, ...response] : response
|
||||
);
|
||||
|
||||
// Only update state if this query is still current to prevent stale
|
||||
// results from overwriting newer results after a race condition
|
||||
if (queryRef.current === searchQuery) {
|
||||
setSearchResults(cacheRef.current.get(searchQuery)!);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
return response;
|
||||
return undefined;
|
||||
},
|
||||
[documents, shareId]
|
||||
);
|
||||
|
||||
const debouncedSetQuery = React.useMemo(
|
||||
const handleSearchInputChange = React.useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setQuery(value);
|
||||
setOpen(!!value);
|
||||
}, 250),
|
||||
[]
|
||||
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
const trimmedValue = value.trim();
|
||||
setQuery(trimmedValue);
|
||||
setOpen(!!trimmedValue);
|
||||
}, 300),
|
||||
[cachedQuery]
|
||||
);
|
||||
|
||||
const handleSearchInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debouncedSetQuery(event.target.value.trim());
|
||||
},
|
||||
[debouncedSetQuery]
|
||||
);
|
||||
|
||||
React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const handleEscapeList = React.useCallback(
|
||||
() => searchInputRef.current?.focus(),
|
||||
[]
|
||||
() => searchInputRef?.current?.focus(),
|
||||
[searchInputRef]
|
||||
);
|
||||
|
||||
const handleSearchInputFocus = React.useCallback(() => {
|
||||
focusRef.current = searchInputRef.current;
|
||||
}, []);
|
||||
}, [searchInputRef]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -146,7 +106,6 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (searchResults) {
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||
@@ -157,12 +116,12 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (atEnd) {
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
if (open || atEnd) {
|
||||
ev.preventDefault();
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
@@ -172,17 +131,21 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
|
||||
if (ev.currentTarget.value) {
|
||||
if (ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
ev.preventDefault();
|
||||
if (ev.key === "Escape") {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[open, searchResults]
|
||||
@@ -190,12 +153,11 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
|
||||
const handleSearchItemClick = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = "";
|
||||
focusRef.current = document.getElementById(bodyContentId);
|
||||
}
|
||||
}, []);
|
||||
}, [searchInputRef]);
|
||||
|
||||
useKeyDown("/", (ev) => {
|
||||
if (
|
||||
@@ -231,7 +193,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
align="start"
|
||||
shrink
|
||||
onEscapeKeyDown={handleEscapeList}
|
||||
onOpenAutoFocus={preventDefault}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target === searchInputRef.current) {
|
||||
@@ -241,13 +203,8 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{
|
||||
query,
|
||||
snippetMinWords: 10,
|
||||
snippetMaxWords: 11,
|
||||
limit: 10,
|
||||
}}
|
||||
items={searchResults}
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
onEscape={handleEscapeList}
|
||||
empty={
|
||||
@@ -261,7 +218,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ref={index === 0 ? firstSearchItem : undefined}
|
||||
document={item.document}
|
||||
context={item.context}
|
||||
highlight={query}
|
||||
highlight={cachedQuery}
|
||||
onClick={handleSearchItemClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -89,11 +89,7 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
const members = React.useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
Array.from(
|
||||
new Map(
|
||||
document.members.map((memberUser) => [memberUser.id, memberUser])
|
||||
).values()
|
||||
),
|
||||
document.members,
|
||||
(memberUser) =>
|
||||
(invitedInSession.includes(memberUser.id) ? "_" : "") +
|
||||
memberUser.name.toLocaleLowerCase(),
|
||||
@@ -128,19 +124,12 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(
|
||||
new Map(
|
||||
groupMemberships
|
||||
.inDocument(document.id)
|
||||
.map((membership) => [membership.group.id, membership])
|
||||
).values()
|
||||
)
|
||||
{groupMemberships
|
||||
.inDocument(document.id)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(
|
||||
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
|
||||
)
|
||||
).localeCompare(b.group.name)
|
||||
)
|
||||
.map((membership) => {
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
|
||||
@@ -193,8 +193,8 @@ export const Suggestions = observer(
|
||||
...pending.map((suggestion) => (
|
||||
<PendingListItem
|
||||
keyboardNavigation
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion)}
|
||||
key={suggestion.id}
|
||||
onClick={() => removePendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
@@ -212,14 +212,12 @@ export const Suggestions = observer(
|
||||
/>
|
||||
)),
|
||||
pending.length > 0 &&
|
||||
(suggestionsWithPending.length > 0 || isEmpty) && (
|
||||
<Separator key="separator" />
|
||||
),
|
||||
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
|
||||
...suggestionsWithPending.map((suggestion) => (
|
||||
<ListItem
|
||||
keyboardNavigation
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion as User)}
|
||||
key={suggestion.id}
|
||||
onClick={() => addPendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
@@ -232,9 +230,7 @@ export const Suggestions = observer(
|
||||
/>
|
||||
)),
|
||||
isEmpty && (
|
||||
<Empty key="empty" style={{ marginTop: 22 }}>
|
||||
{t("No matches")}
|
||||
</Empty>
|
||||
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
|
||||
),
|
||||
]}
|
||||
</ArrowKeyNavigation>
|
||||
|
||||
@@ -8,23 +8,19 @@ import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useWindowScrollbarWidth from "~/hooks/useWindowScrollbarWidth";
|
||||
import { sidebarAppearDuration } from "~/styles/animations";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
border?: boolean;
|
||||
/** When true, skip the entrance animation and render at full width immediately. */
|
||||
skipInitialAnimation?: boolean;
|
||||
}
|
||||
|
||||
function Right({ children, border, className, skipInitialAnimation }: Props) {
|
||||
function Right({ children, border, className }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const windowScrollbarWidth = useWindowScrollbarWidth();
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -71,20 +67,16 @@ function Right({ children, border, className, skipInitialAnimation }: Props) {
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: windowScrollbarWidth
|
||||
? `${ui.sidebarRightWidth - windowScrollbarWidth}px`
|
||||
: `${ui.sidebarRightWidth}px`,
|
||||
width: `${ui.sidebarRightWidth}px`,
|
||||
}),
|
||||
[ui.sidebarRightWidth, windowScrollbarWidth]
|
||||
[ui.sidebarRightWidth]
|
||||
);
|
||||
|
||||
const animationProps = {
|
||||
initial: skipInitialAnimation
|
||||
? false
|
||||
: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
initial: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
animate: {
|
||||
transition: isResizing
|
||||
? { duration: 0 }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import type Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -47,7 +48,7 @@ function SharedSidebar({ share }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar canCollapse={false}>
|
||||
<StyledSidebar $hoverTransition={!teamAvailable} canCollapse={false}>
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
@@ -89,7 +90,7 @@ function SharedSidebar({ share }: Props) {
|
||||
)}
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
</Sidebar>
|
||||
</StyledSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,4 +113,33 @@ const StyledSearchPopover = styled(SearchPopover)`
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
const ToggleWrapper = styled.div`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
transition:
|
||||
opacity 100ms ease-out,
|
||||
transform 100ms ease-out;
|
||||
`;
|
||||
|
||||
const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${({ $hoverTransition }) =>
|
||||
$hoverTransition &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:${hover} {
|
||||
${StyledSearchPopover} {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
${ToggleWrapper} {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(SharedSidebar);
|
||||
|
||||
@@ -127,7 +127,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<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={{
|
||||
activeModels: document ? [document] : [],
|
||||
activeDocumentId: node.id,
|
||||
}}
|
||||
>
|
||||
<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,5 +1,4 @@
|
||||
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";
|
||||
@@ -19,46 +18,44 @@ export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
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}
|
||||
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}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Button
|
||||
{...rest}
|
||||
onClick={onClick}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Content>
|
||||
{image}
|
||||
{title && <Title>{title}</Title>}
|
||||
</Content>
|
||||
{showMoreMenu && <StyledMoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
)
|
||||
<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={{
|
||||
activeModels: [document],
|
||||
activeDocumentId: document.id,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -37,9 +37,8 @@ function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeModels: [document, collection].filter(
|
||||
(m): m is Document | Collection => !!m
|
||||
),
|
||||
activeDocumentId: document?.id,
|
||||
activeCollectionId: collection?.id,
|
||||
}}
|
||||
>
|
||||
<NudeButton
|
||||
|
||||
@@ -28,7 +28,6 @@ interface Props extends Omit<
|
||||
disabled?: boolean;
|
||||
/** Callback when the switch state changes */
|
||||
onChange?: (checked: boolean) => void;
|
||||
inForm?: boolean;
|
||||
}
|
||||
|
||||
function Switch(
|
||||
@@ -36,7 +35,6 @@ function Switch(
|
||||
width = 32,
|
||||
height = 18,
|
||||
labelPosition = "left",
|
||||
inForm = true,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
@@ -73,7 +71,7 @@ function Switch(
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Wrapper $inForm={inForm}>
|
||||
<Wrapper>
|
||||
<Label
|
||||
disabled={disabled}
|
||||
htmlFor={props.id}
|
||||
@@ -102,8 +100,8 @@ function Switch(
|
||||
return component;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div<{ $inForm?: boolean }>`
|
||||
padding-bottom: ${(props) => (props.$inForm ? 8 : 0)}px;
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
${undraggableOnDesktop()}
|
||||
`;
|
||||
|
||||
|
||||
@@ -95,13 +95,6 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
/** Restrict shared layout animation to the X axis only. */
|
||||
const horizontalOnly = (transform: Record<string, string>, generated: string) =>
|
||||
generated.replace(
|
||||
/translate3d\(([^,]+),\s*[^,]+,\s*([^)]+)\)/,
|
||||
"translate3d($1, 0px, $2)"
|
||||
);
|
||||
|
||||
const Tab: React.FC<Props> = (props: Props) => {
|
||||
const { children, exact, exactQueryString } = props;
|
||||
const theme = useTheme();
|
||||
@@ -119,7 +112,6 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</TabButton>
|
||||
@@ -148,7 +140,6 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -26,7 +26,6 @@ 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;
|
||||
|
||||
@@ -235,13 +234,7 @@ function Table<TData>({
|
||||
</TR>
|
||||
);
|
||||
|
||||
return decorateRow ? (
|
||||
<React.Fragment key={row.id}>
|
||||
{decorateRow(row.original, baseRow)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
baseRow
|
||||
);
|
||||
return decorateRow ? decorateRow(row.original, baseRow) : baseRow;
|
||||
})}
|
||||
</TBody>
|
||||
{showPlaceholder && (
|
||||
@@ -337,8 +330,7 @@ const THead = styled.div<{ $topPos: number }>`
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
|
||||
border-bottom: 1px solid
|
||||
${(props) => transparentize(0.3, props.theme.divider)};
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
`;
|
||||
|
||||
@@ -352,17 +344,12 @@ const TR = styled.div<{ $columns: string }>`
|
||||
display: grid;
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid
|
||||
${(props) => transparentize(0.3, props.theme.divider)};
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover ${NudeButton}[aria-haspopup="menu"] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const TH = styled.span`
|
||||
@@ -408,17 +395,11 @@ const TD = styled.span`
|
||||
|
||||
${NudeButton}[aria-haspopup="menu"] {
|
||||
vertical-align: middle;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
|
||||
&[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayoutGroup } from "framer-motion";
|
||||
import { AnimateSharedLayout } from "framer-motion";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -84,13 +84,13 @@ const Tabs: React.FC = ({ children }: Props) => {
|
||||
}, [width, updateShadows]);
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<AnimateSharedLayout>
|
||||
<Sticky>
|
||||
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
|
||||
{children}
|
||||
</Nav>
|
||||
</Sticky>
|
||||
</LayoutGroup>
|
||||
</AnimateSharedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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} />;
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { InputIcon, ShapesIcon } from "outline-icons";
|
||||
import React, { useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import type Template from "~/models/Template";
|
||||
import Editor from "~/scenes/Document/components/Editor";
|
||||
import { DocumentContextProvider } from "~/components/DocumentContext";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Notice from "~/components/Notice";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
export const TemplateForm = observer(function TemplateForm_({
|
||||
handleSubmit,
|
||||
template,
|
||||
}: {
|
||||
handleSubmit: (template: Template) => void;
|
||||
template: Template;
|
||||
}) {
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(template);
|
||||
const dataRef = useRef(template.data);
|
||||
const ref = useRef(null);
|
||||
const [isUploading, handleStartUpload, handleStopUpload] = useBoolean();
|
||||
const readOnly = !can.update && !template.isNew;
|
||||
|
||||
const handleChangeTitle = (title: string) => {
|
||||
template.title = title;
|
||||
};
|
||||
|
||||
const handleChangeIcon = (icon: string, color: string) => {
|
||||
template.icon = icon;
|
||||
template.color = color;
|
||||
};
|
||||
|
||||
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
|
||||
dataRef.current = value(false);
|
||||
template.data = dataRef.current;
|
||||
};
|
||||
|
||||
const handleSave = (options: { autosave?: boolean }) => {
|
||||
if (options.autosave) {
|
||||
return;
|
||||
}
|
||||
handleSubmit(template);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogs.closeAllModals();
|
||||
};
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentContextProvider>
|
||||
<React.Suspense fallback={null}>
|
||||
{isUploading && <LoadingIndicator />}
|
||||
<Notice
|
||||
icon={<ShapesIcon />}
|
||||
description={
|
||||
<Trans>
|
||||
Highlight some text and use the <PlaceholderIcon /> control to add
|
||||
placeholders that can be filled out when creating new documents
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
{t("You’re editing a template")}
|
||||
</Notice>
|
||||
<Editor
|
||||
id={template.id}
|
||||
ref={ref}
|
||||
isDraft={false}
|
||||
document={template}
|
||||
value={readOnly ? template.data : undefined}
|
||||
defaultValue={template.data}
|
||||
onFileUploadStart={handleStartUpload}
|
||||
onFileUploadStop={handleStopUpload}
|
||||
onChangeTitle={handleChangeTitle}
|
||||
onChangeIcon={handleChangeIcon}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onChange={handleChange}
|
||||
readOnly={readOnly}
|
||||
canUpdate={can.update}
|
||||
autoFocus={template.createdAt === template.updatedAt}
|
||||
template
|
||||
/>
|
||||
</React.Suspense>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const PlaceholderIcon = styled(InputIcon)`
|
||||
position: relative;
|
||||
top: 6px;
|
||||
margin-top: -6px;
|
||||
`;
|
||||
@@ -1,36 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import Template from "~/models/Template";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { TemplateForm } from "./TemplateForm";
|
||||
|
||||
type Props = {
|
||||
collectionId?: string | null;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
export const TemplateNew = observer(function TemplateNew_({
|
||||
collectionId,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { templates } = useStores();
|
||||
const [template] = useState(
|
||||
new Template({ title: "", collectionId }, templates)
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
await template.save();
|
||||
onSubmit?.();
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
}, [template, onSubmit]);
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
|
||||
});
|
||||
@@ -49,7 +49,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
|
||||
collections.orderedData.reduce<Option[]>((memo, collection) => {
|
||||
const canCollection = policies.abilities(collection.id);
|
||||
|
||||
if (canCollection.createTemplate) {
|
||||
if (canCollection.createDocument) {
|
||||
memo.push({
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 = {
|
||||
@@ -17,7 +18,7 @@ type Props = {
|
||||
function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { documents, templates } = useStores();
|
||||
const { documents } = useStores();
|
||||
const document = documents.get(documentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
@@ -27,17 +28,15 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await templates.templatize({
|
||||
id: documentId,
|
||||
const template = await document?.templatize({
|
||||
collectionId,
|
||||
publish,
|
||||
});
|
||||
|
||||
if (template) {
|
||||
history.push(template.path);
|
||||
history.push(documentPath(template));
|
||||
toast.success(t("Template created, go ahead and customize it"));
|
||||
}
|
||||
}, [t, templates, documentId, history, collectionId, publish]);
|
||||
}, [t, document, history, collectionId, publish]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
|
||||
@@ -40,7 +40,7 @@ const DrawerContent = React.forwardRef<
|
||||
transition: { bounce: 0, duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<StyledInnerContent column ref={measureRef} {...rest}>
|
||||
<StyledInnerContent ref={measureRef} {...rest}>
|
||||
{children}
|
||||
</StyledInnerContent>
|
||||
</StyledContent>
|
||||
@@ -58,9 +58,9 @@ const DrawerTitle = React.forwardRef<
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<StyledText size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
{children}
|
||||
</StyledText>
|
||||
</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -75,10 +75,6 @@ const DrawerTitle = React.forwardRef<
|
||||
});
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(m.div)`
|
||||
z-index: ${depths.menu};
|
||||
@@ -96,7 +92,7 @@ const StyledContent = styled(m.div)`
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
const StyledInnerContent = styled(Flex)`
|
||||
const StyledInnerContent = styled.div`
|
||||
padding: 6px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
@@ -129,7 +129,7 @@ const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
|
||||
`}
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1);
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
!props.disabled &&
|
||||
`
|
||||
&[data-highlighted],
|
||||
&[data-state="open"],
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
|
||||
@@ -20,7 +20,6 @@ function BlockMenu(props: Props) {
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
disclosure={options.disclosure}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -39,6 +38,7 @@ const EmojiMenu = (props: Props) => {
|
||||
.map((item) => {
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
// @ts-expect-error emojiMartToGemoji key
|
||||
const id = emojiMartToGemoji[item.id] || item.id;
|
||||
const type = determineIconType(id);
|
||||
const value = type === IconType.Custom ? id : snakeCase(id);
|
||||
@@ -76,4 +76,4 @@ const EmojiMenu = (props: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(EmojiMenu);
|
||||
export default EmojiMenu;
|
||||
|
||||
@@ -375,10 +375,6 @@ export default function FindAndReplace({
|
||||
minWidth={420}
|
||||
scrollable={false}
|
||||
onPointerDownOutside={() => setLocalOpen(false)}
|
||||
onFocusOutside={(event) => {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
style={{ marginRight: 16, marginTop: 60 }}
|
||||
>
|
||||
<Content column>
|
||||
|
||||
@@ -90,19 +90,12 @@ function usePosition({
|
||||
} as DOMRect);
|
||||
|
||||
// position at the top right of code blocks
|
||||
const isCodeNodeSelection =
|
||||
selection instanceof NodeSelection && isCode(selection.node);
|
||||
const codeBlock = isCodeNodeSelection
|
||||
? { pos: selection.from, node: selection.node }
|
||||
: findParentNode(isCode)(view.state.selection);
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
const noticeBlock = findParentNode(
|
||||
(node) => node.type.name === "container_notice"
|
||||
)(view.state.selection);
|
||||
|
||||
if (
|
||||
(codeBlock || noticeBlock) &&
|
||||
(view.state.selection.empty || isCodeNodeSelection)
|
||||
) {
|
||||
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
|
||||
const position = codeBlock
|
||||
? codeBlock.pos
|
||||
: noticeBlock
|
||||
|
||||
@@ -44,6 +44,7 @@ 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;
|
||||
@@ -75,15 +76,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (actorId && !loading) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [actorId, loading]);
|
||||
|
||||
// Computed in the render body so MobX observer can track store access
|
||||
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
|
||||
// runs outside the reactive context and triggered MobX warnings.
|
||||
const items: MentionItem[] = actorId
|
||||
? users
|
||||
const items: MentionItem[] = users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
@@ -129,9 +122,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
</Flex>
|
||||
),
|
||||
title: group.name,
|
||||
subtitle: t("{{ count }} members", {
|
||||
count: group.memberCount,
|
||||
}),
|
||||
subtitle: t("{{ count }} members", { count: group.memberCount }),
|
||||
section: GroupSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
@@ -227,8 +218,22 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
label: search,
|
||||
},
|
||||
} as MentionItem,
|
||||
])
|
||||
: [];
|
||||
]);
|
||||
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [
|
||||
t,
|
||||
actorId,
|
||||
loading,
|
||||
search,
|
||||
users,
|
||||
documents,
|
||||
maxResultsInSection,
|
||||
groups,
|
||||
collections,
|
||||
]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
|
||||
@@ -67,10 +67,7 @@ function useItems({
|
||||
|
||||
const singleUrl =
|
||||
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
|
||||
const matchedEmbed = singleUrl
|
||||
? getMatchingEmbed(embeds, singleUrl)?.embed
|
||||
: null;
|
||||
const embed = matchedEmbed?.disabled ? null : matchedEmbed;
|
||||
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
|
||||
|
||||
// Check embeddability for single URL
|
||||
useEffect(() => {
|
||||
|
||||
@@ -240,10 +240,7 @@ export function SelectionToolbar(props: Props) {
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
|
||||
if (
|
||||
isCodeSelection &&
|
||||
(selection.empty || selection instanceof NodeSelection)
|
||||
) {
|
||||
if (isCodeSelection && selection.empty) {
|
||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||
align = "end";
|
||||
} else if (isTableSelected(state)) {
|
||||
|
||||
@@ -6,26 +6,21 @@ import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { s } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { MouseSafeArea } from "~/components/MouseSafeArea";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -34,6 +29,38 @@ import { useEditor } from "./EditorContext";
|
||||
import Input from "./Input";
|
||||
import { MenuHeader } from "~/components/primitives/components/Menu";
|
||||
|
||||
type TopAnchor = {
|
||||
top: number;
|
||||
bottom: undefined;
|
||||
};
|
||||
|
||||
type BottomAnchor = {
|
||||
top: undefined;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
type LeftAnchor = {
|
||||
left: number;
|
||||
right: undefined;
|
||||
};
|
||||
|
||||
type RightAnchor = {
|
||||
left: undefined;
|
||||
right: number;
|
||||
};
|
||||
|
||||
type Position = ((TopAnchor | BottomAnchor) & (LeftAnchor | RightAnchor)) & {
|
||||
isAbove: boolean;
|
||||
};
|
||||
|
||||
const defaultPosition: Position = {
|
||||
top: 0,
|
||||
bottom: undefined,
|
||||
left: -10000,
|
||||
right: undefined,
|
||||
isAbove: false,
|
||||
};
|
||||
|
||||
export type Props<T extends MenuItem = MenuItem> = {
|
||||
rtl: boolean;
|
||||
isActive: boolean;
|
||||
@@ -53,7 +80,6 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
index: number,
|
||||
options: {
|
||||
selected: boolean;
|
||||
disclosure?: boolean;
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
) => React.ReactNode;
|
||||
@@ -66,65 +92,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const dictionary = useDictionary();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const hasActivated = React.useRef(false);
|
||||
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
});
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
|
||||
const [position, setPosition] = React.useState<Position>(defaultPosition);
|
||||
const [insertItem, setInsertItem] = React.useState<
|
||||
MenuItem | EmbedDescriptor
|
||||
>();
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [submenu, setSubmenu] = React.useState<{
|
||||
index: number;
|
||||
items: MenuItem[];
|
||||
selectedIndex: number;
|
||||
} | null>(null);
|
||||
const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map());
|
||||
const submenuContentRef = React.useRef<HTMLDivElement>(null);
|
||||
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Stores the caret bounding rect, snapshotted when the menu opens
|
||||
const caretRectRef = React.useRef(new DOMRect());
|
||||
|
||||
// Stable virtual element for Radix PopoverAnchor – never replaced so the
|
||||
// popper does not trigger unnecessary anchor-change cycles.
|
||||
const caretRef = React.useRef({
|
||||
getBoundingClientRect: () => caretRectRef.current,
|
||||
});
|
||||
|
||||
// Compute and store the caret rect during render so it is available before
|
||||
// the Radix popper effect runs for the first time.
|
||||
const caretRect = React.useMemo(() => {
|
||||
if (!props.isActive) {
|
||||
return new DOMRect();
|
||||
}
|
||||
|
||||
try {
|
||||
const { selection } = view.state;
|
||||
const fromPos = view.coordsAtPos(selection.from);
|
||||
const toPos = view.coordsAtPos(selection.to, -1);
|
||||
const top = Math.min(fromPos.top, toPos.top);
|
||||
const bottom = Math.max(fromPos.bottom, toPos.bottom);
|
||||
const left = Math.min(fromPos.left, toPos.left);
|
||||
const right = Math.max(fromPos.right, toPos.right);
|
||||
return new DOMRect(left, top, right - left, bottom - top);
|
||||
} catch (err) {
|
||||
Logger.warn("Unable to calculate caret position", err);
|
||||
return new DOMRect();
|
||||
}
|
||||
}, [props.isActive, view]);
|
||||
|
||||
caretRectRef.current = caretRect;
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem["children"]
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.isActive) {
|
||||
hasActivated.current = true;
|
||||
// Save the selection position when the menu opens. On mobile, the editor
|
||||
// may lose focus/selection when tapping on menu items, so we restore it.
|
||||
requestAnimationFrame(() => {
|
||||
@@ -137,21 +121,81 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSubmenu(null);
|
||||
const calculatePosition = React.useCallback(
|
||||
(props: Props) => {
|
||||
if (!props.isActive) {
|
||||
return defaultPosition;
|
||||
}
|
||||
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
const caretPosition = () => {
|
||||
let fromPos;
|
||||
let toPos;
|
||||
try {
|
||||
fromPos = view.coordsAtPos(selection.from);
|
||||
toPos = view.coordsAtPos(selection.to, -1);
|
||||
} catch (err) {
|
||||
Logger.warn("Unable to calculate caret position", err);
|
||||
return {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedIndex(0);
|
||||
setInsertItem(undefined);
|
||||
}, [props.isActive]);
|
||||
// ensure that start < end for the menu to be positioned correctly
|
||||
return {
|
||||
top: Math.min(fromPos.top, toPos.top),
|
||||
bottom: Math.max(fromPos.bottom, toPos.bottom),
|
||||
left: Math.min(fromPos.left, toPos.left),
|
||||
right: Math.max(fromPos.right, toPos.right),
|
||||
};
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setSubmenu(null);
|
||||
}, [props.search]);
|
||||
const { selection } = view.state;
|
||||
const ref = menuRef.current;
|
||||
const offsetWidth = ref ? ref.offsetWidth : 0;
|
||||
const offsetHeight = ref ? ref.offsetHeight : 0;
|
||||
const { top, bottom, right, left } = caretPosition();
|
||||
const margin = 12;
|
||||
|
||||
const offsetParent = ref?.offsetParent
|
||||
? ref.offsetParent.getBoundingClientRect()
|
||||
: ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
} as DOMRect);
|
||||
|
||||
let leftPos = Math.min(
|
||||
left - offsetParent.left,
|
||||
window.innerWidth - offsetParent.left - offsetWidth - margin
|
||||
);
|
||||
if (props.rtl) {
|
||||
leftPos = right - offsetWidth;
|
||||
}
|
||||
|
||||
if (top - offsetHeight > margin) {
|
||||
return {
|
||||
left: leftPos,
|
||||
top: undefined,
|
||||
bottom: offsetParent.bottom - top,
|
||||
right: undefined,
|
||||
isAbove: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
left: leftPos,
|
||||
top: bottom - offsetParent.top,
|
||||
bottom: undefined,
|
||||
right: undefined,
|
||||
isAbove: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
[view]
|
||||
);
|
||||
|
||||
const handleClearSearch = React.useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -182,6 +226,26 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
);
|
||||
}, [props.search, props.trigger, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset scroll position to top when opening menu as the contents are
|
||||
// hidden, not unrendered
|
||||
if (menuRef.current) {
|
||||
menuRef.current.scroll({ top: 0 });
|
||||
}
|
||||
|
||||
setPosition(calculatePosition(props));
|
||||
setSelectedIndex(0);
|
||||
setInsertItem(undefined);
|
||||
}, [calculatePosition, props.isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [props.search]);
|
||||
|
||||
const restoreSelection = React.useCallback(() => {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
@@ -397,7 +461,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const embedItems: EmbedDescriptor[] = [];
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title && embed.visible !== false && !embed.disabled) {
|
||||
if (embed.title && embed.visible !== false) {
|
||||
embedItems.push(
|
||||
new EmbedDescriptor({
|
||||
...embed,
|
||||
@@ -417,47 +481,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
|
||||
const searchInput = search.toLowerCase();
|
||||
|
||||
const matchesSearch = (item: MenuItem | EmbedDescriptor) =>
|
||||
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.keywords || "").toLocaleLowerCase().includes(searchInput);
|
||||
|
||||
// When searching, flatten matching children into the top-level list so
|
||||
// they are directly navigable with the keyboard. If all children match,
|
||||
// exclude the parent item since it would be redundant.
|
||||
const fullyFlattenedParents = new Set<MenuItem | EmbedDescriptor>();
|
||||
if (search && filterable) {
|
||||
const flattened: (EmbedDescriptor | MenuItem)[] = [];
|
||||
for (const item of items) {
|
||||
if ("children" in item && item.children) {
|
||||
const children = resolveChildren(item.children);
|
||||
if (children) {
|
||||
const matching = children.filter(matchesSearch);
|
||||
if (matching.length > 0) {
|
||||
for (const child of matching) {
|
||||
const { children: _, ...flat } = child;
|
||||
flattened.push(flat);
|
||||
}
|
||||
if (matching.length === children.length) {
|
||||
fullyFlattenedParents.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items = items.concat(flattened);
|
||||
}
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
if (item.name === "separator") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fullyFlattenedParents.has(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.visible === false) {
|
||||
return false;
|
||||
}
|
||||
@@ -486,7 +514,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return matchesSearch(item);
|
||||
return (
|
||||
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.keywords || "").toLocaleLowerCase().includes(searchInput)
|
||||
);
|
||||
});
|
||||
|
||||
return filterExcessSeparators(
|
||||
@@ -509,40 +541,18 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
);
|
||||
}, [commands, props]);
|
||||
|
||||
const openSubmenu = React.useCallback(
|
||||
(index: number) => {
|
||||
const item = filtered[index];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (!children?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = filterExcessSeparators(
|
||||
children.filter((child) => child.visible !== false)
|
||||
);
|
||||
const firstSelectable = normalized.findIndex(
|
||||
(child) =>
|
||||
child.name !== "separator" && !("disabled" in child && child.disabled)
|
||||
);
|
||||
if (firstSelectable === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmenu({
|
||||
index,
|
||||
items: normalized,
|
||||
selectedIndex: firstSelectable,
|
||||
});
|
||||
},
|
||||
[filtered]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
!menuRef.current ||
|
||||
menuRef.current.contains(event.target as Element)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
@@ -551,109 +561,18 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the link input's own handlers manage navigation keys
|
||||
if (insertItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Submenu open: route keys into it ---
|
||||
if (submenu) {
|
||||
if (event.key === "ArrowDown" || (event.ctrlKey && event.key === "n")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const total = submenu.items.length - 1;
|
||||
let next = submenu.selectedIndex + 1;
|
||||
while (next <= total) {
|
||||
const child = submenu.items[next];
|
||||
if (
|
||||
child?.name !== "separator" &&
|
||||
!("disabled" in child && child.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
next++;
|
||||
}
|
||||
if (next <= total) {
|
||||
setSubmenu((s) => (s ? { ...s, selectedIndex: next } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let prev = submenu.selectedIndex - 1;
|
||||
while (prev >= 0) {
|
||||
const child = submenu.items[prev];
|
||||
if (
|
||||
child?.name !== "separator" &&
|
||||
!("disabled" in child && child.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
prev--;
|
||||
}
|
||||
if (prev >= 0) {
|
||||
setSubmenu((s) => (s ? { ...s, selectedIndex: prev } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSubmenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const child = submenu.items[submenu.selectedIndex];
|
||||
if (child) {
|
||||
handleClickItem(child);
|
||||
setSubmenu(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Normal (no submenu) ---
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
const item = filtered[selectedIndex];
|
||||
|
||||
if (item) {
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (children?.length) {
|
||||
openSubmenu(selectedIndex);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
handleClickItem(item);
|
||||
} else {
|
||||
props.onClose(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
const item = filtered[selectedIndex];
|
||||
if (item) {
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (children?.length) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openSubmenu(selectedIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "ArrowUp" ||
|
||||
(event.key === "Tab" && event.shiftKey) ||
|
||||
@@ -717,16 +636,18 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("keydown", handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("keydown", handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [close, filtered, handleClickItem, insertItem, openSubmenu, props, selectedIndex, submenu]);
|
||||
}, [close, filtered, handleClickItem, props, selectedIndex]);
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
@@ -754,23 +675,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
</VisuallyHidden.Root>
|
||||
);
|
||||
|
||||
// Close submenu when parent selection moves away from the trigger
|
||||
React.useEffect(() => {
|
||||
if (submenu && submenu.index !== selectedIndex) {
|
||||
setSubmenu(null);
|
||||
}
|
||||
}, [selectedIndex, submenu]);
|
||||
|
||||
// Cleanup hover timer on unmount
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderItems = () => {
|
||||
let prevHeading: string | undefined;
|
||||
|
||||
@@ -789,10 +693,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasChildren = !!(
|
||||
"children" in item && resolveChildren(item.children)?.length
|
||||
);
|
||||
|
||||
const handlePointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
!("disabled" in item && item.disabled) &&
|
||||
@@ -808,22 +708,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
|
||||
// Hover to open submenu with delay
|
||||
if (hasChildren) {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
openSubmenu(index);
|
||||
}, 150);
|
||||
} else {
|
||||
// Close submenu when hovering a regular item
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
setSubmenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
@@ -838,37 +722,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const handleOnClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (hasChildren) {
|
||||
openSubmenu(index);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
handleClickItem(item);
|
||||
};
|
||||
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const itemRef = (node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
itemRefs.current.set(index, node);
|
||||
} else {
|
||||
itemRefs.current.delete(index);
|
||||
}
|
||||
};
|
||||
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== prevHeading && (
|
||||
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
|
||||
)}
|
||||
<ListItem
|
||||
ref={itemRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
disclosure: hasChildren,
|
||||
onClick: handleOnClick,
|
||||
})}
|
||||
</ListItem>
|
||||
@@ -922,152 +792,37 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={isActive} onOpenChange={handleOpenChange} modal={false}>
|
||||
<PopoverAnchor virtualRef={caretRef} />
|
||||
<BouncyPopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
width={280}
|
||||
shrink
|
||||
style={{
|
||||
padding: 0,
|
||||
maxHeight:
|
||||
"min(324px, var(--radix-popover-content-available-height))",
|
||||
}}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
if (
|
||||
submenuContentRef.current?.contains(
|
||||
e.target as Node
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
"placeholder" in insertItem && !!insertItem.placeholder
|
||||
? insertItem.placeholder
|
||||
: insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
{fileInput}
|
||||
</BouncyPopoverContent>
|
||||
</Popover>
|
||||
{submenu && itemRefs.current.get(submenu.index) && (
|
||||
<Popover open modal={false}>
|
||||
<PopoverAnchor
|
||||
virtualRef={{
|
||||
current: {
|
||||
getBoundingClientRect: () =>
|
||||
itemRefs.current
|
||||
.get(submenu.index)!
|
||||
.getBoundingClientRect(),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<SubmenuPopoverContent
|
||||
ref={submenuContentRef}
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
width={220}
|
||||
shrink
|
||||
style={{ padding: 0 }}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onPointerLeave={() => setSubmenu(null)}
|
||||
>
|
||||
<MouseSafeArea parentRef={submenuContentRef} />
|
||||
<List>
|
||||
{submenu.items.map((child, childIndex) => {
|
||||
if (child.name === "separator") {
|
||||
return (
|
||||
<ListItem key={childIndex}>
|
||||
<hr />
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
if (!child.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChildPointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
submenu.selectedIndex !== childIndex &&
|
||||
(pointerRef.current.clientX !== ev.clientX ||
|
||||
pointerRef.current.clientY !== ev.clientY)
|
||||
) {
|
||||
setSubmenu((s) =>
|
||||
s ? { ...s, selectedIndex: childIndex } : s
|
||||
);
|
||||
<Portal>
|
||||
<Wrapper active={isActive} ref={menuRef} hiddenScrollbars {...position}>
|
||||
{(isActive || hasActivated.current) && (
|
||||
<>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
"placeholder" in insertItem && !!insertItem.placeholder
|
||||
? insertItem.placeholder
|
||||
: insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
}
|
||||
pointerRef.current = {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const handleChildClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handleClickItem(child);
|
||||
setSubmenu(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`sub-${childIndex}-${child.name}`}
|
||||
onPointerMove={handleChildPointerMove}
|
||||
>
|
||||
{props.renderMenuItem(child as any, childIndex, {
|
||||
selected: childIndex === submenu.selectedIndex,
|
||||
onClick: handleChildClick,
|
||||
})}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</SubmenuPopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
{fileInput}
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const bouncyFadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`;
|
||||
|
||||
const BouncyPopoverContent = styled(PopoverContent)`
|
||||
&[data-state="open"] {
|
||||
animation: ${bouncyFadeIn} 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
`;
|
||||
|
||||
const SubmenuPopoverContent = styled(PopoverContent)`
|
||||
max-height: min(324px, var(--radix-popover-content-available-height));
|
||||
`;
|
||||
|
||||
const LinkInputWrapper = styled.div`
|
||||
margin: 8px;
|
||||
`;
|
||||
@@ -1084,13 +839,6 @@ const List = styled.ol`
|
||||
height: 100%;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid ${s("divider")};
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
@@ -1112,4 +860,61 @@ const MobileScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Scrollable)<{
|
||||
active: boolean;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
isAbove: boolean;
|
||||
}>`
|
||||
color: ${s("textSecondary")};
|
||||
font-family: ${s("fontFamily")};
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
${(props) => props.top !== undefined && `top: ${props.top}px`};
|
||||
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
|
||||
left: ${(props) => props.left}px;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
|
||||
rgba(0, 0, 0, 0.08) 0px 4px 8px,
|
||||
rgba(0, 0, 0, 0.08) 0px 2px 4px;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transition-delay: 150ms;
|
||||
line-height: 0;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: 280px;
|
||||
height: auto;
|
||||
max-height: 324px;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid ${s("divider")};
|
||||
}
|
||||
|
||||
${({ active, isAbove }) =>
|
||||
active &&
|
||||
`
|
||||
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default SuggestionsMenu;
|
||||
|
||||
@@ -5,7 +5,6 @@ import styled from "styled-components";
|
||||
import { usePortalContext } from "~/components/Portal";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuDisclosure,
|
||||
MenuIconWrapper,
|
||||
MenuLabel,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
@@ -27,8 +26,6 @@ export type Props = {
|
||||
subtitle?: React.ReactNode;
|
||||
/** A string representing the keyboard shortcut for the item */
|
||||
shortcut?: string;
|
||||
/** Whether to show a disclosure arrow indicating a submenu */
|
||||
disclosure?: boolean;
|
||||
};
|
||||
|
||||
function SuggestionsMenuItem({
|
||||
@@ -40,7 +37,6 @@ function SuggestionsMenuItem({
|
||||
subtitle,
|
||||
shortcut,
|
||||
icon,
|
||||
disclosure,
|
||||
}: Props) {
|
||||
const portal = usePortalContext();
|
||||
const ref = React.useCallback(
|
||||
@@ -79,7 +75,6 @@ function SuggestionsMenuItem({
|
||||
)}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuLabel>
|
||||
{disclosure && <MenuDisclosure />}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,10 +55,12 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
Decoration.widget(
|
||||
parent.pos,
|
||||
() => {
|
||||
button.onclick = action(() => {
|
||||
this.state.query = "";
|
||||
this.state.open = true;
|
||||
});
|
||||
button.addEventListener(
|
||||
"click",
|
||||
action(() => {
|
||||
this.state.open = true;
|
||||
})
|
||||
);
|
||||
return button;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -102,10 +102,6 @@ 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) {
|
||||
@@ -224,10 +220,6 @@ 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;
|
||||
@@ -280,7 +272,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
|
||||
const nextIndex = index + 1;
|
||||
|
||||
if (nextIndex >= this.results.length) {
|
||||
if (!this.results[nextIndex]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,9 @@ export default class SelectionToolbarExtension extends Extension {
|
||||
}
|
||||
|
||||
if (
|
||||
isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)
|
||||
(isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)) &&
|
||||
selection.from > 0
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
+18
-5
@@ -692,14 +692,19 @@ export class Editor extends React.PureComponent<
|
||||
public removeComment = (commentId: string) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markRemoved = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (markRemoved) {
|
||||
return false;
|
||||
}
|
||||
const mark = node.marks.find(
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
tr.removeMark(pos, pos + node.nodeSize, mark);
|
||||
markRemoved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -713,7 +718,10 @@ export class Editor extends React.PureComponent<
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, attrs);
|
||||
markRemoved = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -731,8 +739,13 @@ export class Editor extends React.PureComponent<
|
||||
) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markUpdated = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (markUpdated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
@@ -745,6 +758,7 @@ export class Editor extends React.PureComponent<
|
||||
...attrs,
|
||||
});
|
||||
tr.removeMark(from, to, mark).addMark(from, to, newMark);
|
||||
markUpdated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -760,7 +774,10 @@ export class Editor extends React.PureComponent<
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, newAttrs);
|
||||
markUpdated = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -894,11 +911,7 @@ const EditorContainer = styled(Styles)<{
|
||||
css`
|
||||
span#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
text-decoration: underline 2px ${props.theme.commentMarkBackground};
|
||||
|
||||
* {
|
||||
background: transparent !important;
|
||||
}
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
}
|
||||
a#comment-${props.focusedCommentId}
|
||||
~ span.component-image
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CopyIcon, EditIcon, ExpandedIcon, TextWrapIcon } from "outline-icons";
|
||||
import { CopyIcon, EditIcon, ExpandedIcon } from "outline-icons";
|
||||
import type { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import {
|
||||
pluginKey as mermaidPluginKey,
|
||||
@@ -20,10 +19,7 @@ export default function codeMenuItems(
|
||||
readOnly: boolean | undefined,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const node =
|
||||
state.selection instanceof NodeSelection
|
||||
? state.selection.node
|
||||
: state.selection.$from.node();
|
||||
const node = state.selection.$from.node();
|
||||
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
@@ -48,9 +44,6 @@ export default function codeMenuItems(
|
||||
]
|
||||
: remainingLangMenuItems;
|
||||
|
||||
const isEditingMermaid = !!(mermaidPluginKey.getState(state) as MermaidState)
|
||||
?.editingId;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "copyToClipboard",
|
||||
@@ -67,17 +60,10 @@ export default function codeMenuItems(
|
||||
name: "edit_mermaid",
|
||||
icon: <EditIcon />,
|
||||
tooltip: dictionary.editDiagram,
|
||||
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleCodeBlockWrap",
|
||||
icon: <TextWrapIcon />,
|
||||
tooltip: dictionary.wrapText,
|
||||
active: () => node.attrs.wrap,
|
||||
visible: !readOnly && (!isMermaid(node) || isEditingMermaid),
|
||||
visible:
|
||||
!(mermaidPluginKey.getState(state) as MermaidState)?.editingId &&
|
||||
isMermaid(node) &&
|
||||
!readOnly,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -7,25 +7,14 @@ 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?: ActionContextProviderValue;
|
||||
value?: Partial<ActionContextType>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,15 +23,15 @@ type ActionContextProviderProps = {
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Override active models for a collection menu
|
||||
* <ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
* <CollectionMenu />
|
||||
* // Override context for a command bar
|
||||
* <ActionContextProvider value={{ isCommandBar: true }}>
|
||||
* <CommandBar />
|
||||
* </ActionContextProvider>
|
||||
*
|
||||
* // Nested overrides
|
||||
* <ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
* <ActionContextProvider value={{ activeCollectionId: "collection-1" }}>
|
||||
* <CollectionView />
|
||||
* <ActionContextProvider value={{ activeModels: [document] }}>
|
||||
* <ActionContextProvider value={{ activeDocumentId: "doc-1" }}>
|
||||
* <DocumentView />
|
||||
* </ActionContextProvider>
|
||||
* </ActionContextProvider>
|
||||
@@ -56,7 +45,6 @@ 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 ?? {
|
||||
@@ -68,6 +56,7 @@ 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),
|
||||
@@ -94,18 +83,33 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
t,
|
||||
};
|
||||
|
||||
// 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;
|
||||
// Merge the parent context with the provided overrides
|
||||
const activeCollectionId =
|
||||
value.activeCollectionId ?? baseContext.activeCollectionId;
|
||||
const activeDocumentId =
|
||||
value.activeDocumentId ?? baseContext.activeDocumentId;
|
||||
|
||||
const getActiveModels = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T[] => {
|
||||
// @ts-expect-error modelName
|
||||
if (activeCollectionId && modelClass.modelName === "Collection") {
|
||||
const model = stores.collections.get(activeCollectionId);
|
||||
if (model) {
|
||||
return [model as unknown as T];
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error modelName
|
||||
if (activeDocumentId && modelClass.modelName === "Document") {
|
||||
const model = stores.documents.get(activeDocumentId);
|
||||
if (model) {
|
||||
return [model as unknown as T];
|
||||
}
|
||||
}
|
||||
|
||||
return baseContext.getActiveModels(modelClass);
|
||||
};
|
||||
|
||||
const getActiveModel = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
@@ -118,34 +122,12 @@ 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,
|
||||
...overrides,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
...value,
|
||||
getActiveModels,
|
||||
getActiveModel,
|
||||
getActivePolicies,
|
||||
isModelActive,
|
||||
activeModels: allActiveModels,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -123,7 +123,6 @@ export default function useDictionary() {
|
||||
uploadImage: t("Upload an image"),
|
||||
formattingControls: t("Formatting controls"),
|
||||
distributeColumns: t("Distribute columns"),
|
||||
wrapText: t("Wrap text"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
unstarDocument,
|
||||
editDocument,
|
||||
shareDocument,
|
||||
createNewDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory,
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
@@ -34,7 +36,7 @@ import {
|
||||
} from "~/actions/definitions/documents";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import useMobile from "./useMobile";
|
||||
import type Template from "~/models/Template";
|
||||
import type Document from "~/models/Document";
|
||||
import usePolicy from "./usePolicy";
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import { useTemplateMenuActions } from "./useTemplateMenuActions";
|
||||
@@ -48,7 +50,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: Template) => void;
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
};
|
||||
|
||||
export function useDocumentMenuAction({
|
||||
@@ -92,16 +94,18 @@ 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,
|
||||
|
||||
+2
-10
@@ -1,10 +1,9 @@
|
||||
import find from "lodash/find";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { IntegrationType, TeamPreference } from "@shared/types";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import type Integration from "~/models/Integration";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import useStores from "./useStores";
|
||||
|
||||
/**
|
||||
@@ -15,7 +14,6 @@ import useStores from "./useStores";
|
||||
*/
|
||||
export default function useEmbeds(loadIfMissing = false) {
|
||||
const { integrations } = useStores();
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchEmbedIntegrations() {
|
||||
@@ -33,9 +31,6 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
}
|
||||
}, [integrations, loadIfMissing]);
|
||||
|
||||
const disabledEmbeds =
|
||||
(team?.getPreference(TeamPreference.DisabledEmbeds) as string[]) || [];
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
embeds.map((e) => {
|
||||
@@ -47,11 +42,8 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
e.settings = integration.settings;
|
||||
}
|
||||
|
||||
e.disabled = disabledEmbeds.includes(e.id);
|
||||
|
||||
return e;
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[integrations.orderedData, team?.preferences]
|
||||
[integrations.orderedData]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,6 @@ 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,
|
||||
@@ -57,8 +55,6 @@ export default function useImportDocument(
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -63,14 +63,9 @@ window.addEventListener("keydown", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track whether defaultPrevented was already set by an external handler (e.g.
|
||||
// Radix UI's DismissableLayer) so we only break on preventDefault calls made
|
||||
// by our own callbacks.
|
||||
const wasDefaultPrevented = event.defaultPrevented;
|
||||
|
||||
// reverse so that the last registered callbacks get executed first
|
||||
for (const registered of [...callbacks].reverse()) {
|
||||
if (!wasDefaultPrevented && event.defaultPrevented) {
|
||||
for (const registered of callbacks.reverse()) {
|
||||
if (event.defaultPrevented === true) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { getCookie, removeCookie, setCookie } from "tiny-cookie";
|
||||
import usePersistedState, { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import Logger from "~/utils/Logger";
|
||||
import history from "~/utils/history";
|
||||
import { isAllowedLoginRedirect } from "~/utils/urls";
|
||||
@@ -30,23 +30,6 @@ 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.
|
||||
*
|
||||
|
||||
@@ -92,14 +92,6 @@ 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 };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Icon } from "outline-icons";
|
||||
import {
|
||||
EmailIcon,
|
||||
ProfileIcon,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
GlobeIcon,
|
||||
ShieldIcon,
|
||||
TeamIcon,
|
||||
SparklesIcon,
|
||||
BeakerIcon,
|
||||
SettingsIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
@@ -16,8 +17,9 @@ import {
|
||||
PlusIcon,
|
||||
InternetIcon,
|
||||
SmileyIcon,
|
||||
BrowserIcon,
|
||||
BuildingBlocksIcon,
|
||||
} from "outline-icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
@@ -32,7 +34,7 @@ import useStores from "./useStores";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
|
||||
const APIAndAccess = lazy(() => import("~/scenes/Settings/APIAndAccess"));
|
||||
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
|
||||
const Authentication = lazy(() => import("~/scenes/Settings/Authentication"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
@@ -48,16 +50,11 @@ const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
|
||||
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
|
||||
const CustomEmojis = lazy(() => import("~/scenes/Settings/CustomEmojis"));
|
||||
const Embeds = lazy(() => import("~/scenes/Settings/Embeds"));
|
||||
|
||||
export type ConfigItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: React.FC<{
|
||||
size?: number;
|
||||
fill?: string;
|
||||
monochrome?: boolean;
|
||||
}>;
|
||||
icon: React.FC<ComponentProps<typeof Icon>>;
|
||||
component: React.ComponentType;
|
||||
description?: string;
|
||||
preload?: () => void;
|
||||
@@ -108,13 +105,13 @@ const useSettingsConfig = () => {
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API & Access"),
|
||||
path: settingsPath("api-and-access"),
|
||||
component: APIAndAccess.Component,
|
||||
preload: APIAndAccess.preload,
|
||||
name: t("API & Apps"),
|
||||
path: settingsPath("api-and-apps"),
|
||||
component: APIAndApps.Component,
|
||||
preload: APIAndApps.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: PadlockIcon,
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
// Workspace
|
||||
{
|
||||
@@ -145,13 +142,13 @@ const useSettingsConfig = () => {
|
||||
icon: ShieldIcon,
|
||||
},
|
||||
{
|
||||
name: t("AI"),
|
||||
name: t("Features"),
|
||||
path: settingsPath("features"),
|
||||
component: Features.Component,
|
||||
preload: Features.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: SparklesIcon,
|
||||
icon: BeakerIcon,
|
||||
},
|
||||
{
|
||||
name: t("Members"),
|
||||
@@ -176,7 +173,7 @@ const useSettingsConfig = () => {
|
||||
path: settingsPath("templates"),
|
||||
component: Templates.Component,
|
||||
preload: Templates.preload,
|
||||
enabled: can.readTemplate,
|
||||
enabled: can.createTemplate,
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
@@ -235,18 +232,6 @@ const useSettingsConfig = () => {
|
||||
icon: ExportIcon,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
name: t("Embeds"),
|
||||
path: integrationSettingsPath("embeds"),
|
||||
component: Embeds.Component,
|
||||
preload: Embeds.preload,
|
||||
description: t(
|
||||
"Configure which embed providers are available in the editor."
|
||||
),
|
||||
enabled: can.update,
|
||||
group: t("Integrations"),
|
||||
icon: BrowserIcon,
|
||||
},
|
||||
{
|
||||
name: `${t("Install")}…`,
|
||||
path: settingsPath("integrations"),
|
||||
|
||||
@@ -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 Template from "~/models/Template";
|
||||
import type Document from "~/models/Document";
|
||||
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: Template) => void;
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,12 +34,12 @@ export function useTemplateMenuActions({
|
||||
onSelectTemplate,
|
||||
}: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { documents, templates: templatesStore } = useStores();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const document = documents.get(documentId);
|
||||
|
||||
const templateToAction = useCallback(
|
||||
(template: Template): Action =>
|
||||
(template: Document): Action =>
|
||||
createAction({
|
||||
name: TextHelper.replaceTemplateVariables(
|
||||
template.titleWithDefault,
|
||||
@@ -66,8 +66,8 @@ export function useTemplateMenuActions({
|
||||
return [];
|
||||
}
|
||||
|
||||
const templates = templatesStore.orderedData.filter(
|
||||
(template) => template.isActive
|
||||
const templates = documents.templates.filter(
|
||||
(template) => template.publishedAt
|
||||
);
|
||||
|
||||
const collectionTemplatesActions = templates
|
||||
@@ -82,13 +82,6 @@ export function useTemplateMenuActions({
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
.map(templateToAction);
|
||||
|
||||
if (
|
||||
!collectionTemplatesActions.length &&
|
||||
!workspaceTemplatesActions.length
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...collectionTemplatesActions,
|
||||
ActionSeparator,
|
||||
@@ -97,5 +90,5 @@ export function useTemplateMenuActions({
|
||||
actions: workspaceTemplatesActions,
|
||||
}),
|
||||
];
|
||||
}, [document?.collectionId, templateToAction, t]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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,
|
||||
createDocumentFromTemplate,
|
||||
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,
|
||||
createDocumentFromTemplate,
|
||||
copyTemplate,
|
||||
ActionSeparator,
|
||||
deleteTemplate,
|
||||
],
|
||||
[can.update, can.duplicate, onEdit, t, template, templates]
|
||||
);
|
||||
|
||||
return useMenuAction(actions);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Returns the width of the window's vertical scrollbar in pixels, or null
|
||||
* if not yet measured. Continuously re-measures as the scrollbar appears or
|
||||
* disappears.
|
||||
*
|
||||
* @returns the scrollbar width, or null before measurement.
|
||||
*/
|
||||
export default function useWindowScrollbarWidth(): number | null {
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
const measure = () => {
|
||||
const scrollbarWidth = htmlElement.scrollWidth - htmlElement.clientWidth;
|
||||
setWidth(scrollbarWidth);
|
||||
};
|
||||
|
||||
// Defer initial measurement to after browser has painted
|
||||
const timeout = setTimeout(measure);
|
||||
|
||||
// Re-measure when html element resizes (scrollbar appears/disappears)
|
||||
const resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(htmlElement);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return width;
|
||||
}
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
// oxlint-disable-next-line import/no-unresolved
|
||||
import "vite/modulepreload-polyfill";
|
||||
import { LazyMotion, domMax } from "framer-motion";
|
||||
import { LazyMotion } from "framer-motion";
|
||||
import { KBarProvider } from "kbar";
|
||||
import { Provider } from "mobx-react";
|
||||
import { configure as configureMobx } from "mobx";
|
||||
@@ -41,10 +41,13 @@ 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,
|
||||
});
|
||||
|
||||
// Make sure to return the specific export containing the feature bundle.
|
||||
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
|
||||
|
||||
const commandBarOptions = {
|
||||
animations: {
|
||||
enterMs: 250,
|
||||
@@ -64,7 +67,7 @@ if (element) {
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={domMax}>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
|
||||
@@ -53,7 +53,7 @@ function CollectionMenu({
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align={align}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
@@ -34,7 +33,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: Template) => void;
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Invoked when menu is opened */
|
||||
@@ -199,10 +198,11 @@ function DocumentMenu({
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeModels: [
|
||||
document,
|
||||
...(!isShared && document.collection ? [document.collection] : []),
|
||||
],
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu
|
||||
|
||||
@@ -17,7 +17,6 @@ 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();
|
||||
@@ -33,7 +32,7 @@ function NewTemplateMenu() {
|
||||
name: collection.name,
|
||||
section: DocumentSection,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
visible: !!canCollection.createTemplate,
|
||||
visible: !!canCollection.createDocument,
|
||||
to: newTemplatePath(collection.id),
|
||||
});
|
||||
}),
|
||||
@@ -45,7 +44,7 @@ function NewTemplateMenu() {
|
||||
createInternalLinkAction({
|
||||
name: t("Save in workspace"),
|
||||
section: DocumentSection,
|
||||
icon: <TeamLogo model={team} size={AvatarSize.Small} />,
|
||||
icon: <TeamLogo model={team} />,
|
||||
visible: can.createTemplate,
|
||||
to: newTemplatePath(),
|
||||
}),
|
||||
|
||||
@@ -33,7 +33,7 @@ function RevisionMenu({ document, revisionId }: Props) {
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeModels: [document] }}>
|
||||
<ActionContextProvider value={{ activeDocumentId: document.id }}>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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,7 +2,6 @@ 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";
|
||||
@@ -14,7 +13,7 @@ type Props = {
|
||||
/** Whether to render the button as a compact icon */
|
||||
isCompact?: boolean;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate: (template: Template) => void;
|
||||
onSelectTemplate: (template: Document) => void;
|
||||
};
|
||||
|
||||
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NavigationNodeType,
|
||||
type ProsemirrorData,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import type CollectionsStore from "~/stores/CollectionsStore";
|
||||
import type Document from "~/models/Document";
|
||||
@@ -124,7 +125,13 @@ export default class Collection extends ParanoidModel {
|
||||
* @returns boolean
|
||||
*/
|
||||
get isPrivate(): boolean {
|
||||
return this.permission === null;
|
||||
return !this.permission;
|
||||
}
|
||||
|
||||
/** Returns whether the collection description is not empty. */
|
||||
@computed
|
||||
get hasDescription(): boolean {
|
||||
return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false;
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
+30
-3
@@ -21,6 +21,7 @@ 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";
|
||||
@@ -149,6 +150,12 @@ 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.
|
||||
*/
|
||||
@@ -273,7 +280,8 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
|
||||
@computed
|
||||
get path(): string {
|
||||
const prefix = "/doc";
|
||||
const prefix =
|
||||
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
|
||||
|
||||
if (!this.title) {
|
||||
return `${prefix}/untitled-${this.urlId}`;
|
||||
@@ -285,7 +293,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
|
||||
@computed
|
||||
get noun(): string {
|
||||
return t("document");
|
||||
return this.template ? t("template") : t("document");
|
||||
}
|
||||
|
||||
@computed
|
||||
@@ -384,6 +392,11 @@ 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;
|
||||
@@ -449,6 +462,11 @@ 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");
|
||||
}
|
||||
@@ -562,6 +580,15 @@ 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>,
|
||||
@@ -628,7 +655,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
|
||||
@computed
|
||||
get isActive(): boolean {
|
||||
return !this.isDeleted && !this.isArchived;
|
||||
return !this.isDeleted && !this.isTemplate && !this.isArchived;
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -7,6 +7,7 @@ import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
import type RevisionsStore from "~/stores/RevisionsStore";
|
||||
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
class Revision extends ParanoidModel {
|
||||
@@ -96,6 +97,11 @@ class Revision extends ParanoidModel {
|
||||
: null;
|
||||
}
|
||||
|
||||
@computed
|
||||
get changeset() {
|
||||
return ChangesetHelper.getChangeset(this.data, this.before?.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a download of the revision in the specified format.
|
||||
*
|
||||
|
||||
+3
-3
@@ -114,10 +114,10 @@ class Team extends Model {
|
||||
/**
|
||||
* Set the value for a specific preference key.
|
||||
*
|
||||
* @param key The TeamPreference key to set.
|
||||
* @param value The value to set.
|
||||
* @param key The TeamPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference<T extends TeamPreference>(key: T, value: TeamPreferences[T]) {
|
||||
setPreference(key: TeamPreference, value: boolean) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user