Compare commits

..

1 Commits

Author SHA1 Message Date
tommoor 7430647de4 chore: Compressed inefficient images automatically 2026-02-06 11:32:49 +00:00
633 changed files with 7357 additions and 29592 deletions
+1 -8
View File
@@ -1,3 +1,4 @@
__mocks__
.git
.vscode
.github
@@ -7,19 +8,11 @@
.eslint*
.oxlintrc*
.log
*.md
Makefile
Procfile
app.json
crowdin.yml
lint-staged.config.mjs
build
docker-compose.yml
node_modules
.yarn
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.jsx
**/__tests__
**/__mocks__
+3 -12
View File
@@ -119,18 +119,14 @@ 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 ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF these is required for a
# working installation or you'll have no sign-in options.
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
@@ -216,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=
-3
View File
@@ -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
+1 -1
View File
@@ -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
-1
View File
@@ -20,5 +20,4 @@ data/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.yarn/releases
!.yarn/sdks
-3
View File
@@ -1,6 +1,3 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
npmPreapprovedPackages:
- outline-icons
+1 -1
View File
@@ -70,7 +70,7 @@ yarn install
### Exports
- Exported members must appear at the top of the file.
- Always use named exports for new components & classes.
- Prefer named exports for components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.6.1
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-03-18
Change Date: 2030-01-27
Change License: Apache License, Version 2.0
+2 -2
View File
@@ -33,9 +33,9 @@ There is a short guide for [setting up a development environment](https://docs.g
## Contributing
Outline is built and maintained by a small team your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
+11 -8
View File
@@ -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 },
};
},
});
+139 -204
View File
@@ -32,8 +32,6 @@ import {
CaseSensitiveIcon,
RestoreIcon,
EditIcon,
EmbedIcon,
OpenIcon,
} from "outline-icons";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
@@ -44,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";
@@ -71,27 +70,17 @@ import {
homePath,
newDocumentPath,
newNestedDocumentPath,
newSiblingDocumentPath,
searchPath,
documentPath,
urlify,
desktopify,
trashPath,
documentEditPath,
} 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 { isMac, isWindows } from "@shared/utils/browser";
import isCloudHosted from "~/utils/isCloudHosted";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -143,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
@@ -206,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 &&
@@ -258,100 +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, activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return (
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -428,7 +346,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId) {
if (document?.collectionId || document?.template) {
await document.save(undefined, {
publish: true,
});
@@ -577,10 +495,7 @@ export const shareDocument = createAction({
section: ActiveDocumentSection,
icon: <PadlockIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
return false;
}
const can = stores.policies.abilities(activeDocumentId);
const can = stores.policies.abilities(activeDocumentId!);
return can.manageUsers || can.share;
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -955,50 +870,7 @@ export const printDocument = createAction({
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
setTimeout(window.print, 0);
},
});
export const openDocumentInDesktop = createAction({
name: ({ t }) => t("Open in desktop app"),
analyticsName: "Open in desktop",
section: ActiveDocumentSection,
icon: <OpenIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
);
},
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
window.location.href = desktopify(documentPath(document));
}
},
});
export const presentDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["Meta+Alt+p"],
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
stores.ui.setPresentingDocument(document);
queueMicrotask(window.print);
},
});
@@ -1019,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";
@@ -1028,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(
@@ -1042,8 +913,6 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -1061,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 }) => {
@@ -1113,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 />,
@@ -1152,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;
@@ -1160,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",
@@ -1218,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
@@ -1255,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;
@@ -1397,7 +1330,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.set({ rightSidebar: "comments" });
stores.ui.toggleComments();
},
});
@@ -1432,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 ?? "");
@@ -1440,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
@@ -1519,7 +1456,6 @@ export const rootDocumentActions = [
archiveDocument,
createDocument,
createDraftDocument,
createNewDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
@@ -1541,17 +1477,16 @@ export const rootDocumentActions = [
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
presentDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
shareDocument,
];
-231
View File
@@ -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];
-9
View File
@@ -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;
+65 -22
View File
@@ -1,10 +1,17 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route } 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,13 +23,20 @@ import {
searchPath,
newDocumentPath,
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
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 +47,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();
@@ -57,17 +73,15 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
history.push(newDocumentPath(activeCollectionId));
};
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
history.replace(postLoginPath);
}
}, [spendPostLoginPath]);
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
@@ -77,20 +91,49 @@ 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 />
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
);
};
+1 -2
View File
@@ -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 -2
View File
@@ -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);
-7
View File
@@ -3,8 +3,6 @@ import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import type { HapticInput } from "web-haptics";
import { useWebHaptics } from "web-haptics/react";
import { s } from "@shared/styles";
import type { Props as ActionButtonProps } from "~/components/ActionButton";
import ActionButton from "~/components/ActionButton";
@@ -154,8 +152,6 @@ export type Props<T> = ActionButtonProps & {
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
haptic?: HapticInput;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
@@ -180,13 +176,11 @@ const Button = <T extends React.ElementType = "button">(
hideIcon,
fullwidth,
danger,
haptic,
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const hasIcon = ic !== undefined;
const { trigger } = useWebHaptics();
return (
<RealButton
@@ -197,7 +191,6 @@ const Button = <T extends React.ElementType = "button">(
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
{...rest}
>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
+5 -2
View File
@@ -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> = ({
+1 -1
View File
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
key={collaborator.id}
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
-112
View File
@@ -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;
}
}
`;
+35 -93
View File
@@ -6,16 +6,14 @@ import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission, TeamPreference } from "@shared/types";
import type { Option } from "~/components/InputSelect";
import type { CollectionPermission } from "@shared/types";
import { TeamPreference } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
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 { InputSelect } from "~/components/InputSelect";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
@@ -35,7 +33,6 @@ export interface FormData {
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
templateManagement: CollectionPermission;
}
const useIconColor = (collection?: Collection) => {
@@ -70,22 +67,6 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const templateManagementOptions = useMemo<Option[]>(
() => [
{
type: "item",
label: t("Managers"),
value: CollectionPermission.Admin,
},
{
type: "item",
label: t("Members"),
value: CollectionPermission.ReadWrite,
},
],
[t]
);
const iconColor = useIconColor(collection);
const fallbackIcon = (
<Icon
@@ -111,8 +92,6 @@ export const CollectionForm = observer(function CollectionForm_({
sharing: collection?.sharing ?? true,
permission: collection?.permission,
commenting: collection?.commenting ?? true,
templateManagement:
collection?.templateManagement ?? CollectionPermission.Admin,
color: iconColor,
},
});
@@ -155,71 +134,6 @@ export const CollectionForm = observer(function CollectionForm_({
const initial = values.name.charAt(0).toUpperCase();
const options = (
<>
<Controller
control={control}
name="templateManagement"
render={({ field }) => (
<>
<InputSelect
value={field.value}
onChange={(value: string) => {
field.onChange(value as CollectionPermission);
}}
options={templateManagementOptions}
label={t("Manage templates")}
/>
<Text
type="secondary"
size="small"
as="p"
style={{ paddingTop: 4 }}
>
{t(
"Choose who can create and edit templates in this collection."
)}
</Text>
</>
)}
/>
{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}
/>
)}
/>
)}
</>
);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
@@ -230,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,
@@ -275,10 +189,38 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{collection ? (
options
) : (
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
{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}
/>
)}
/>
)}
<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 -8
View File
@@ -128,14 +128,7 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
if (document.activeElement === contentRef.current) {
// Don't reset content while the user is actively editing. Update
// lastValue so that the next input or blur event will push the
// current DOM text back to the model via onChange.
lastValue.current = value;
} else {
setInnerValue(value);
}
setInnerValue(value);
}
}, [value, contentRef]);
+59 -104
View File
@@ -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;
+9 -2
View File
@@ -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,
@@ -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>
@@ -12,14 +12,15 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
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";
@@ -37,39 +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;
};
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
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();
@@ -88,6 +59,8 @@ function DocumentExplorer({
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
@@ -110,6 +83,9 @@ function DocumentExplorer({
);
const listRef = React.useRef<List<NavigationNode[]>>(null);
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const searchIndex = React.useMemo(
() =>
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
@@ -160,18 +136,24 @@ function DocumentExplorer({
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);
}, [defaultValue, selectedNode, nodes]);
const baseDepth = nodes.reduce(
(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) => {
listRef.current?.scrollToItem(node, "smart");
}, []);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
if (itemRefs[node] && itemRefs[node].current) {
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
behavior: "auto",
block: "center",
});
}
},
[itemRefs]
);
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(ev.target.value);
@@ -179,16 +161,16 @@ function DocumentExplorer({
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
const preserveScrollOffset = (itemCount: number) => {
const calculateInitialScrollOffset = (itemCount: number) => {
if (listRef.current) {
const { height, itemSize } = listRef.current.props;
const { scrollOffset } = listRef.current.state as {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
setTimeout(() => listRef.current?.scrollTo(offset), 0);
return itemsHeight < Number(height) ? 0 : scrollOffset;
}
return 0;
};
const collapse = (node: number) => {
@@ -199,7 +181,8 @@ function DocumentExplorer({
// remove children
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
preserveScrollOffset(newNodes.length);
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
const expand = (node: number) => {
@@ -208,7 +191,8 @@ function DocumentExplorer({
// add children
const newNodes = nodes.slice();
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
preserveScrollOffset(newNodes.length);
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
React.useEffect(() => {
@@ -232,8 +216,7 @@ function DocumentExplorer({
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 ||
(showDocuments !== false && nodes[node].type === "collection");
nodes[node].children.length > 0 || nodes[node].type === "collection";
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -395,16 +378,31 @@ function DocumentExplorer({
}
};
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={
showDocuments
? `${t("Search collections & documents")}`
: `${t("Search collections")}`
}
placeholder={`${t("Search collections & documents")}`}
autoFocus
/>
<ListContainer>
@@ -414,12 +412,14 @@ function DocumentExplorer({
<Flex role="listbox" column>
<List
ref={listRef}
key={nodes.length}
width={width}
height={height}
itemData={nodes}
itemCount={nodes.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results) => results[index].id}
>
{ListItem}
@@ -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("Couldnt move the template, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={move}
onSelect={selectPath}
showDocuments={false}
/>
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
);
}
export default observer(TemplateMove);
-3
View File
@@ -1,3 +0,0 @@
import DocumentExplorer from "./DocumentExplorer";
export default DocumentExplorer;
@@ -40,8 +40,10 @@ function DocumentExplorerNode(
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const DISCLOSURE = 24;
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
const OFFSET = 12;
const DISCLOSURE = 20;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
return (
<Node
@@ -52,7 +54,6 @@ function DocumentExplorerNode(
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
<Spacer width={width}>
{hasChildren && (
@@ -78,7 +79,7 @@ const Title = styled(Text)`
const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin: 2px 0;
margin-top: 2px;
`;
const Spacer = styled(Flex)<{ width: number }>`
@@ -1,9 +1,10 @@
import { observer } from "mobx-react";
import * as React from "react";
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";
@@ -31,15 +32,28 @@ function DocumentExplorerSearchResult({
}: Props) {
const { t } = useTranslation();
const ref = React.useCallback(
(node: HTMLSpanElement | null) => {
if (active && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
});
}
},
[active]
);
return (
<SearchResult
ref={ref}
selected={selected}
active={active}
onClick={onClick}
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
{icon}
<Flex>
+12 -8
View File
@@ -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 && (
+5 -4
View File
@@ -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) {
@@ -169,7 +170,7 @@ const DocumentMeta: React.FC<Props> = ({
};
return (
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
@@ -218,8 +219,8 @@ const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ $rtl?: boolean }>`
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
font-size: 13px;
white-space: nowrap;
+1 -1
View File
@@ -266,7 +266,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<>
{paragraphs ? (
<EditorContainer
$rtl={props.dir === "rtl"}
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
-1
View File
@@ -88,7 +88,6 @@ function Header(
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
haptic="light"
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
+6 -4
View File
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
white-space: nowrap;
`;
export const Description = styled(StyledText)<{ $margin?: string }>`
export const Description = styled(StyledText)`
${sharedVars}
margin-top: ${(props) => props.$margin ?? "0.5em"};
margin-top: 0.5em;
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
overflow: hidden;
@@ -64,6 +64,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
width: fit-content;
border-radius: 2em;
padding: 1px 8px 1px 20px;
margin-right: 0.5em;
margin-top: 0.5em;
position: relative;
flex-shrink: 0;
@@ -73,8 +75,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: ${(props) =>
props.color || props.theme.backgroundSecondary};
@@ -17,7 +17,6 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewProject from "./HoverPreviewProject";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 500;
@@ -193,18 +192,6 @@ const HoverPreviewDesktop = observer(
createdAt={data.createdAt}
state={data.state}
/>
) : data.type === UnfurlResourceType.Project ? (
<HoverPreviewProject
ref={cardRef}
url={data.url}
name={data.name}
color={data.color}
lead={data.lead}
labels={data.labels}
description={data.description}
state={data.state}
targetDate={data.targetDate}
/>
) : (
<HoverPreviewLink
ref={cardRef}
@@ -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,20 +58,9 @@ 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 gap={6} style={{ marginTop: 8 }}>
<Flex wrap>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
@@ -1,148 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Backticks } from "@shared/components/Backticks";
import Squircle from "@shared/components/Squircle";
import Editor from "~/components/Editor";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Card,
CardContent,
Label,
Description,
} from "./Components";
import { richExtensions } from "@shared/editor/nodes";
type Props = Pick<
UnfurlResponse[UnfurlResourceType.Project],
| "url"
| "name"
| "color"
| "lead"
| "labels"
| "state"
| "targetDate"
| "description"
>;
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
{ url, name, color, lead, labels, state, description, targetDate }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={4} column>
<Title>
<StyledSquircle color={color} size={16} />
<span>
<Backticks content={name} />
</span>
</Title>
{description && (
<Description as="div" $margin="0">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Text
type="tertiary"
size="small"
style={{ textTransform: "capitalize" }}
>
{state.name}
</Text>
{(lead || targetDate) && (
<>
<Divider />
{lead && (
<MetadataRow>
<MetadataLabel>{t("Lead")}</MetadataLabel>
<Flex align="center" gap={6}>
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
<Text size="small">{lead.name}</Text>
</Flex>
</MetadataRow>
)}
{targetDate && (
<MetadataRow>
<MetadataLabel>{t("Target date")}</MetadataLabel>
<Text size="small">
<Time dateTime={targetDate} addSuffix />
</Text>
</MetadataRow>
)}
</>
)}
{labels.length > 0 && (
<>
<Divider />
<MetadataRow>
<MetadataLabel>{t("Labels")}</MetadataLabel>
<Flex wrap gap={6}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
</Label>
))}
</Flex>
</MetadataRow>
</>
)}
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
const StyledSquircle = styled(Squircle)`
flex-shrink: 0;
margin-top: 4px;
`;
const Divider = styled.div`
height: 1px;
background: ${s("divider")};
margin: 4px 0;
`;
const MetadataRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
`;
const MetadataLabel = styled(Text).attrs({
type: "tertiary",
size: "small",
})`
flex-shrink: 0;
min-width: 80px;
`;
export default HoverPreviewProject;
@@ -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>
+3 -3
View File
@@ -95,7 +95,7 @@ const IconWrapper = styled.span`
export const Outline = styled(Flex)<{
margin?: string | number;
hasError?: boolean;
$focused?: boolean;
focused?: boolean;
}>`
flex: 1;
margin: ${(props) =>
@@ -106,7 +106,7 @@ export const Outline = styled(Flex)<{
border-color: ${(props) =>
props.hasError
? props.theme.danger
: props.$focused
: props.focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: 4px;
@@ -224,7 +224,7 @@ function Input(
) : (
wrappedLabel
))}
<Outline $focused={focused} margin={margin}>
<Outline focused={focused} margin={margin}>
{prefix}
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
+4 -5
View File
@@ -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>
);
+17 -22
View File
@@ -9,44 +9,39 @@ export interface LazyComponent<T extends React.ComponentType<any>> {
interface LazyLoadOptions {
retries?: number;
interval?: number;
/** If provided, picks this named export from the module instead of `default`. */
exportName?: string;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
* Supports both default and named exports.
*
* @param factory A function that returns a promise of a module.
* @param options Optional configuration for retry behavior and export name.
* @returns An object containing the lazy Component and a preload function.
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
*
* @example
* ```typescript
* // Default export
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* // Named export
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
* exportName: 'MyComponent',
* });
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<Record<string, T>>,
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval, exportName } = options;
const wrappedFactory = exportName
? () =>
factory().then((m) => ({
default: m[exportName],
}))
: (factory as () => Promise<{ default: T }>);
const { retries, interval } = options;
return {
Component: lazyWithRetry(wrappedFactory, retries, interval),
preload: wrappedFactory,
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
};
}
+6 -2
View File
@@ -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>
+6 -2
View File
@@ -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}
+3 -6
View File
@@ -16,7 +16,6 @@ import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import ErrorBoundary from "./ErrorBoundary";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import Tooltip from "./Tooltip";
type Props = {
children?: React.ReactNode;
@@ -94,11 +93,9 @@ const Modal: React.FC<Props> = ({
</DesktopContent>
<Header>
{title && <Text size="large">{title}</Text>}
<Tooltip content={t("Close")} shortcut="Esc">
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Tooltip>
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Header>
</Centered>
</Wrapper>
+1 -1
View File
@@ -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;
-47
View File
@@ -1,47 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { NotificationBadgeType, UserPreference } from "@shared/types";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import Desktop from "~/utils/Desktop";
/**
* Component that keeps the app icon notification badge in sync with unread
* notification count. Renders nothing visible — mount near the app root so it
* stays alive as long as the user is authenticated.
*/
function NotificationBadge() {
const { notifications } = useStores();
const user = useCurrentUser();
const badgeType = user.getPreference(UserPreference.NotificationBadge);
const unreadCount = notifications.approximateUnreadCount;
React.useEffect(() => {
// Desktop app badge
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
if (badgeType === NotificationBadgeType.Disabled || unreadCount === 0) {
void Desktop.bridge.setNotificationCount(0);
} else if (badgeType === NotificationBadgeType.Count) {
void Desktop.bridge.setNotificationCount(unreadCount);
} else {
void Desktop.bridge.setNotificationCount("・");
}
}
// PWA badge
if ("setAppBadge" in navigator) {
if (unreadCount > 0 && badgeType !== NotificationBadgeType.Disabled) {
void navigator.setAppBadge(
badgeType === NotificationBadgeType.Count ? unreadCount : undefined
);
} else {
void navigator.clearAppBadge();
}
}
}, [unreadCount, badgeType]);
return null;
}
export default observer(NotificationBadge);
@@ -103,7 +103,6 @@ const StyledLink = styled(Link)`
const StyledCommentEditor = styled(CommentEditor)`
font-size: 0.9em;
margin-top: 4px;
pointer-events: none;
${truncateMultiline(3)}
`;
+31 -55
View File
@@ -8,6 +8,7 @@ import Notification, { type NotificationFilter } from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import Desktop from "~/utils/Desktop";
import Empty from "../Empty";
import ErrorBoundary from "../ErrorBoundary";
import Flex from "../Flex";
@@ -20,55 +21,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,12 +50,36 @@ function Notifications(
[t]
);
const filteredNotifications = useStableOrderedNotifications(
notifications.active,
filter
);
const filteredNotifications = React.useMemo(() => {
if (filter === "all") {
return notifications.active;
}
const unreadCount = notifications.approximateUnreadCount;
const eventTypes = Notification.filterCategories[filter];
return notifications.active.filter((notification) =>
eventTypes.includes(notification.event)
);
}, [notifications.active, filter]);
// Update the notification count in the dock icon, if possible.
React.useEffect(() => {
// Account for old versions of the desktop app that don't have the
// setNotificationCount method on the bridge.
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
void Desktop.bridge.setNotificationCount(
notifications.approximateUnreadCount
);
}
// PWA badging
if ("setAppBadge" in navigator) {
if (notifications.approximateUnreadCount) {
void navigator.setAppBadge(notifications.approximateUnreadCount);
} else {
void navigator.clearAppBadge();
}
}
}, [notifications.approximateUnreadCount]);
return (
<ErrorBoundary>
@@ -129,7 +105,7 @@ function Notifications(
short
nude
/>
{unreadCount > 0 && (
{notifications.approximateUnreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Button
action={markNotificationsAsRead}
+1
View File
@@ -49,6 +49,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
/>
)}
-32
View File
@@ -1,32 +0,0 @@
import { observer } from "mobx-react";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const PresentationMode = lazyWithRetry(
() => import("~/scenes/Document/components/PresentationMode")
);
function Presentation() {
const { ui } = useStores();
if (!ui.presentationData) {
return null;
}
return (
<Suspense fallback={null}>
<PresentationMode
title={ui.presentationData.title}
icon={ui.presentationData.icon}
iconColor={ui.presentationData.color}
data={ui.presentationData.data}
onClose={() => {
ui.setPresentingDocument(null);
}}
/>
</Suspense>
);
}
export default observer(Presentation);
+1 -1
View File
@@ -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")}
-57
View File
@@ -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);
+1 -4
View File
@@ -1,5 +1,4 @@
import { useKBar } from "kbar";
import { observer } from "mobx-react";
import { useEffect, useRef } from "react";
import { Minute } from "@shared/utils/time";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
@@ -15,7 +14,7 @@ interface CacheEntry {
// Cache configuration
const cacheTTL = Minute.ms * 5;
function SearchActions() {
export default function SearchActions() {
const { searches, documents } = useStores();
// Cache structure: Map of search queries to timestamp of last search
@@ -59,5 +58,3 @@ function SearchActions() {
return null;
}
export default observer(SearchActions);
+58 -101
View File
@@ -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>
+7 -15
View File
@@ -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 -2
View File
@@ -76,8 +76,7 @@ function SettingsSidebar() {
to={item.path}
onClickIntent={item.preload}
active={
item.path.startsWith(settingsPath("templates")) ||
item.path.startsWith(settingsPath("groups"))
item.path.startsWith(settingsPath("templates"))
? location.pathname.startsWith(item.path)
: undefined
}
+32 -2
View File
@@ -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);
+1 -8
View File
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { useLocation } from "react-router-dom";
import styled, { css, useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -54,7 +53,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
const collapsed = ui.sidebarIsClosed && canCollapse;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const { trigger } = useWebHaptics();
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
@@ -226,11 +224,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
[width]
);
const handleCloseSidebar = () => {
trigger("light");
ui.toggleMobileSidebar();
};
return (
<TooltipProvider>
<Container
@@ -282,7 +275,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={handleCloseSidebar} />}
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</TooltipProvider>
);
});
@@ -15,7 +15,6 @@ import EditableTitle from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
@@ -123,11 +122,10 @@ const CollectionLink: React.FC<Props> = ({
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
onRename: handleRename,
});
return (
<ActionContextProvider value={{ activeModels: [collection] }}>
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
@@ -143,7 +141,7 @@ const CollectionLink: React.FC<Props> = ({
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
$showActions={menuOpen}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
@@ -167,18 +165,17 @@ const CollectionLink: React.FC<Props> = ({
!isDraggingAnyCollection && (
<Fade>
{can.createDocument && (
<Tooltip content={t("New doc")} delay={500}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
)}
<CollectionMenu
collection={collection}
@@ -40,10 +40,6 @@ import type UserMembership from "~/models/UserMembership";
import type GroupMembership from "~/models/GroupMembership";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
type Props = {
node: NavigationNode;
@@ -123,13 +119,6 @@ function InnerDocumentLink(
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
// Context-based recursive expand/collapse for descendant DocumentLinks
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Subscribe to recursive expand/collapse events from an ancestor
useSidebarDisclosure(setExpanded, setCollapsed);
React.useEffect(() => {
if (showChildren) {
setExpanded();
@@ -143,18 +132,13 @@ function InnerDocumentLink(
}
}, [setCollapsed, expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
const willExpand = !expanded;
if (willExpand) {
setExpanded();
} else {
setCollapsed();
}
onDisclosureClick(willExpand, ev.altKey);
},
[setCollapsed, setExpanded, expanded, onDisclosureClick]
);
const handleDisclosureClick = React.useCallback(() => {
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
}, [setCollapsed, setExpanded, expanded]);
const handlePrefetch = React.useCallback(() => {
void prefetchDocument?.(node.id);
@@ -352,10 +336,7 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({
documentId: node.id,
onRename: handleRename,
});
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
const labelElement = React.useMemo(
() => (
@@ -416,7 +397,7 @@ function InnerDocumentLink(
return (
<ActionContextProvider
value={{
activeModels: document ? [document] : [],
activeDocumentId: node.id,
}}
>
<Relative ref={parentRef}>
@@ -451,7 +432,7 @@ function InnerDocumentLink(
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
$showActions={menuOpen}
showActions={menuOpen}
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
@@ -483,24 +464,22 @@ function InnerDocumentLink(
}
/>
)}
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
</ActionContextProvider>
);
}
@@ -13,9 +13,6 @@ import useStores from "~/hooks/useStores";
import type { DragObject } from "../hooks/useDragAndDrop";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import Relative from "./Relative";
import { useSidebarContext } from "./SidebarContext";
@@ -39,10 +36,6 @@ function DraggableCollectionLink({
);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Context-based recursive expand/collapse for descendant DocumentLinks
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Drop to reorder collection
const [
{ isCollectionDropping, isDraggingAnyCollection },
@@ -98,22 +91,15 @@ function DraggableCollectionLink({
locationSidebarContext,
]);
const handleDisclosureClick = useCallback(
(ev) => {
ev?.preventDefault();
setExpanded((e) => {
const willExpand = !e;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[onDisclosureClick]
);
const handleDisclosureClick = useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
const displayChildDocuments = expanded && !isDragging;
return (
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
@@ -135,7 +121,7 @@ function DraggableCollectionLink({
/>
)}
</Relative>
</SidebarDisclosureContext.Provider>
</>
);
}
+13 -28
View File
@@ -7,9 +7,6 @@ import Folder from "./Folder";
import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -24,20 +21,10 @@ const GroupLink: React.FC<Props> = ({ group }) => {
locationSidebarContext === sidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
const handleDisclosureClick = React.useCallback(
(ev) => {
ev?.preventDefault();
setExpanded((e) => {
const willExpand = !e;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[onDisclosureClick]
);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
React.useEffect(() => {
if (locationSidebarContext === sidebarContext) {
@@ -55,17 +42,15 @@ const GroupLink: React.FC<Props> = ({ group }) => {
depth={0}
/>
<SidebarContext.Provider value={sidebarContext}>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
key={membership.id}
membership={membership}
depth={1}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
key={membership.id}
membership={membership}
depth={1}
/>
))}
</Folder>
</SidebarContext.Provider>
</Relative>
);
@@ -9,10 +9,6 @@ import type Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { sharedModelPath } from "~/utils/routeHelpers";
import { descendants } from "@shared/utils/tree";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -66,14 +62,6 @@ function DocumentLink(
const [expanded, setExpanded] = React.useState(showChildren);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
const handleExpand = React.useCallback(() => setExpanded(true), []);
const handleCollapse = React.useCallback(() => setExpanded(false), []);
useSidebarDisclosure(handleExpand, handleCollapse);
React.useEffect(() => {
if (showChildren) {
setExpanded(showChildren);
@@ -84,12 +72,9 @@ function DocumentLink(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
const willExpand = !expanded;
setExpanded(willExpand);
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
onDisclosureClick(willExpand, !!altKey);
setExpanded(!expanded);
},
[expanded, onDisclosureClick]
[expanded]
);
// since we don't have access to the collection sort here, we just put any
@@ -148,24 +133,22 @@ function DocumentLink(
ref={ref}
isActive={() => !!isActiveDocument}
/>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
{expanded &&
nodeChildren.map((childNode, index) => (
<SharedDocumentLink
shareId={shareId}
key={childNode.id}
collection={collection}
node={childNode}
activeDocumentId={activeDocumentId}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
parentId={node.id}
/>
))}
</SidebarDisclosureContext.Provider>
{expanded &&
nodeChildren.map((childNode, index) => (
<SharedDocumentLink
shareId={shareId}
key={childNode.id}
collection={collection}
node={childNode}
activeDocumentId={activeDocumentId}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
parentId={node.id}
/>
))}
</>
);
}
@@ -22,10 +22,6 @@ import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -52,12 +48,6 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
isActiveDocumentInPath && locationSidebarContext === sidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Subscribe to recursive expand/collapse events from an ancestor (e.g. GroupLink)
useSidebarDisclosure(setExpanded, setCollapsed);
React.useEffect(() => {
if (isActiveDocumentInPath && locationSidebarContext === sidebarContext) {
setExpanded();
@@ -86,15 +76,13 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
const willExpand = !expanded;
if (willExpand) {
setExpanded();
} else {
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
onDisclosureClick(willExpand, ev.altKey);
},
[expanded, setExpanded, setCollapsed, onDisclosureClick]
[expanded, setExpanded, setCollapsed]
);
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -170,7 +158,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
NotificationEventType.AddUserToDocument
).length > 0
}
$showActions={menuOpen}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
@@ -186,22 +174,20 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
</div>
</Draggable>
</Relative>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((childNode, index) => (
<DocumentLink
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((childNode, index) => (
<DocumentLink
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{reorderProps.isDragging && (
<DropCursor
isActiveDrop={reorderProps.isOverCursor}
@@ -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)`
@@ -1,127 +0,0 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
useCallback,
} from "react";
/**
* Represents a recursive expand/collapse event broadcast through context.
*/
export interface SidebarDisclosureEvent {
/** Whether descendants should expand or collapse. */
action: "expand" | "collapse";
/**
* Monotonically increasing counter used to detect new events.
* Each increment represents a distinct user interaction.
*/
generation: number;
}
/**
* Context for broadcasting recursive expand/collapse events from a parent
* (e.g. a collection or document disclosure toggle with alt-click) to all
* descendant DocumentLinks in the sidebar tree.
*
* The nearest provider determines the scope — only descendants within that
* provider react to the event. Each DocumentLink should both consume and
* provide this context so that alt-click at any level only affects its subtree.
*/
const SidebarDisclosureContext = createContext<SidebarDisclosureEvent | null>(
null
);
/**
* Hook that subscribes to recursive expand/collapse events from an ancestor
* provider. When a new event is detected, the appropriate callback is invoked.
*
* Newly mounted components will also react to the current event, which enables
* cascading: expanding a parent reveals children, which mount and see the
* expand event, then expand themselves to reveal grandchildren, and so on.
*
* @param onExpand - called when a recursive expand event is received.
* @param onCollapse - called when a recursive collapse event is received.
*/
export function useSidebarDisclosure(
onExpand: () => void,
onCollapse: () => void
): void {
const event = useContext(SidebarDisclosureContext);
const lastHandledGeneration = useRef(-1);
useEffect(() => {
if (!event || event.generation === lastHandledGeneration.current) {
return;
}
lastHandledGeneration.current = event.generation;
if (event.action === "expand") {
onExpand();
} else {
onCollapse();
}
}, [event, onExpand, onCollapse]);
}
/**
* Hook for the producing side of the disclosure context. Returns the current
* event value (to pass to a Provider) and a single callback to handle
* alt-click expand/collapse broadcasts.
*
* This hook also reads the parent context and automatically forwards any
* incoming disclosure events so that the cascade propagates through the
* entire tree — even when intermediate nodes each create their own provider.
*
* @returns object with `event` to spread onto the Provider's value and
* `onDisclosureClick` to call from disclosure click handlers.
*/
export function useSidebarDisclosureState() {
const parentEvent = useContext(SidebarDisclosureContext);
const [event, setEvent] = useState<SidebarDisclosureEvent | null>(null);
const lastForwardedParentGeneration = useRef(-1);
// Forward parent disclosure events into our own provider value so that
// grandchildren (and beyond) see the event even though each level creates
// its own independent provider.
useEffect(() => {
if (
!parentEvent ||
parentEvent.generation === lastForwardedParentGeneration.current
) {
return;
}
lastForwardedParentGeneration.current = parentEvent.generation;
setEvent((prev) => ({
action: parentEvent.action,
generation: (prev?.generation ?? 0) + 1,
}));
}, [parentEvent]);
/**
* Call from a disclosure click handler after toggling expand/collapse state.
* When alt is held, broadcasts a recursive expand or collapse event to all
* descendants. Otherwise, clears any stale event.
*
* @param willExpand - whether the node is expanding or collapsing.
* @param altKey - whether the alt/option key was held during the click.
*/
const onDisclosureClick = useCallback(
(willExpand: boolean, altKey: boolean) => {
if (altKey) {
setEvent((prev) => ({
action: willExpand ? "expand" : "collapse",
generation: (prev?.generation ?? 0) + 1,
}));
} else {
setEvent(null);
}
},
[]
);
return { event, onDisclosureClick };
}
export default SidebarDisclosureContext;
@@ -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,
@@ -152,7 +152,7 @@ function SidebarLink(
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
style={active ? activeStyle : style}
style={style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
onActiveClick={handleDisclosureClick}
@@ -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;
@@ -19,9 +19,6 @@ import {
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import CollectionLink from "./CollectionLink";
import DocumentLink from "./DocumentLink";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
@@ -103,7 +100,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
return (
<ActionContextProvider
value={{
activeModels: [document],
activeDocumentId: document.id,
}}
>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
@@ -124,7 +121,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
$showActions={menuOpen}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
@@ -207,9 +204,6 @@ function StarredLink({ star }: Props) {
sidebarContext === locationSidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
React.useEffect(() => {
if (
star.documentId === ui.activeDocumentId &&
@@ -241,13 +235,9 @@ function StarredLink({ star }: Props) {
(ev?: React.MouseEvent<HTMLElement>) => {
ev?.preventDefault();
ev?.stopPropagation();
setExpanded((prevExpanded) => {
const willExpand = !prevExpanded;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
setExpanded((prevExpanded) => !prevExpanded);
},
[onDisclosureClick]
[]
);
const handlePrefetch = React.useCallback(() => {
@@ -294,43 +284,39 @@ function StarredLink({ star }: Props) {
if (documentId) {
return (
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<StarredDocumentLink
star={star}
documentId={documentId}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
</SidebarDisclosureContext.Provider>
<StarredDocumentLink
star={star}
documentId={documentId}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
);
}
if (collection) {
return (
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
</SidebarDisclosureContext.Provider>
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
);
}
+2 -3
View File
@@ -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
+3 -5
View File
@@ -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()}
`;
-9
View File
@@ -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}
/>
)}
</>
+3 -26
View File
@@ -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;
@@ -60,7 +59,6 @@ export type Props<TData> = {
};
rowHeight: number;
stickyOffset?: number;
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
};
function Table<TData>({
@@ -72,7 +70,6 @@ function Table<TData>({
page,
rowHeight,
stickyOffset = 0,
decorateRow,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
@@ -209,7 +206,7 @@ function Table<TData>({
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as TRow<TData>;
const baseRow = (
return (
<TR
role="row"
key={row.id}
@@ -234,14 +231,6 @@ function Table<TData>({
))}
</TR>
);
return decorateRow ? (
<React.Fragment key={row.id}>
{decorateRow(row.original, baseRow)}
</React.Fragment>
) : (
baseRow
);
})}
</TBody>
{showPlaceholder && (
@@ -337,8 +326,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 +340,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 +391,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;
}
}
`;
+3 -3
View File
@@ -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>
);
};
-30
View File
@@ -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} />;
});
-103
View File
@@ -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("Youre editing a template")}
</Notice>
<Editor
id={template.id}
ref={ref}
isDraft={false}
document={template}
value={readOnly ? template.data : undefined}
defaultValue={template.data}
onFileUploadStart={handleStartUpload}
onFileUploadStop={handleStopUpload}
onChangeTitle={handleChangeTitle}
onChangeIcon={handleChangeIcon}
onSave={handleSave}
onCancel={handleCancel}
onChange={handleChange}
readOnly={readOnly}
canUpdate={can.update}
autoFocus={template.createdAt === template.updatedAt}
template
/>
</React.Suspense>
</DocumentContextProvider>
);
});
const PlaceholderIcon = styled(InputIcon)`
position: relative;
top: 6px;
margin-top: -6px;
`;
-36
View File
@@ -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,
+5 -6
View File
@@ -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
+1 -18
View File
@@ -1,28 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Toaster, useSonner } from "sonner";
import { Toaster } from "sonner";
import styled, { useTheme } from "styled-components";
import { useWebHaptics } from "web-haptics/react";
import useStores from "~/hooks/useStores";
function Toasts() {
const { ui } = useStores();
const theme = useTheme();
const { toasts } = useSonner();
const { trigger } = useWebHaptics();
const prevCountRef = React.useRef(toasts.length);
React.useEffect(() => {
if (toasts.length > prevCountRef.current) {
const latest = toasts[toasts.length - 1];
if (latest.type === "error") {
void trigger("error");
} else if (latest.type === "success") {
void trigger("success");
}
}
prevCountRef.current = toasts.length;
}, [toasts, trigger]);
return (
<StyledToaster
+2 -2
View File
@@ -2,7 +2,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { transparentize } from "polished";
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { s, depths } from "@shared/styles";
import { s } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -267,7 +267,7 @@ const StyledContent = styled(TooltipPrimitive.Content)`
white-space: normal;
outline: 0;
padding: 5px 9px;
z-index: ${depths.tooltip};
z-index: 9999;
max-width: calc(100vw - 10px);
/* Animation */
+4 -8
View File
@@ -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%;
`;
+1 -1
View File
@@ -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};
+3 -113
View File
@@ -1,126 +1,17 @@
import { DocumentIcon, ShapesIcon } from "outline-icons";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import type { MenuItem } from "@shared/editor/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useCallback } from "react";
import useDictionary from "~/hooks/useDictionary";
import useStores from "~/hooks/useStores";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
/**
* Hook that returns a template menu item with children for inserting template
* content into the editor, or undefined if no templates are available.
*/
function useTemplateMenuItem(): MenuItem | undefined {
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const { documents, templates: templatesStore } = useStores();
const editor = useEditor();
const documentId = editor.props.id;
const document = documentId ? documents.get(documentId) : undefined;
const collectionId = document?.collectionId;
return useMemo(() => {
if (!user) {
return undefined;
}
const allTemplates = templatesStore.orderedData.filter(
(template) => template.isActive
);
const hasTemplates = allTemplates.some(
(template) =>
template.isWorkspaceTemplate || template.collectionId === collectionId
);
if (!hasTemplates) {
return undefined;
}
const toMenuItem = (template: (typeof allTemplates)[0]): MenuItem => ({
name: "noop",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
) : (
<DocumentIcon />
),
keywords: template.titleWithDefault,
onClick: () => {
const data = cloneDeep(template.data);
ProsemirrorHelper.replaceTemplateVariables(data, user);
editor.insertContent(data);
},
});
const children = (): MenuItem[] => {
const collectionTemplates = allTemplates.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === collectionId
);
const workspaceTemplates = allTemplates.filter(
(tmpl) => tmpl.isWorkspaceTemplate
);
const items: MenuItem[] = collectionTemplates.map(toMenuItem);
if (collectionTemplates.length && workspaceTemplates.length) {
items.push({ name: "separator" });
}
if (workspaceTemplates.length) {
for (const template of workspaceTemplates) {
items.push(toMenuItem(template));
}
}
return items;
};
return {
name: "noop",
title: t("Templates"),
icon: <ShapesIcon />,
keywords: "template",
children,
} satisfies MenuItem;
}, [user, templatesStore.orderedData, collectionId, editor, t]);
}
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
const templateMenuItem = useTemplateMenuItem();
const items = useMemo(() => {
const baseItems = getMenuItems(dictionary, elementRef);
if (!templateMenuItem) {
return baseItems;
}
return [...baseItems, { name: "separator" } as MenuItem, templateMenuItem];
}, [dictionary, elementRef, templateMenuItem]);
const renderMenuItem = useCallback(
(item, _index, options) => (
@@ -129,7 +20,6 @@ function BlockMenu(props: Props) {
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
disclosure={options.disclosure}
/>
),
[]
@@ -141,9 +31,9 @@ function BlockMenu(props: Props) {
filterable
trigger="/"
renderMenuItem={renderMenuItem}
items={items}
items={getMenuItems(dictionary, elementRef)}
/>
);
}
export default observer(BlockMenu);
export default BlockMenu;
+2 -2
View File
@@ -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;
-4
View File
@@ -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>
+2 -9
View File
@@ -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
+1 -3
View File
@@ -33,7 +33,6 @@ type Props = {
mark?: Mark;
dictionary: Dictionary;
view: EditorView;
autoFocus?: boolean;
onLinkAdd: () => void;
onLinkUpdate: () => void;
onLinkRemove: () => void;
@@ -46,7 +45,6 @@ const LinkEditor: React.FC<Props> = ({
mark,
dictionary,
view,
autoFocus,
onLinkAdd,
onLinkUpdate,
onLinkRemove,
@@ -211,7 +209,7 @@ const LinkEditor: React.FC<Props> = ({
onKeyDown={handleKeyDown}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={autoFocus}
autoFocus={getHref() === ""}
readOnly={!view.editable}
/>
{actions.map((action, index) => {
+20 -38
View File
@@ -1,12 +1,7 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import {
DocumentIcon,
PlusIcon,
NewDocumentIcon,
CollectionIcon,
} from "outline-icons";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -49,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;
@@ -80,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) =>
@@ -134,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: {
@@ -232,26 +218,22 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
label: search,
},
} as MentionItem,
{
name: "link",
icon: <NewDocumentIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a nested doc"),
visible: !!search && !isEmail(search) && !!documentId,
priority: -2,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
nested: true,
},
} as MentionItem,
])
: [];
]);
setItems(items);
setLoaded(true);
}
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
groups,
collections,
]);
const handleSelect = useCallback(
async (item: MentionItem) => {
+6 -12
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isInternalUrl, isUrl } from "@shared/utils/urls";
import { isUrl } from "@shared/utils/urls";
import type Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -67,15 +67,11 @@ function useItems({
const singleUrl =
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
const isInternal = singleUrl ? isInternalUrl(singleUrl) : false;
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(() => {
if (!singleUrl || !embed || isInternal) {
if (!singleUrl || !embed) {
setEmbedCheck({ loading: false });
return;
}
@@ -102,7 +98,7 @@ function useItems({
return () => {
cancelled = true;
};
}, [singleUrl, embed, isInternal]);
}, [singleUrl, embed]);
// single item is pasted.
if (typeof pastedText === "string") {
@@ -144,10 +140,8 @@ function useItems({
name: "embed",
title: t("Embed"),
subtitle:
embedCheck.embeddable === false || isInternal
? t("Not supported")
: undefined,
disabled: isInternal || embedCheck.loading || !embedCheck.embeddable,
embedCheck.embeddable === false ? t("Not supported") : undefined,
disabled: embedCheck.loading || !embedCheck.embeddable,
icon: embed?.icon,
keywords: embed?.keywords,
},
+31 -48
View File
@@ -87,29 +87,25 @@ export function SelectionToolbar(props: Props) {
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const { state } = view;
const [autoFocusLinkInput, setAutoFocusLinkInput] = React.useState(false);
const isDragging = useIsDragging(state);
const { selection } = state;
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
null
);
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
React.useEffect(() => {
const { selection } = state;
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
const isEmbedSelection =
selection instanceof NodeSelection && selection.node.type.name === "embed";
const isEmbedSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
React.useLayoutEffect(() => {
if (!isActive) {
setActiveToolbar(null);
return;
}
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
@@ -128,37 +124,22 @@ export function SelectionToolbar(props: Props) {
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [
readOnly,
isActive,
selection,
linkMark,
isEmbedSelection,
isCodeSelection,
isNoticeSelection,
]);
React.useLayoutEffect(() => {
if (autoFocusLinkInput && activeToolbar !== Toolbar.Link) {
setAutoFocusLinkInput(false);
}
}, [activeToolbar]);
}, [readOnly, selection]);
// Refocus the editor when the link toolbar closes to prevent focus loss
const prevActiveToolbar = React.useRef(activeToolbar);
React.useLayoutEffect(() => {
React.useEffect(() => {
if (
prevActiveToolbar.current === Toolbar.Link &&
activeToolbar !== Toolbar.Link &&
!readOnly &&
isActive
!readOnly
) {
view.focus();
}
prevActiveToolbar.current = activeToolbar;
}, [activeToolbar, readOnly, isActive, view]);
}, [activeToolbar, readOnly, view]);
React.useLayoutEffect(() => {
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
if (
ev.target instanceof HTMLElement &&
@@ -212,10 +193,11 @@ export function SelectionToolbar(props: Props) {
) {
ev.preventDefault();
ev.stopPropagation();
setAutoFocusLinkInput(true);
setActiveToolbar(
activeToolbar === Toolbar.Link ? Toolbar.Menu : Toolbar.Link
);
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
} else if (activeToolbar === Toolbar.Menu) {
setActiveToolbar(Toolbar.Link);
}
}
},
view.dom,
@@ -236,14 +218,17 @@ export function SelectionToolbar(props: Props) {
const isAttachmentSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
const link =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
if (
isCodeSelection &&
(selection.empty || selection instanceof NodeSelection)
) {
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelected(state)) {
@@ -304,7 +289,6 @@ export function SelectionToolbar(props: Props) {
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setAutoFocusLinkInput(true);
setActiveToolbar(Toolbar.Link);
};
}
@@ -331,11 +315,10 @@ export function SelectionToolbar(props: Props) {
>
{activeToolbar === Toolbar.Link ? (
<LinkEditor
key={`link-${selection.anchor}`}
key={`${selection.from}-${selection.to}`}
dictionary={dictionary}
autoFocus={autoFocusLinkInput}
view={view}
mark={linkMark ? linkMark.mark : undefined}
mark={link ? link.mark : undefined}
onLinkAdd={() => setActiveToolbar(null)}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
@@ -345,7 +328,7 @@ export function SelectionToolbar(props: Props) {
/>
) : activeToolbar === Toolbar.Media ? (
<MediaLinkEditor
key={`embed-${selection.anchor}`}
key={`embed-${selection.from}`}
node={
"node" in selection ? (selection as NodeSelection).node : undefined
}
+248 -454
View File
@@ -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,70 +92,25 @@ 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) {
// Save the selection position when the menu opens and as the user types.
// On mobile, the editor may lose focus/selection when tapping on menu
// items, so we restore it. The position must stay current as the search
// text grows, otherwise the deletion range calculated in handleClearSearch
// will be wrong.
hasActivated.current = true;
// Save the selection position when the menu opens. On mobile, the editor
// may lose focus/selection when tapping on menu items, so we restore it.
requestAnimationFrame(() => {
const { from, to } = view.state.selection;
selectionRef.current = { from, to };
@@ -138,23 +119,83 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
selectionRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isActive, props.search]);
React.useEffect(() => {
setSubmenu(null);
if (!props.isActive) {
return;
}
setSelectedIndex(0);
setInsertItem(undefined);
}, [props.isActive]);
React.useEffect(() => {
setSelectedIndex(0);
setSubmenu(null);
}, [props.search]);
const calculatePosition = React.useCallback(
(props: Props) => {
if (!props.isActive) {
return defaultPosition;
}
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,
};
}
// 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),
};
};
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;
@@ -185,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;
@@ -213,9 +274,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
if (item.name === "noop") {
if ("onClick" in item) {
item.onClick?.();
}
// Do nothing
} else if (command) {
command(attrs);
} else {
@@ -245,13 +304,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
...item,
name: "mention",
});
void editorProps.onCreateLink?.(
{
title: item.attrs.label,
id: item.attrs.modelId,
},
!!item.attrs.nested
);
void editorProps.onCreateLink?.({
title: item.attrs.label,
id: item.attrs.modelId,
});
return;
case "image":
return triggerFilePick(
@@ -405,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,
@@ -425,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;
}
@@ -494,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(
@@ -517,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;
@@ -559,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) ||
@@ -725,25 +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;
@@ -760,7 +664,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const fileInput = uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Upload file</Trans>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
@@ -771,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;
@@ -806,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) &&
@@ -825,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 = () => {
@@ -855,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>
@@ -939,146 +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>
)}
</BouncyPopoverContent>
</Popover>
{fileInput}
{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;
`;
@@ -1095,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`
@@ -1123,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>
);
}
+6 -4
View File
@@ -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;
},
{
@@ -12,10 +12,6 @@ export default class ClipboardTextSerializer extends Extension {
return "clipboardTextSerializer";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const mdSerializer = this.editor.extensions.serializer();

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