Compare commits

..

1 Commits

Author SHA1 Message Date
Salihu 98445e9996 option to show more 'shared with me' documents 2025-10-31 21:09:55 +01:00
456 changed files with 6458 additions and 13854 deletions
-1
View File
@@ -1,7 +1,6 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
"projects": [
{
"displayName": "server",
-192
View File
@@ -1,192 +0,0 @@
Outline is a fast, collaborative knowledge base built for teams. It's built with React and TypeScript in both frontend and backend, uses a real-time collaboration engine, and is designed for excellent performance and user experience. The backend is a Koa server with an RPC API and uses PostgreSQL and Redis. The application can be self-hosted or used as a cloud service.
There is a web client which is fully responsive and works on mobile devices.
**Monorepo Structure:**
- **`app/`** - React web application with MobX state management
- **`server/`** - Koa API server with Sequelize ORM and background workers
- **`shared/`** - Shared TypeScript types, utilities, and editor components
- **`plugins/`** - Plugin system for extending functionality
- **`public/`** - Static assets served directly
- **Various config files** - TypeScript, Vite, Jest, Prettier, Oxlint configurations
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
## Instructions
You're an expert in the following areas:
- TypeScript
- React and React Router
- MobX and MobX-React
- Node.js and Koa
- Sequelize ORM
- PostgreSQL
- Redis
- HTML, CSS and Styled Components
- Prosemirror (rich text editor)
- WebSockets and real-time collaboration
## General Guidelines
- Use early returns for readability.
- Emphasize type safety and static analysis.
- Follow consistent Prettier formatting.
- Do not replace smart quotes ("") or ('') with simple quotes ("").
## Dependencies and Upgrading
- Use yarn for all dependency management.
- After updating dependency versions, install to update lockfiles:
```bash
yarn install
```
## TypeScript Usage
- Use strict mode.
- Avoid "unknown" unless absolutely necessary.
- Never use "any".
- Prefer type definitions; avoid type assertions (as, !).
- Always use curly braces for if statements.
- Avoid # for private properties.
- Prefer interface over type for object shapes.
## Classes & Code Organization
### Class Member Order
1. Public static variables
2. Public static methods
3. Public variables
4. Public methods
6. Protected variables & methods
8. Private variables & methods
### Exports
- Exported members must appear at the top of the file.
- Prefer named exports for components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
- Use functional components with hooks.
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
- Use descriptive prop types with TypeScript interfaces.
- You do not need to import React unless it is used directly.
- Use styled-components for component styling.
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.
## MobX State Management
- Use MobX stores for global state management.
- Keep stores in `app/stores/`.
- Use `observable`, `action`, and `computed` decorators appropriately.
- Prefer computed values over manual calculations in render.
- Keep business logic in stores, not components.
## Database & ORM
- Use Sequelize models in `server/models/`.
- Generate migrations with Sequelize CLI:
```bash
yarn sequelize migration:create --name=add-field-to-table
```
- Run migrations with `yarn db:migrate`.
- Use transactions for multi-table operations.
- Add appropriate indexes for query performance.
- Always handle database errors gracefully.
## API Design
- RESTful endpoints under `/api/`.
- Authentication endpoints under `/auth/`.
- Use consistent error responses.
- Validate request data using the validation middleware and schemas
- Use presenters to format API responses.
- Keep API routes thin, use model methods for business logic, or commands if logic spans multiple models.
## Authentication & Authorization
- JWT tokens for authentication.
- Policies in `server/policies/` for authorization.
- Use cancan-style ability checks.
- Use authenticated middleware for protected routes.
- Always verify user permissions before data access.
## Real-time Collaboration
- WebSocket connections for real-time updates.
- Use Y.js for collaborative editing.
- Handle connection state changes gracefully.
## Documentation
- All public/exported functions & classes must have JSDoc.
- Include:
- Description
- @param and @return (start lowercase, end with period)
- @throws if applicable
- Add a newline between the description and the @ block.
- Use correct punctuation.
## Testing
- Run tests with Jest:
```bash
# Run all tests
yarn test
# Run specific test suites
yarn test:app # Frontend tests
yarn test:server # Backend tests
yarn test:shared # Shared code tests
# Run specific test file
yarn test path/to/test.spec.ts
```
- Write unit tests for utilities and business logic in a collocated .test.ts file.
- Mock external dependencies appropriately in __mocks__ folder.
- Aim for high code coverage but focus on critical paths.
## Code Quality
- Use Oxlint for linting: `yarn lint`
- Format code with Prettier: `yarn format`
- Check types with TypeScript: `yarn tsc`
- Pre-commit hooks run automatically via Husky.
- Fix linting issues before committing.
## Error Handling
- Use custom error classes in `server/errors.ts`.
- Always catch and handle errors appropriately.
- Log errors with appropriate context.
- Return user-friendly error messages.
- Never expose sensitive information in errors.
## Performance
- Use React.memo for expensive components.
- Implement pagination for large lists.
- Use database indexes effectively.
- Cache expensive computations.
- Monitor performance with appropriate tools.
- Lazy load routes and components where appropriate.
## Security
- Sanitize all user input.
- Use CSRF protection.
- Use rateLimiter middleware for sensitive endpoints.
- Follow OWASP guidelines.
- Never store sensitive data in plain text.
- Use environment variables for secrets.
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.1.0
Licensed Work: Outline 1.0.1
The Licensed Work is (c) 2025 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: 2029-11-16
Change Date: 2029-10-29
Change License: Apache License, Version 2.0
-5
View File
@@ -194,11 +194,6 @@
"description": "Use a secure SMTP connection (optional)",
"required": false
},
"SMTP_DISABLE_STARTTLS": {
"value": "false",
"description": "Disable STARTTLS even if the server supports it (optional)",
"required": false
},
"SMTP_TLS_CIPHERS": {
"description": "Override SMTP cipher configuration (optional)",
"required": false
+2 -2
View File
@@ -3,7 +3,7 @@ import stores from "~/stores";
import ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
import { createAction } from "..";
import { createAction, createActionV2 } from "..";
import { SettingsSection } from "../sections";
export const createApiKey = createAction({
@@ -26,7 +26,7 @@ export const createApiKey = createAction({
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
createActionV2({
name: ({ t, isMenu }) =>
isMenu
? apiKey.isExpired
-123
View File
@@ -1,123 +0,0 @@
import { ArchiveIcon, MoveIcon, TrashIcon } from "outline-icons";
import DocumentMove from "~/scenes/DocumentMove";
import { createAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentArchive from "~/scenes/DocumentArchive";
import Document from "~/models/Document";
type Props = {
documents: Document[];
};
/**
* Archive multiple documents at once.
*/
export const bulkArchiveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Bulk archive documents",
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).archive);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Archive {{ count }} documents", { count }),
content: (
<DocumentArchive
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Move multiple documents at once.
*/
export const bulkMoveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Move")}`,
analyticsName: "Bulk move documents",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).move);
},
perform: ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Move {{ count }} documents", { count }),
content: (
<DocumentMove
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Delete multiple documents at once.
*/
export const bulkDeleteDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Bulk delete documents",
section: ActiveDocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).delete);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Delete {{ count }} documents", { count }),
content: (
<DocumentDelete
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
export const rootBulkDocumentActions = [
bulkArchiveDocuments,
bulkMoveDocuments,
bulkDeleteDocuments,
];
+36 -34
View File
@@ -29,8 +29,9 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
createActionV2,
createActionV2WithChildren,
createInternalLinkActionV2,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
@@ -51,7 +52,7 @@ const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
export const openCollection = createActionWithChildren({
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
analyticsName: "Open collection",
section: CollectionSection,
@@ -59,17 +60,15 @@ export const openCollection = createActionWithChildren({
icon: <CollectionIcon />,
children: ({ stores }) => {
const collections = stores.collections.orderedData;
return collections.map((collection) =>
createInternalLinkAction({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
to: collection.path,
})
);
return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
to: collection.path,
}));
},
});
@@ -91,7 +90,7 @@ export const createCollection = createAction({
},
});
export const editCollection = createAction({
export const editCollection = createActionV2({
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}` : t("Edit collection")),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
@@ -116,7 +115,7 @@ export const editCollection = createAction({
},
});
export const editCollectionPermissions = createAction({
export const editCollectionPermissions = createActionV2({
name: ({ t, isMenu }) =>
isMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
@@ -148,7 +147,7 @@ export const editCollectionPermissions = createAction({
},
});
export const importDocument = createAction({
export const importDocument = createActionV2({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
@@ -164,7 +163,7 @@ export const importDocument = createAction({
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev) => {
const files = getEventFiles(ev);
@@ -189,7 +188,7 @@ export const importDocument = createAction({
},
});
export const sortCollection = createActionWithChildren({
export const sortCollection = createActionV2WithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ activeCollectionId, stores }) =>
@@ -211,7 +210,7 @@ export const sortCollection = createActionWithChildren({
);
},
children: [
createAction({
createActionV2({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
@@ -231,7 +230,7 @@ export const sortCollection = createActionWithChildren({
});
},
}),
createAction({
createActionV2({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
@@ -251,7 +250,7 @@ export const sortCollection = createActionWithChildren({
});
},
}),
createAction({
createActionV2({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
@@ -271,7 +270,7 @@ export const sortCollection = createActionWithChildren({
],
});
export const searchInCollection = createInternalLinkAction({
export const searchInCollection = createInternalLinkActionV2({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: ActiveCollectionSection,
@@ -302,7 +301,7 @@ export const searchInCollection = createInternalLinkAction({
},
});
export const starCollection = createAction({
export const starCollection = createActionV2({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: ActiveCollectionSection,
@@ -329,7 +328,7 @@ export const starCollection = createAction({
},
});
export const unstarCollection = createAction({
export const unstarCollection = createActionV2({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: ActiveCollectionSection,
@@ -355,7 +354,7 @@ export const unstarCollection = createAction({
},
});
export const subscribeCollection = createAction({
export const subscribeCollection = createActionV2({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
@@ -386,7 +385,7 @@ export const subscribeCollection = createAction({
},
});
export const unsubscribeCollection = createAction({
export const unsubscribeCollection = createActionV2({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
@@ -417,7 +416,7 @@ export const unsubscribeCollection = createAction({
},
});
export const archiveCollection = createAction({
export const archiveCollection = createActionV2({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
section: ActiveCollectionSection,
@@ -458,7 +457,7 @@ export const archiveCollection = createAction({
},
});
export const restoreCollection = createAction({
export const restoreCollection = createActionV2({
name: ({ t }) => t("Restore"),
analyticsName: "Restore collection",
section: CollectionSection,
@@ -483,7 +482,7 @@ export const restoreCollection = createAction({
},
});
export const deleteCollection = createAction({
export const deleteCollection = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: ActiveCollectionSection,
@@ -517,7 +516,7 @@ export const deleteCollection = createAction({
},
});
export const exportCollection = createAction({
export const exportCollection = createActionV2({
name: ({ t }) => `${t("Export")}`,
analyticsName: "Export collection",
section: ActiveCollectionSection,
@@ -527,7 +526,10 @@ export const exportCollection = createAction({
return false;
}
return !!stores.policies.abilities(activeCollectionId).export;
return (
!!stores.policies.abilities(currentTeamId).createExport &&
!!stores.policies.abilities(activeCollectionId).export
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
@@ -550,7 +552,7 @@ export const exportCollection = createAction({
},
});
export const createDocument = createInternalLinkAction({
export const createDocument = createInternalLinkActionV2({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveCollectionSection,
@@ -572,7 +574,7 @@ export const createDocument = createInternalLinkAction({
},
});
export const createTemplate = createInternalLinkAction({
export const createTemplate = createInternalLinkActionV2({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
+5 -5
View File
@@ -3,7 +3,7 @@ import { toast } from "sonner";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import { createAction } from "..";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
export const deleteCommentFactory = ({
@@ -13,7 +13,7 @@ export const deleteCommentFactory = ({
comment: Comment;
onDelete: () => void;
}) =>
createAction({
createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: ActiveDocumentSection,
@@ -39,7 +39,7 @@ export const resolveCommentFactory = ({
comment: Comment;
onResolve: () => void;
}) =>
createAction({
createActionV2({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: ActiveDocumentSection,
@@ -61,7 +61,7 @@ export const unresolveCommentFactory = ({
comment: Comment;
onUnresolve: () => void;
}) =>
createAction({
createActionV2({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: ActiveDocumentSection,
@@ -80,7 +80,7 @@ export const viewCommentReactionsFactory = ({
}: {
comment: Comment;
}) =>
createAction({
createActionV2({
name: ({ t }) => `${t("View reactions")}`,
analyticsName: "View comment reactions",
section: ActiveDocumentSection,
+4 -4
View File
@@ -9,7 +9,7 @@ import {
UserIcon,
} from "outline-icons";
import { toast } from "sonner";
import { createAction, createActionWithChildren } from "~/actions";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
@@ -19,7 +19,7 @@ import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
import { homePath } from "~/utils/routeHelpers";
export const copyId = createActionWithChildren({
export const copyId = createAction({
name: ({ t }) => t("Copy ID"),
icon: <CopyIcon />,
keywords: "uuid",
@@ -191,7 +191,7 @@ export const toggleDebugSafeArea = createAction({
},
});
export const toggleFeatureFlag = createActionWithChildren({
export const toggleFeatureFlag = createAction({
name: "Toggle feature flag",
icon: <BeakerIcon />,
section: DeveloperSection,
@@ -215,7 +215,7 @@ export const toggleFeatureFlag = createActionWithChildren({
),
});
export const developer = createActionWithChildren({
export const developer = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
+96 -89
View File
@@ -47,15 +47,17 @@ import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import {
createAction,
createActionGroup,
createActionWithChildren,
createInternalLinkAction,
createActionV2,
createActionV2Group,
createActionV2WithChildren,
createInternalLinkActionV2,
} from "~/actions";
import {
ActiveDocumentSection,
@@ -78,9 +80,8 @@ import {
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Action, ActionGroup, ActionSeparator } from "~/types";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentArchive from "~/scenes/DocumentArchive";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -89,7 +90,7 @@ const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document/SharePopover")
);
export const openDocument = createActionWithChildren({
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
analyticsName: "Open document",
section: DocumentSection,
@@ -103,29 +104,23 @@ export const openDocument = createActionWithChildren({
);
const documents = stores.documents.orderedData;
return uniqBy([...documents, ...nodes], "id").map((item) =>
createInternalLinkAction({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
icon: item.icon ? (
<Icon
value={item.icon}
initial={item.title}
color={item.color ?? undefined}
/>
) : (
<DocumentIcon />
),
section: DocumentSection,
to: item.url,
})
);
return uniqBy([...documents, ...nodes], "id").map((item) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
section: DocumentSection,
to: item.url,
}));
},
});
export const editDocument = createInternalLinkAction({
export const editDocument = createInternalLinkActionV2({
name: ({ t }) => t("Edit"),
analyticsName: "Edit document",
section: ActiveDocumentSection,
@@ -157,7 +152,7 @@ export const editDocument = createInternalLinkAction({
},
});
export const createDocument = createInternalLinkAction({
export const createDocument = createAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: DocumentSection,
@@ -175,18 +170,13 @@ export const createDocument = createInternalLinkAction({
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(newDocumentPath(activeCollectionId), {
sidebarContext,
}),
});
export const createDraftDocument = createInternalLinkAction({
export const createDraftDocument = createAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
@@ -194,13 +184,13 @@ export const createDraftDocument = createInternalLinkAction({
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
to: ({ sidebarContext }) => ({
pathname: newDocumentPath(),
state: { sidebarContext },
}),
perform: ({ sidebarContext }) =>
history.push(newDocumentPath(), {
sidebarContext,
}),
});
export const createDocumentFromTemplate = createInternalLinkAction({
export const createDocumentFromTemplate = createInternalLinkActionV2({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
@@ -247,7 +237,7 @@ export const createDocumentFromTemplate = createInternalLinkAction({
},
});
export const createNestedDocument = createInternalLinkAction({
export const createNestedDocument = createInternalLinkActionV2({
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
@@ -270,7 +260,7 @@ export const createNestedDocument = createInternalLinkAction({
},
});
export const starDocument = createAction({
export const starDocument = createActionV2({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
section: ActiveDocumentSection,
@@ -296,7 +286,7 @@ export const starDocument = createAction({
},
});
export const unstarDocument = createAction({
export const unstarDocument = createActionV2({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar document",
section: ActiveDocumentSection,
@@ -322,7 +312,7 @@ export const unstarDocument = createAction({
},
});
export const publishDocument = createAction({
export const publishDocument = createActionV2({
name: ({ t }) => t("Publish"),
analyticsName: "Publish document",
section: ActiveDocumentSection,
@@ -364,7 +354,7 @@ export const publishDocument = createAction({
},
});
export const unpublishDocument = createAction({
export const unpublishDocument = createActionV2({
name: ({ t }) => t("Unpublish"),
analyticsName: "Unpublish document",
section: ActiveDocumentSection,
@@ -395,7 +385,7 @@ export const unpublishDocument = createAction({
},
});
export const subscribeDocument = createAction({
export const subscribeDocument = createActionV2({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
@@ -441,7 +431,7 @@ export const subscribeDocument = createAction({
},
});
export const unsubscribeDocument = createAction({
export const unsubscribeDocument = createActionV2({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
@@ -489,7 +479,7 @@ export const unsubscribeDocument = createAction({
},
});
export const shareDocument = createAction({
export const shareDocument = createActionV2({
name: ({ t }) => `${t("Permissions")}`,
analyticsName: "Share document",
section: ActiveDocumentSection,
@@ -522,7 +512,7 @@ export const shareDocument = createAction({
},
});
export const downloadDocumentAsHTML = createAction({
export const downloadDocumentAsHTML = createActionV2({
name: ({ t }) => t("HTML"),
analyticsName: "Download document as HTML",
section: ActiveDocumentSection,
@@ -541,7 +531,7 @@ export const downloadDocumentAsHTML = createAction({
},
});
export const downloadDocumentAsPDF = createAction({
export const downloadDocumentAsPDF = createActionV2({
name: ({ t }) => t("PDF"),
analyticsName: "Download document as PDF",
section: ActiveDocumentSection,
@@ -567,7 +557,7 @@ export const downloadDocumentAsPDF = createAction({
},
});
export const downloadDocumentAsMarkdown = createAction({
export const downloadDocumentAsMarkdown = createActionV2({
name: ({ t }) => t("Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
@@ -586,7 +576,7 @@ export const downloadDocumentAsMarkdown = createAction({
},
});
export const downloadDocument = createActionWithChildren({
export const downloadDocument = createActionV2WithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
analyticsName: "Download document",
section: ActiveDocumentSection,
@@ -601,7 +591,7 @@ export const downloadDocument = createActionWithChildren({
],
});
export const copyDocumentAsMarkdown = createAction({
export const copyDocumentAsMarkdown = createActionV2({
name: ({ t }) => t("Copy as Markdown"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -623,7 +613,7 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentAsPlainText = createAction({
export const copyDocumentAsPlainText = createActionV2({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -645,7 +635,7 @@ export const copyDocumentAsPlainText = createAction({
},
});
export const copyDocumentShareLink = createAction({
export const copyDocumentShareLink = createActionV2({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
keywords: "clipboard share",
@@ -666,7 +656,7 @@ export const copyDocumentShareLink = createAction({
},
});
export const copyDocumentLink = createAction({
export const copyDocumentLink = createActionV2({
name: ({ t }) => t("Copy link"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -684,7 +674,7 @@ export const copyDocumentLink = createAction({
},
});
export const copyDocument = createActionWithChildren({
export const copyDocument = createActionV2WithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy document",
section: ActiveDocumentSection,
@@ -698,7 +688,7 @@ export const copyDocument = createActionWithChildren({
],
});
export const duplicateDocument = createAction({
export const duplicateDocument = createActionV2({
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
@@ -733,7 +723,7 @@ export const duplicateDocument = createAction({
* Pin a document to a collection. Pinned documents will be displayed at the top
* of the collection for all collection members to see.
*/
export const pinDocumentToCollection = createAction({
export const pinDocumentToCollection = createActionV2({
name: ({ activeDocumentId = "", t, stores }) => {
const selectedDocument = stores.documents.get(activeDocumentId);
const collectionName = selectedDocument
@@ -778,7 +768,7 @@ export const pinDocumentToCollection = createAction({
* Pin a document to team home. Pinned documents will be displayed at the top
* of the home screen for all team members to see.
*/
export const pinDocumentToHome = createAction({
export const pinDocumentToHome = createActionV2({
name: ({ t }) => t("Pin to home"),
analyticsName: "Pin document to home",
section: ActiveDocumentSection,
@@ -810,7 +800,7 @@ export const pinDocumentToHome = createAction({
},
});
export const pinDocument = createActionWithChildren({
export const pinDocument = createActionV2WithChildren({
name: ({ t }) => t("Pin"),
analyticsName: "Pin document",
section: ActiveDocumentSection,
@@ -818,7 +808,7 @@ export const pinDocument = createActionWithChildren({
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const searchInDocument = createInternalLinkAction({
export const searchInDocument = createInternalLinkActionV2({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
@@ -848,7 +838,7 @@ export const searchInDocument = createInternalLinkAction({
},
});
export const printDocument = createAction({
export const printDocument = createActionV2({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
analyticsName: "Print document",
section: ActiveDocumentSection,
@@ -859,7 +849,7 @@ export const printDocument = createAction({
},
});
export const importDocument = createAction({
export const importDocument = createActionV2({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: DocumentSection,
@@ -880,7 +870,7 @@ export const importDocument = createAction({
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev) => {
const files = getEventFiles(ev);
@@ -905,7 +895,7 @@ export const importDocument = createAction({
},
});
export const createTemplateFromDocument = createAction({
export const createTemplateFromDocument = createActionV2({
name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document",
section: ActiveDocumentSection,
@@ -956,7 +946,7 @@ export const openRandomDocument = createAction({
});
export const searchDocumentsForQuery = (query: string) =>
createInternalLinkAction({
createAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
@@ -967,7 +957,7 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
export const moveTemplateToWorkspace = createActionV2({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
@@ -997,7 +987,7 @@ export const moveTemplateToWorkspace = createAction({
},
});
export const moveDocumentToCollection = createAction({
export const moveDocumentToCollection = createActionV2({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
@@ -1028,13 +1018,13 @@ export const moveDocumentToCollection = createAction({
title: t("Move {{ documentType }}", {
documentType: document.noun,
}),
content: <DocumentMove documents={[document]} />,
content: <DocumentMove document={document} />,
});
}
},
});
export const moveDocument = createAction({
export const moveDocument = createActionV2({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
@@ -1053,7 +1043,7 @@ export const moveDocument = createAction({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionWithChildren({
export const moveTemplate = createActionV2WithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
@@ -1072,7 +1062,7 @@ export const moveTemplate = createActionWithChildren({
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
export const archiveDocument = createActionV2({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
section: ActiveDocumentSection,
@@ -1094,13 +1084,25 @@ export const archiveDocument = createAction({
dialogs.openModal({
title: t("Are you sure you want to archive this document?"),
content: <DocumentArchive documents={[document]} />,
content: (
<ConfirmationDialog
onSubmit={async () => {
await document.archive();
toast.success(t("Document archived"));
}}
savingText={`${t("Archiving")}`}
>
{t(
"Archiving this document will remove it from the collection and search results."
)}
</ConfirmationDialog>
),
});
}
},
});
export const restoreDocument = createAction({
export const restoreDocument = createActionV2({
name: ({ t }) => `${t("Restore")}`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
@@ -1140,7 +1142,7 @@ export const restoreDocument = createAction({
},
});
export const restoreDocumentToCollection = createActionWithChildren({
export const restoreDocumentToCollection = createActionV2WithChildren({
name: ({ t }) => `${t("Restore")}`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
@@ -1175,7 +1177,7 @@ export const restoreDocumentToCollection = createActionWithChildren({
const actions = collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return createAction({
return createActionV2({
name: collection.name,
section: ActiveDocumentSection,
icon: <CollectionIcon collection={collection} />,
@@ -1191,11 +1193,11 @@ export const restoreDocumentToCollection = createActionWithChildren({
});
});
return [createActionGroup({ name: t("Choose a collection"), actions })];
return [createActionV2Group({ name: t("Choose a collection"), actions })];
},
});
export const deleteDocument = createAction({
export const deleteDocument = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete document",
section: ActiveDocumentSection,
@@ -1218,13 +1220,18 @@ export const deleteDocument = createAction({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
content: <DocumentDelete documents={[document]} />,
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const permanentlyDeleteDocument = createAction({
export const permanentlyDeleteDocument = createActionV2({
name: ({ t }) => t("Permanently delete"),
analyticsName: "Permanently delete document",
section: ActiveDocumentSection,
@@ -1279,7 +1286,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
},
});
export const openDocumentComments = createAction({
export const openDocumentComments = createActionV2({
name: ({ t }) => t("Comments"),
analyticsName: "Open comments",
section: ActiveDocumentSection,
@@ -1302,7 +1309,7 @@ export const openDocumentComments = createAction({
},
});
export const openDocumentHistory = createInternalLinkAction({
export const openDocumentHistory = createInternalLinkActionV2({
name: ({ t }) => t("History"),
analyticsName: "Open document history",
section: ActiveDocumentSection,
@@ -1329,7 +1336,7 @@ export const openDocumentHistory = createInternalLinkAction({
},
});
export const openDocumentInsights = createAction({
export const openDocumentInsights = createActionV2({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
@@ -1362,7 +1369,7 @@ export const openDocumentInsights = createAction({
},
});
export const leaveDocument = createAction({
export const leaveDocument = createActionV2({
name: ({ t }) => t("Leave document"),
analyticsName: "Leave document",
section: ActiveDocumentSection,
@@ -1401,9 +1408,9 @@ export const leaveDocument = createAction({
export const applyTemplateFactory = ({
actions,
}: {
actions: (Action | ActionGroup | ActionSeparator)[];
actions: (ActionV2 | ActionV2Group | ActionV2Separator)[];
}) =>
createActionWithChildren({
createActionV2WithChildren({
name: ({ t }) => t("Apply template"),
analyticsName: "Apply template",
section: ActiveDocumentSection,
-21
View File
@@ -1,21 +0,0 @@
import { PlusIcon } from "outline-icons";
import { createAction } from "~/actions";
import { TeamSection } from "../sections";
import stores from "~/stores";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
export const createEmoji = createAction({
name: ({ t }) => `${t("New emoji")}`,
analyticsName: "Create emoji",
icon: <PlusIcon />,
keywords: "emoji custom upload image",
section: TeamSection,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Upload emoji"),
content: <EmojiCreateDialog onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+1 -20
View File
@@ -3,27 +3,8 @@ import stores from "~/stores";
import { createAction } from "..";
import { SettingsSection } from "../sections";
import Integration from "~/models/Integration";
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
import { IntegrationType } from "@shared/types";
import { settingsPath } from "@shared/utils/routeHelpers";
import history from "~/utils/history";
export const disconnectIntegrationFactory = (integration?: Integration) =>
createAction({
name: ({ t }) => t("Disconnect"),
analyticsName: "Disconnect integration",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "disconnect",
visible: () => !!integration,
perform: async ({ event }) => {
event?.preventDefault();
event?.stopPropagation();
await integration?.delete();
history.push(settingsPath("integrations"));
},
});
import { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
export const disconnectAnalyticsIntegrationFactory = (
integration?: Integration<IntegrationType.Analytics>
+27 -24
View File
@@ -21,8 +21,9 @@ import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import {
createAction,
createExternalLinkAction,
createInternalLinkAction,
createActionV2,
createExternalLinkActionV2,
createInternalLinkActionV2,
} from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import Desktop from "~/utils/Desktop";
@@ -36,7 +37,7 @@ import {
settingsPath,
} from "~/utils/routeHelpers";
export const navigateToHome = createInternalLinkAction({
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),
analyticsName: "Navigate to home",
section: NavigationSection,
@@ -47,7 +48,7 @@ export const navigateToHome = createInternalLinkAction({
});
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createInternalLinkAction({
createAction({
section: RecentSearchesSection,
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
@@ -55,7 +56,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
to: searchPath({ query: searchQuery.query }),
});
export const navigateToDrafts = createInternalLinkAction({
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts",
section: NavigationSection,
@@ -64,7 +65,7 @@ export const navigateToDrafts = createInternalLinkAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createInternalLinkAction({
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
@@ -73,7 +74,7 @@ export const navigateToSearch = createInternalLinkAction({
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createInternalLinkAction({
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
section: NavigationSection,
@@ -83,7 +84,7 @@ export const navigateToArchive = createInternalLinkAction({
visible: ({ location }) => location.pathname !== archivePath(),
});
export const navigateToTrash = createInternalLinkAction({
export const navigateToTrash = createAction({
name: ({ t }) => t("Trash"),
analyticsName: "Navigate to trash",
section: NavigationSection,
@@ -92,7 +93,7 @@ export const navigateToTrash = createInternalLinkAction({
visible: ({ location }) => location.pathname !== trashPath(),
});
export const navigateToSettings = createInternalLinkAction({
export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to settings",
section: NavigationSection,
@@ -102,7 +103,7 @@ export const navigateToSettings = createInternalLinkAction({
to: settingsPath(),
});
export const navigateToWorkspaceSettings = createInternalLinkAction({
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to workspace settings",
section: NavigationSection,
@@ -111,7 +112,7 @@ export const navigateToWorkspaceSettings = createInternalLinkAction({
to: settingsPath("details"),
});
export const navigateToProfileSettings = createInternalLinkAction({
export const navigateToProfileSettings = createInternalLinkActionV2({
name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings",
section: NavigationSection,
@@ -120,7 +121,7 @@ export const navigateToProfileSettings = createInternalLinkAction({
to: settingsPath(),
});
export const navigateToTemplateSettings = createInternalLinkAction({
export const navigateToTemplateSettings = createAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to template settings",
section: NavigationSection,
@@ -129,7 +130,7 @@ export const navigateToTemplateSettings = createInternalLinkAction({
to: settingsPath("templates"),
});
export const navigateToNotificationSettings = createInternalLinkAction({
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isMenu }) =>
isMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
@@ -139,7 +140,7 @@ export const navigateToNotificationSettings = createInternalLinkAction({
to: settingsPath("notifications"),
});
export const navigateToAccountPreferences = createInternalLinkAction({
export const navigateToAccountPreferences = createInternalLinkActionV2({
name: ({ t }) => t("Preferences"),
analyticsName: "Navigate to account preferences",
section: NavigationSection,
@@ -148,7 +149,7 @@ export const navigateToAccountPreferences = createInternalLinkAction({
to: settingsPath("preferences"),
});
export const openDocumentation = createExternalLinkAction({
export const openDocumentation = createExternalLinkActionV2({
name: ({ t }) => t("Documentation"),
analyticsName: "Open documentation",
section: NavigationSection,
@@ -158,7 +159,7 @@ export const openDocumentation = createExternalLinkAction({
target: "_blank",
});
export const openAPIDocumentation = createExternalLinkAction({
export const openAPIDocumentation = createExternalLinkActionV2({
name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation",
section: NavigationSection,
@@ -176,7 +177,7 @@ export const toggleSidebar = createAction({
perform: () => stores.ui.toggleCollapsedSidebar(),
});
export const openFeedbackUrl = createExternalLinkAction({
export const openFeedbackUrl = createExternalLinkActionV2({
name: ({ t }) => t("Send us feedback"),
analyticsName: "Open feedback",
section: NavigationSection,
@@ -186,7 +187,7 @@ export const openFeedbackUrl = createExternalLinkAction({
target: "_blank",
});
export const openBugReportUrl = createExternalLinkAction({
export const openBugReportUrl = createExternalLinkActionV2({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
@@ -196,7 +197,7 @@ export const openBugReportUrl = createExternalLinkAction({
target: "_blank",
});
export const openChangelog = createExternalLinkAction({
export const openChangelog = createExternalLinkActionV2({
name: ({ t }) => t("Changelog"),
analyticsName: "Open changelog",
section: NavigationSection,
@@ -206,7 +207,7 @@ export const openChangelog = createExternalLinkAction({
target: "_blank",
});
export const openKeyboardShortcuts = createAction({
export const openKeyboardShortcuts = createActionV2({
name: ({ t }) => t("Keyboard shortcuts"),
analyticsName: "Open keyboard shortcuts",
section: NavigationSection,
@@ -221,7 +222,7 @@ export const openKeyboardShortcuts = createAction({
},
});
export const downloadApp = createExternalLinkAction({
export const downloadApp = createAction({
name: ({ t }) =>
t("Download {{ platform }} app", {
platform: isMac() ? "macOS" : "Windows",
@@ -231,11 +232,13 @@ export const downloadApp = createExternalLinkAction({
iconInContextMenu: false,
icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
url: "https://desktop.getoutline.com",
target: "_blank",
to: {
url: "https://desktop.getoutline.com",
target: "_blank",
},
});
export const logout = createAction({
export const logout = createActionV2({
name: ({ t }) => t("Log out"),
analyticsName: "Log out",
section: NavigationSection,
+2 -2
View File
@@ -1,5 +1,5 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import { createAction } from "..";
import { createAction, createActionV2 } from "..";
import { NotificationSection } from "../sections";
export const markNotificationsAsRead = createAction({
@@ -12,7 +12,7 @@ export const markNotificationsAsRead = createAction({
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const markNotificationsAsArchived = createAction({
export const markNotificationsAsArchived = createActionV2({
name: ({ t }) => t("Archive all notifications"),
analyticsName: "Mark notifications as archived",
section: NotificationSection,
+3 -3
View File
@@ -3,7 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
import { createAction } from "~/actions";
import { createAction, createActionV2 } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import {
@@ -11,7 +11,7 @@ import {
matchDocumentHistory,
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
export const restoreRevision = createActionV2({
name: ({ t }) => t("Restore"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
@@ -73,7 +73,7 @@ export const deleteRevision = createAction({
},
});
export const copyLinkToRevision = createAction({
export const copyLinkToRevision = createActionV2({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
+5 -5
View File
@@ -1,9 +1,9 @@
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import { Theme } from "~/stores/UiStore";
import { createAction, createActionWithChildren } from "~/actions";
import { createActionV2, createActionV2WithChildren } from "~/actions";
import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createAction({
export const changeToDarkTheme = createActionV2({
name: ({ t }) => t("Dark"),
analyticsName: "Change to dark theme",
icon: <MoonIcon />,
@@ -14,7 +14,7 @@ export const changeToDarkTheme = createAction({
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
});
export const changeToLightTheme = createAction({
export const changeToLightTheme = createActionV2({
name: ({ t }) => t("Light"),
analyticsName: "Change to light theme",
icon: <SunIcon />,
@@ -25,7 +25,7 @@ export const changeToLightTheme = createAction({
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
});
export const changeToSystemTheme = createAction({
export const changeToSystemTheme = createActionV2({
name: ({ t }) => t("System"),
analyticsName: "Change to system theme",
icon: <BrowserIcon />,
@@ -36,7 +36,7 @@ export const changeToSystemTheme = createAction({
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
});
export const changeTheme = createActionWithChildren({
export const changeTheme = createActionV2WithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
+4 -4
View File
@@ -1,13 +1,13 @@
import copy from "copy-to-clipboard";
import Share from "~/models/Share";
import { createAction, createInternalLinkAction } from "..";
import { createActionV2, createInternalLinkActionV2 } from "..";
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
import { ShareSection } from "../sections";
import env from "~/env";
import { toast } from "sonner";
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
createAction({
createActionV2({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy share link",
section: ShareSection,
@@ -22,7 +22,7 @@ export const copyShareUrlFactory = ({ share }: { share: Share }) =>
});
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
createInternalLinkAction({
createInternalLinkActionV2({
name: ({ t }) =>
share.collectionId ? t("Go to collection") : t("Go to document"),
analyticsName: "Go to share source",
@@ -41,7 +41,7 @@ export const revokeShareFactory = ({
share: Share;
can: Record<string, boolean>;
}) =>
createAction({
createActionV2({
name: ({ t }) => t("Revoke link"),
analyticsName: "Revoke share",
section: ShareSection,
+9 -9
View File
@@ -6,17 +6,17 @@ import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
import {
createAction,
createActionWithChildren,
createExternalLinkAction,
createActionV2,
createActionV2WithChildren,
createExternalLinkActionV2,
} from "~/actions";
import { ActionContext, ExternalLinkAction } from "~/types";
import { ActionContext, ExternalLinkActionV2 } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map<ExternalLinkAction>((session) =>
createExternalLinkAction({
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
createExternalLinkActionV2({
id: `switch-${session.id}`,
name: session.name,
analyticsName: "Switch workspace",
@@ -41,7 +41,7 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
})
) ?? [];
export const switchTeam = createActionWithChildren({
export const switchTeam = createActionV2WithChildren({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace",
@@ -52,7 +52,7 @@ export const switchTeam = createActionWithChildren({
children: switchTeamsList,
});
export const createTeam = createAction({
export const createTeam = createActionV2({
name: ({ t }) => `${t("New workspace")}`,
analyticsName: "New workspace",
keywords: "create change switch workspace organization team",
@@ -74,7 +74,7 @@ export const createTeam = createAction({
},
});
export const desktopLoginTeam = createAction({
export const desktopLoginTeam = createActionV2({
name: ({ t }) => t("Login to workspace"),
analyticsName: "Login to workspace",
keywords: "change switch workspace organization team",
+3 -3
View File
@@ -8,7 +8,7 @@ import {
UserChangeRoleDialog,
UserDeleteDialog,
} from "~/components/UserDialogs";
import { createAction } from "~/actions";
import { createAction, createActionV2 } from "~/actions";
import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
@@ -29,7 +29,7 @@ export const inviteUser = createAction({
});
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
createAction({
createActionV2({
name: ({ t }) =>
UserRoleHelper.isRoleHigher(role, user!.role)
? `${t("Promote to {{ role }}", {
@@ -64,7 +64,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
});
export const deleteUserActionFactory = (userId: string) =>
createAction({
createActionV2({
name: ({ t }) => `${t("Delete user")}`,
analyticsName: "Delete user",
keywords: "leave",
+193 -39
View File
@@ -1,17 +1,23 @@
import { LocationDescriptor } from "history";
import { v4 as uuidv4 } from "uuid";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
ActionContext,
Action,
ActionGroup,
ActionSeparator as TActionSeparator,
ActionVariant,
ActionWithChildren,
ExternalLinkAction,
InternalLinkAction,
ActionContext,
ActionV2,
ActionV2Group,
ActionV2Separator as TActionV2Separator,
ActionV2Variant,
ActionV2WithChildren,
ExternalLinkActionV2,
InternalLinkActionV2,
MenuExternalLink,
MenuInternalLink,
MenuItem,
MenuItemButton,
MenuItemWithChildren,
} from "~/types";
import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
@@ -21,13 +27,161 @@ export function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
}
export const ActionSeparator: TActionSeparator = {
export function createAction(definition: Optional<Action, "id">): Action {
return {
...definition,
perform: definition.perform
? (context) => {
// We must use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
});
}
return definition.perform?.(context);
}
: undefined,
id: definition.id ?? uuidv4(),
};
}
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemButton | MenuExternalLink | MenuInternalLink | MenuItemWithChildren {
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name, context);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? resolvedIcon
: undefined;
if (resolvedChildren) {
const items = resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter(Boolean)
.filter((a) => a.visible);
return {
type: "submenu",
title,
icon,
items,
visible: visible && items.length > 0,
};
}
if (action.to) {
return typeof action.to === "string"
? {
type: "route",
title,
icon,
visible,
to: action.to,
selected: action.selected?.(context),
}
: {
type: "link",
title,
icon,
visible,
href: action.to,
selected: action.selected?.(context),
};
}
return {
type: "button",
title,
icon,
visible,
dangerous: action.dangerous,
onClick: () => performAction(action, context),
selected: action.selected?.(context),
};
}
export function actionToKBar(
action: Action,
context: ActionContext
): KbarAction[] {
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const resolvedSection = resolve<string>(action.section, context);
const resolvedName = resolve<string>(action.name, context);
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
)
: [];
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? ((action.section.priority as number) ?? 0)
: 0;
return [
{
id: action.id,
name: resolvedName,
analyticsName: action.analyticsName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
perform:
action.perform || action.to
? () => performAction(action, context)
: undefined,
},
].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
children.map((child) => ({ ...child, parent: child.parent ?? action.id }))
);
}
export async function performAction(action: Action, context: ActionContext) {
const result = action.perform
? action.perform(context)
: action.to
? typeof action.to === "string"
? history.push(action.to)
: window.open(action.to.url, action.to.target)
: undefined;
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
}
/** Actions V2 */
export const ActionV2Separator: TActionV2Separator = {
type: "action_separator",
};
export function createAction(
definition: Optional<Omit<Action, "type" | "variant">, "id">
): Action {
export function createActionV2(
definition: Optional<Omit<ActionV2, "type" | "variant">, "id">
): ActionV2 {
return {
...definition,
type: "action",
@@ -52,9 +206,9 @@ export function createAction(
};
}
export function createInternalLinkAction(
definition: Optional<Omit<InternalLinkAction, "type" | "variant">, "id">
): InternalLinkAction {
export function createInternalLinkActionV2(
definition: Optional<Omit<InternalLinkActionV2, "type" | "variant">, "id">
): InternalLinkActionV2 {
return {
...definition,
type: "action",
@@ -63,9 +217,9 @@ export function createInternalLinkAction(
};
}
export function createExternalLinkAction(
definition: Optional<Omit<ExternalLinkAction, "type" | "variant">, "id">
): ExternalLinkAction {
export function createExternalLinkActionV2(
definition: Optional<Omit<ExternalLinkActionV2, "type" | "variant">, "id">
): ExternalLinkActionV2 {
return {
...definition,
type: "action",
@@ -74,9 +228,9 @@ export function createExternalLinkAction(
};
}
export function createActionWithChildren(
definition: Optional<Omit<ActionWithChildren, "type" | "variant">, "id">
): ActionWithChildren {
export function createActionV2WithChildren(
definition: Optional<Omit<ActionV2WithChildren, "type" | "variant">, "id">
): ActionV2WithChildren {
return {
...definition,
type: "action",
@@ -85,9 +239,9 @@ export function createActionWithChildren(
};
}
export function createActionGroup(
definition: Omit<ActionGroup, "type">
): ActionGroup {
export function createActionV2Group(
definition: Omit<ActionV2Group, "type">
): ActionV2Group {
return {
...definition,
type: "action_group",
@@ -95,8 +249,8 @@ export function createActionGroup(
}
export function createRootMenuAction(
actions: (ActionVariant | ActionGroup | TActionSeparator)[]
): ActionWithChildren {
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: uuidv4(),
type: "action",
@@ -107,8 +261,8 @@ export function createRootMenuAction(
};
}
export function actionToMenuItem(
action: ActionVariant | ActionGroup | TActionSeparator,
export function actionV2ToMenuItem(
action: ActionV2Variant | ActionV2Group | TActionV2Separator,
context: ActionContext
): MenuItem {
switch (action.type) {
@@ -132,7 +286,7 @@ export function actionToMenuItem(
tooltip: resolve<React.ReactChild>(action.tooltip, context),
selected: resolve<boolean>(action.selected, context),
dangerous: action.dangerous,
onClick: () => performAction(action, context),
onClick: () => performActionV2(action, context),
};
case "internal_link": {
@@ -161,10 +315,10 @@ export function actionToMenuItem(
case "action_with_children": {
const children = resolve<
(ActionVariant | ActionGroup | TActionSeparator)[]
(ActionV2Variant | ActionV2Group | TActionV2Separator)[]
>(action.children, context);
const subMenuItems = children.map((a) =>
actionToMenuItem(a, context)
actionV2ToMenuItem(a, context)
);
return {
type: "submenu",
@@ -183,7 +337,7 @@ export function actionToMenuItem(
case "action_group": {
const groupItems = action.actions.map((a) =>
actionToMenuItem(a, context)
actionV2ToMenuItem(a, context)
);
return {
type: "group",
@@ -198,8 +352,8 @@ export function actionToMenuItem(
}
}
export function actionToKBar(
action: ActionVariant,
export function actionV2ToKBar(
action: ActionV2Variant,
context: ActionContext
): KbarAction[] {
const visible = resolve<boolean>(action.visible, context);
@@ -231,18 +385,18 @@ export function actionToKBar(
shortcut: action.shortcut,
icon,
priority,
perform: () => performAction(action, context),
perform: () => performActionV2(action, context),
},
];
}
case "action_with_children": {
const resolvedChildren = resolve<ActionVariant[]>(
const resolvedChildren = resolve<ActionV2Variant[]>(
action.children,
context
);
const children = resolvedChildren
.map((a) => actionToKBar(a, context))
.map((a) => actionV2ToKBar(a, context))
.flat()
.filter(Boolean);
@@ -268,8 +422,8 @@ export function actionToKBar(
}
}
export async function performAction(
action: Exclude<ActionVariant, ActionWithChildren>,
export async function performActionV2(
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
context: ActionContext
) {
const perform =
-2
View File
@@ -38,8 +38,6 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const GroupSection = ({ t }: ActionContext) => t("Groups");
export const EmojiSecion = ({ t }: ActionContext) => t("Emoji");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
+13 -10
View File
@@ -1,10 +1,10 @@
/* oxlint-disable react/prop-types */
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction, resolve } from "~/actions";
import { performAction, performActionV2, resolve } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
import useActionContext from "~/hooks/useActionContext";
import { ActionVariant, ActionWithChildren } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -12,7 +12,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Exclude<ActionVariant, ActionWithChildren>;
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
@@ -30,20 +30,20 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
});
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (!actionContext || !action) {
return <button {...rest} ref={ref} />;
}
const actionIsDisabled =
action.visible && !resolve<boolean>(action.visible, actionContext);
if (actionIsDisabled && hideOnActionDisabled) {
if (
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
hideOnActionDisabled
) {
return null;
}
const disabled = rest.disabled || actionIsDisabled;
const label =
rest["aria-label"] ??
(typeof action.name === "function"
@@ -61,7 +61,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const response = performAction(action, actionContext);
const response =
"variant" in action
? performActionV2(action, actionContext)
: performAction(action, actionContext);
if (response?.finally) {
setExecuting(true);
void response.finally(
+1 -3
View File
@@ -59,8 +59,6 @@ function Avatar(props: Props) {
} = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
const initial =
model?.initial || (model?.name ? model.name[0] : "").toUpperCase();
const content = (
<Relative
@@ -73,7 +71,7 @@ function Avatar(props: Props) {
<Image onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} {...rest}>
{initial}
{model.initial}
</Initials>
) : (
<Initials {...rest} />
+10 -7
View File
@@ -6,17 +6,17 @@ import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { InternalLinkAction, MenuInternalLink } from "~/types";
import { actionToMenuItem } from "~/actions";
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useComputed } from "~/hooks/useComputed";
type TopLevelAction =
| InternalLinkAction
| { type: "menu"; actions: InternalLinkAction[] };
| InternalLinkActionV2
| { type: "menu"; actions: InternalLinkActionV2[] };
type Props = React.PropsWithChildren<{
actions: InternalLinkAction[];
actions: InternalLinkActionV2[];
max?: number;
highlightFirstItem?: boolean;
}>;
@@ -46,7 +46,7 @@ function Breadcrumb(
const menuActions = topLevelActions.splice(
halfMax,
totalVisibleActions - max
) as InternalLinkAction[];
) as InternalLinkActionV2[];
topLevelActions.splice(halfMax, 0, {
type: "menu",
@@ -60,7 +60,10 @@ function Breadcrumb(
return <BreadcrumbMenu key="menu" actions={action.actions} />;
}
const item = actionToMenuItem(action, actionContext) as MenuInternalLink;
const item = actionV2ToMenuItem(
action,
actionContext
) as MenuInternalLink;
return (
<>
-108
View File
@@ -1,108 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import {
MenuHeader,
MenuSeparator,
} from "~/components/primitives/components/Menu";
import { Portal } from "~/components/Portal";
import { toMobileMenuItems } from "~/components/Menu/transformer";
import { actionToMenuItem } from "~/actions";
import { useBulkDocumentMenuAction } from "~/hooks/useBulkDocumentMenuAction";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import { ActionVariant } from "~/types";
import NudeButton from "./NudeButton";
import { CrossIcon } from "outline-icons";
function BulkSelectionToolbar() {
const { t } = useTranslation();
const { documents, ui } = useStores();
const selectedCount = documents.selectedDocumentIds.length;
const selectedDocuments = documents.selectedDocuments;
const sidebarWidth = ui.sidebarWidth;
const handleClearSelection = React.useCallback(() => {
documents.clearSelection();
}, [documents]);
const rootAction = useBulkDocumentMenuAction({
documents: selectedDocuments,
});
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = React.useMemo(() => {
if (!rootAction.children || selectedCount === 0) {
return [];
}
return (rootAction.children as ActionVariant[]).map((childAction) =>
actionToMenuItem(childAction, actionContext)
);
}, [rootAction.children, selectedCount, actionContext]);
const content = toMobileMenuItems(menuItems, handleClearSelection, () => {});
if (selectedCount === 0) {
return null;
}
return (
<Portal>
<Wrapper $sidebarWidth={sidebarWidth}>
<MenuContainer>
<Header>
<MenuHeader>
{t("{{ count }} selected", { count: selectedCount })}
</MenuHeader>
<ClearButton
onClick={handleClearSelection}
tooltip={{
content: t("Clear selection"),
}}
>
<CrossIcon size={18} />
</ClearButton>
</Header>
<MenuSeparator />
{content}
</MenuContainer>
</Wrapper>
</Portal>
);
}
const ClearButton = styled(NudeButton)`
&:hover {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const Wrapper = styled.div<{ $sidebarWidth: number }>`
position: fixed;
bottom: 24px;
left: ${(props) => props.$sidebarWidth + 16}px;
z-index: ${depths.menu};
`;
const MenuContainer = styled.div`
min-width: 180px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
`;
export default observer(BulkSelectionToolbar);
-4
View File
@@ -34,7 +34,6 @@ const RealButton = styled(ActionButton)<RealProps>`
cursor: var(--pointer);
user-select: none;
appearance: none !important;
transition: background 200ms ease-out;
${undraggableOnDesktop()}
&::-moz-focus-inner {
@@ -45,7 +44,6 @@ const RealButton = styled(ActionButton)<RealProps>`
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.accent)};
transition: background 0s;
}
&:disabled {
@@ -80,7 +78,6 @@ const RealButton = styled(ActionButton)<RealProps>`
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
transition: background 0s;
}
&:focus-visible {
@@ -106,7 +103,6 @@ const RealButton = styled(ActionButton)<RealProps>`
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${darken(0.05, props.theme.danger)};
transition: background 0s;
}
&:disabled {
+1 -7
View File
@@ -67,13 +67,7 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = useIconColor(collection);
const fallbackIcon = (
<Icon
value="collection"
initial={collection?.initial ?? "?"}
color={iconColor}
/>
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
register,
+3 -3
View File
@@ -5,7 +5,7 @@ import Collection from "~/models/Collection";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
import { createInternalLinkAction } from "~/actions";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveCollectionSection } from "~/actions/sections";
type Props = {
@@ -17,14 +17,14 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const actions = React.useMemo(
() => [
createInternalLinkAction({
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: collection.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
createInternalLinkActionV2({
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
-63
View File
@@ -1,63 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import NudeButton from "./NudeButton";
import { hover, s } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** The current color value in hex format. If no color is passed a radial gradient will be shown */
color?: string;
/** Whether the button is currently active/selected */
active?: boolean;
/** The size of the button in pixels */
size?: number;
};
export const ColorButton = React.forwardRef(
(
{ color, active = false, size = 24, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) => (
<ColorButtonInternal
$active={active}
$size={size}
{...rest}
style={{ "--color": color, ...rest.style } as React.CSSProperties}
ref={ref}
>
<Selected />
</ColorButtonInternal>
)
);
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButtonInternal = styled(NudeButton)<{
$active: boolean;
$size: number;
}>`
display: inline-flex;
justify-content: center;
align-items: center;
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
border-radius: 50%;
background: var(
--color,
linear-gradient(135deg, #ff5858 0%, #fbcc34 50%, #00c6ff 100%)
);
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: 0px 0px 3px 3px var(--color);
}
& ${Selected} {
display: ${({ $active }) => ($active ? "block" : "none")};
}
`;
@@ -1,7 +1,7 @@
import { DocumentIcon } from "outline-icons";
import { useMemo } from "react";
import Icon from "@shared/components/Icon";
import { createInternalLinkAction } from "~/actions";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
@@ -15,16 +15,12 @@ const useRecentDocumentActions = (count = 6) => {
.filter((document) => document.id !== ui.activeDocumentId)
.slice(0, count)
.map((item) =>
createInternalLinkAction({
createAction({
name: item.titleWithDefault,
analyticsName: "Recently viewed document",
section: RecentSection,
icon: item.icon ? (
<Icon
value={item.icon}
initial={item.initial}
color={item.color ?? undefined}
/>
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
@@ -1,6 +1,6 @@
import { SettingsIcon } from "outline-icons";
import { useMemo } from "react";
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useSettingsConfig from "~/hooks/useSettingsConfig";
@@ -10,20 +10,20 @@ const useSettingsAction = () => {
() =>
config.map((item) => {
const Icon = item.icon;
return createInternalLinkAction({
return {
id: item.path,
name: item.name,
icon: <Icon />,
section: NavigationSection,
to: item.path,
});
};
}),
[config]
);
const navigateToSettings = useMemo(
() =>
createActionWithChildren({
createAction({
id: "settings",
name: ({ t }) => t("Settings"),
section: NavigationSection,
@@ -1,13 +1,14 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import { useEffect, useMemo } from "react";
import Icon from "@shared/components/Icon";
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
DocumentSection,
TeamSection,
} from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
@@ -20,18 +21,14 @@ const useTemplatesAction = () => {
const actions = useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
createInternalLinkAction({
createAction({
name: template.titleWithDefault,
analyticsName: "New document",
section: template.isWorkspaceTemplate
? TeamSection
: ActiveCollectionSection,
icon: template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<NewDocumentIcon />
),
@@ -50,20 +47,15 @@ const useTemplatesAction = () => {
template.isWorkspaceTemplate
);
},
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(
template.collectionId ?? activeCollectionId,
{
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(
newDocumentPath(template.collectionId ?? activeCollectionId, {
templateId: template.id,
}),
{
sidebarContext,
}
).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
),
})
),
[documents.templatesAlphabetical]
@@ -71,7 +63,7 @@ const useTemplatesAction = () => {
const newFromTemplate = useMemo(
() =>
createActionWithChildren({
createAction({
id: "templates",
name: ({ t }) => t("New from template"),
placeholder: ({ t }) => t("Choose a template"),
@@ -86,7 +78,7 @@ const useTemplatesAction = () => {
stores.policies.abilities(currentTeamId).createDocument
);
},
children: actions,
children: () => actions,
}),
[actions]
);
+8 -13
View File
@@ -12,7 +12,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkAction } from "~/actions";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
type Props = {
@@ -53,28 +53,28 @@ function DocumentBreadcrumb(
}
const outputActions = [
createInternalLinkAction({
createInternalLinkActionV2({
name: t("Trash"),
section: ActiveDocumentSection,
icon: <TrashIcon />,
visible: document.isDeleted,
to: trashPath(),
}),
createInternalLinkAction({
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
createInternalLinkActionV2({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkAction({
createInternalLinkActionV2({
name: collection?.name,
section: ActiveDocumentSection,
icon: collection ? (
@@ -88,7 +88,7 @@ function DocumentBreadcrumb(
}
: "",
}),
createInternalLinkAction({
createInternalLinkActionV2({
name: t("Deleted Collection"),
section: ActiveDocumentSection,
visible: document.isCollectionDeleted,
@@ -96,15 +96,10 @@ function DocumentBreadcrumb(
}),
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkAction({
return createInternalLinkActionV2({
name: node.icon ? (
<>
<StyledIcon
value={node.icon}
color={node.color}
initial={node.title.charAt(0).toUpperCase()}
/>{" "}
{title}
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
title
+2 -2
View File
@@ -187,12 +187,12 @@ function DocumentCard(props: Props) {
const DocumentSquircle = ({
icon,
initial,
color,
initial,
}: {
icon: string;
initial: string;
color?: string;
initial?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
+2 -4
View File
@@ -18,7 +18,6 @@ import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
@@ -28,6 +27,7 @@ import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { ancestors, descendants, flattenTree } from "~/utils/tree";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
@@ -268,9 +268,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
title = doc?.title ?? node.title;
if (icon) {
renderedIcon = (
<Icon value={icon} initial={node.title} color={color} />
);
renderedIcon = <Icon value={icon} color={color} />;
} else if (doc?.isStarred) {
renderedIcon = <StarredIcon color={theme.yellow} />;
} else {
+3 -4
View File
@@ -7,7 +7,6 @@ import Document from "~/models/Document";
import CircularProgressBar from "~/components/CircularProgressBar";
import usePrevious from "~/hooks/usePrevious";
import { bounceIn } from "~/styles/animations";
import Flex from "./Flex";
type Props = {
document: Document;
@@ -42,7 +41,7 @@ function DocumentTasks({ document }: Props) {
const message = getMessage(t, total, completed);
return (
<Flex align="center" style={{ padding: "0 1px" }} gap={2}>
<>
{completed === total ? (
<Done
color={theme.accent}
@@ -52,8 +51,8 @@ function DocumentTasks({ document }: Props) {
) : (
<CircularProgressBar percentage={tasksPercentage} />
)}
{message}
</Flex>
&nbsp;{message}
</>
);
}
+20 -40
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { s, truncateMultiline } from "@shared/styles";
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
@@ -42,47 +42,27 @@ function EditableTitle(
setValue(title);
}, [title]);
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
},
[]
);
const handleChange = React.useCallback((event) => {
setValue(event.target.value);
}, []);
const handleDoubleClick = React.useCallback(
(event: React.MouseEvent<HTMLSpanElement>) => {
if (event.altKey) {
return;
}
event.preventDefault();
event.stopPropagation();
setIsEditing(true);
},
[]
);
const handleDoubleClick = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
setIsEditing(true);
}, []);
const stopPropagation = React.useCallback(
(event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
},
[]
);
const stopPropagation = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
}, []);
const handleFocus = React.useCallback(
(event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
},
[]
);
const handleFocus = React.useCallback((event) => {
event.target.select();
}, []);
const handleSave = React.useCallback(
async (
ev:
| React.FocusEvent<HTMLInputElement>
| React.KeyboardEvent<HTMLInputElement>
| React.FormEvent<HTMLFormElement>
) => {
async (ev) => {
ev.preventDefault();
ev.stopPropagation();
@@ -118,7 +98,7 @@ function EditableTitle(
);
const handleKeyDown = React.useCallback(
async (ev: React.KeyboardEvent<HTMLInputElement>) => {
async (ev) => {
if (ev.nativeEvent.isComposing) {
return;
}
@@ -168,8 +148,8 @@ function EditableTitle(
);
}
const Text = styled.div`
${ellipsis()}
const Text = styled.span`
${truncateMultiline(3)}
`;
const Input = styled.input`
+3 -4
View File
@@ -21,7 +21,6 @@ import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
import useShare from "@shared/hooks/useShare";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -34,6 +33,7 @@ export type Props = Optional<
| "dictionary"
| "extensions"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => void;
@@ -41,9 +41,9 @@ export type Props = Optional<
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, onChange, onCreateCommentMark, onDeleteCommentMark } = props;
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const { comments } = useStores();
const { shareId } = useShare();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
@@ -202,7 +202,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
style={props.style}
editorStyle={props.editorStyle}
commenting={!!props.onClickCommentMark}
lang={props.lang}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
-227
View File
@@ -1,227 +0,0 @@
import * as React from "react";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { getDataTransferFiles } from "@shared/utils/files";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { compressImage } from "~/utils/compressImage";
import { generateEmojiNameFromFilename } from "~/utils/emoji";
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
import { bytesToHumanReadable } from "@shared/utils/files";
type Props = {
onSubmit: () => void;
};
export function EmojiCreateDialog({ onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [name, setName] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const handleFileSelection = React.useCallback(
(file: File) => {
const isValidType = AttachmentValidation.emojiContentTypes.includes(
file.type
);
if (!isValidType) {
toast.error(
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
);
return;
}
// Validate file size
if (file.size > AttachmentValidation.emojiMaxFileSize) {
toast.error(
t("File size too large. Maximum size is {{ size }}.", {
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
})
);
return;
}
setFile(file);
// Auto-populate name field if it's empty
setName((currentName) => {
if (!currentName.trim()) {
const generatedName = generateEmojiNameFromFilename(file.name);
return generatedName || currentName;
}
return currentName;
});
},
[t]
);
const onDrop = React.useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
handleFileSelection(acceptedFiles[0]);
}
},
[handleFileSelection]
);
// Handle paste events
React.useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const files = getDataTransferFiles(event);
if (files.length > 0) {
event.preventDefault();
handleFileSelection(files[0]);
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [handleFileSelection]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDropAccepted: onDrop,
accept: AttachmentValidation.emojiContentTypes,
maxSize: AttachmentValidation.emojiMaxFileSize,
maxFiles: 1,
});
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("Please enter a name for the emoji"));
return;
}
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
const compressed = await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
await emojis.create({
name: name.trim(),
attachmentId: attachment.id,
});
toast.success(t("Emoji created successfully"));
onSubmit();
} finally {
setIsUploading(false);
}
};
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
)}
</Text>
<Input
label={t("Name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<Flex column align="center" gap={8}>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="medium">
{isDragActive
? t("Drop the image here")
: t("Click, drop, or paste an image here")}
</Text>
<Text size="medium" type="secondary">
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
size: bytesToHumanReadable(
AttachmentValidation.emojiMaxFileSize
),
})}
</Text>
</>
)}
</Flex>
</DropZone>
{name.trim() && isValidName && (
<Text type="secondary" style={{ marginTop: "8px" }}>
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
)}
</ConfirmationDialog>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("inputBorder")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
&:hover {
border-color: ${s("inputBorderFocused")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;
+2 -4
View File
@@ -46,22 +46,20 @@ class ErrorBoundary extends React.Component<Props> {
componentDidCatch(error: Error) {
this.error = error;
this.trackError();
if (
this.props.reloadOnChunkMissing &&
error.message &&
error.message.match(/dynamically imported module/) &&
!this.isRepeatedError
error.message.match(/dynamically imported module/)
) {
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
// Don't reload if this is a repeated error to avoid infinite reload loops.
window.location.reload();
return;
}
this.trackError();
Logger.error("ErrorBoundary", error);
}
+13 -15
View File
@@ -156,21 +156,19 @@ function ExportDialog({ collection, onSubmit }: Props) {
</Text>{" "}
</div>
</Option>
{!collection && (
<Option>
<input
type="checkbox"
name="includePrivate"
checked={includePrivate}
onChange={handleIncludePrivateChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{t("Include private collections")}
</Text>
</div>
</Option>
)}
<Option>
<input
type="checkbox"
name="includePrivate"
checked={includePrivate}
onChange={handleIncludePrivateChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{t("Include private collections")}
</Text>
</div>
</Option>
</Flex>
</ConfirmationDialog>
);
+1 -4
View File
@@ -6,7 +6,6 @@ import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { s } from "@shared/styles";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
type Props = {
/** The users to display */
@@ -69,9 +68,7 @@ function Facepile({
/>
);
})}
<VisuallyHidden>
<FacepileClip size={size} />
</VisuallyHidden>
<FacepileClip size={size} />
</Avatars>
);
}
+24 -48
View File
@@ -10,7 +10,6 @@ import Input, { NativeInput, Outline } from "./Input";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
import { MenuIconWrapper } from "./primitives/components/Menu";
interface TFilterOption extends PaginatedItem {
key: string;
@@ -56,11 +55,7 @@ const FilterOptions = ({
(option) => (
<MenuButton
key={option.key}
icon={
option.icon ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined
}
icon={option.icon}
label={option.label}
onClick={() => {
onSelect(option.key);
@@ -82,45 +77,30 @@ const FilterOptions = ({
const filteredOptions = React.useMemo(() => {
const normalizedQuery = deburr(query.toLowerCase());
const filtered = query
? options.filter((option) =>
deburr(option.label).toLowerCase().includes(normalizedQuery)
)
return query
? options
.filter((option) =>
deburr(option.label).toLowerCase().includes(normalizedQuery)
)
// sort options starting with query first
.sort((a, b) => {
const aStartsWith = deburr(a.label)
.toLowerCase()
.startsWith(normalizedQuery);
const bStartsWith = deburr(b.label)
.toLowerCase()
.startsWith(normalizedQuery);
if (aStartsWith && !bStartsWith) {
return -1;
}
if (!aStartsWith && bStartsWith) {
return 1;
}
return 0;
})
: options;
return filtered.sort((a, b) => {
const aSelected = selectedKeys.includes(a.key);
const bSelected = selectedKeys.includes(b.key);
// Selected items come first
if (aSelected && !bSelected) {
return -1;
}
if (!aSelected && bSelected) {
return 1;
}
// If both have the same selection state and there's a query,
// sort options starting with query first
if (query) {
const aStartsWith = deburr(a.label)
.toLowerCase()
.startsWith(normalizedQuery);
const bStartsWith = deburr(b.label)
.toLowerCase()
.startsWith(normalizedQuery);
if (aStartsWith && !bStartsWith) {
return -1;
}
if (!aStartsWith && bStartsWith) {
return 1;
}
}
return 0;
});
}, [options, query, selectedKeys]);
}, [options, query]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
@@ -128,10 +108,6 @@ const FilterOptions = ({
return;
}
// Stop all keyboard events from propagating to prevent Radix UI menu
// from handling them and potentially moving focus
ev.stopPropagation();
switch (ev.key) {
case "Escape":
setOpen(false);
+1 -21
View File
@@ -117,31 +117,12 @@ const HoverPreviewDesktop = observer(
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{
opacity: 0,
y: -20,
filter: "blur(5px)",
pointerEvents: "none",
}}
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
filter: "blur(0px)",
transitionEnd: { pointerEvents: "auto" },
}}
transition={{
y: {
type: "spring",
stiffness: 400,
damping: 25,
},
opacity: {
duration: 0.2,
},
filter: {
duration: 0.2,
},
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
@@ -156,7 +137,6 @@ const HoverPreviewDesktop = observer(
<HoverPreviewGroup
ref={cardRef}
name={data.name}
description={data.description}
memberCount={data.memberCount}
users={data.users}
/>
@@ -1,5 +1,4 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import User from "~/models/User";
@@ -18,30 +17,21 @@ import ErrorBoundary from "../ErrorBoundary";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
{ name, description, memberCount, users }: Props,
{ name, memberCount, users }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="div">
<Card fadeOut={false} ref={ref}>
<CardContent>
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Flex column gap={2} align="start">
<Flex
justify="space-between"
gap={4}
style={{ width: "100%" }}
auto
>
<Flex column align="start">
<Title>{name}</Title>
<Info>
{t("{{ count }} members", { count: memberCount })}
</Info>
</Flex>
{users.length > 0 && (
<Title>{name}</Title>
<Info>
{memberCount === 1 ? "1 member" : `${memberCount} members`}
</Info>
{users.length > 0 && (
<Description>
<Facepile
users={users.map(
(member) =>
@@ -56,9 +46,8 @@ const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
overflow={Math.max(0, memberCount - users.length)}
limit={MAX_AVATAR_DISPLAY}
/>
)}
</Flex>
{description && <Description>{description}</Description>}
</Description>
)}
</Flex>
</ErrorBoundary>
</CardContent>
@@ -1,11 +1,17 @@
import { BackIcon } from "outline-icons";
import * as React from "react";
import debounce from "lodash/debounce";
import styled from "styled-components";
import { s } from "@shared/styles";
import { breakpoints, s, hover } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import { validateColorHex } from "@shared/utils/color";
import Flex from "~/components/Flex";
import { SwatchButton } from "~/components/SwatchButton";
import { ColorButton } from "~/components/ColorButton";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
enum Panel {
Builtin,
Hex,
}
type Props = {
width: number;
@@ -13,82 +19,200 @@ type Props = {
onSelect: (color: string) => void;
};
const ColorPicker = ({ activeColor, onSelect }: Props) => {
const [selectedColor, setSelectedColor] = React.useState(activeColor);
const isBuiltInColor = colorPalette.includes(selectedColor);
const color = isBuiltInColor ? undefined : selectedColor;
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
const [localValue, setLocalValue] = React.useState(activeColor);
const debouncedOnSelect = React.useMemo(
() =>
debounce((color: string) => {
onSelect(color);
}, 250),
[onSelect]
const [panel, setPanel] = React.useState(
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
);
React.useEffect(
() => () => {
debouncedOnSelect.cancel();
},
[debouncedOnSelect]
);
const handleSwitcherClick = React.useCallback(() => {
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
}, [panel, setPanel]);
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
React.useEffect(() => {
setSelectedColor(activeColor);
setLocalValue(activeColor);
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
}, [activeColor]);
const handleSelect = (color: string) => {
setSelectedColor(color);
debouncedOnSelect(color);
};
return (
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
<Divider />
<SwatchButton
color={color}
active={!isBuiltInColor}
onChange={handleSelect}
pickerInModal
return isLargeMobile ? (
<Container justify="space-between">
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
<LargeMobileCustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
</BuiltinColors>
</Container>
) : (
<Container gap={12}>
<PanelSwitcher align="center">
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
{panel === Panel.Builtin ? "#" : <BackIcon />}
</SwitcherButton>
</PanelSwitcher>
{panel === Panel.Builtin ? (
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
) : (
<CustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
)}
</Container>
);
};
const Divider = styled.div`
width: 1px;
height: 24px;
background-color: ${s("inputBorder")};
`;
const BuiltinColors = ({
activeColor,
onClick,
className,
children,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
children?: React.ReactNode;
}) => (
<Container className={className} justify="space-between" align="center" auto>
<Flex className={className} justify="space-between" align="center" auto>
{colorPalette.map((color) => (
<ColorButton
key={color}
color={color}
active={color === activeColor}
$color={color}
$active={color === activeColor}
onClick={() => onClick(color)}
/>
>
<Selected />
</ColorButton>
))}
{children}
</Container>
</Flex>
);
const CustomColor = ({
value,
setLocalValue,
onValidHex,
className,
}: {
value: string;
setLocalValue: (value: string) => void;
onValidHex: (color: string) => void;
className?: string;
}) => {
const hasHexChars = React.useCallback(
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
[]
);
const handleInputChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const val = ev.target.value;
if (val === "" || val === "#") {
setLocalValue("#");
return;
}
const uppercasedVal = val.toUpperCase();
if (hasHexChars(uppercasedVal)) {
setLocalValue(uppercasedVal);
}
if (validateColorHex(uppercasedVal)) {
onValidHex(uppercasedVal);
}
},
[setLocalValue, hasHexChars, onValidHex]
);
return (
<Flex className={className} align="center" gap={8}>
<Text type="tertiary" size="small">
HEX
</Text>
<CustomColorInput
maxLength={7}
value={value}
onChange={handleInputChange}
autoFocus
/>
</Flex>
);
};
const Container = styled(Flex)`
height: 48px;
padding: 8px 12px;
border-bottom: 1px solid ${s("inputBorder")};
`;
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButton = styled(NudeButton)<{ $color: string; $active: boolean }>`
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: ${({ $color }) => $color};
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: ${({ $color }) => `0px 0px 3px 3px ${$color}`};
}
& ${Selected} {
display: ${({ $active }) => ($active ? "block" : "none")};
}
`;
const PanelSwitcher = styled(Flex)`
width: 40px;
border-right: 1px solid ${s("inputBorder")};
`;
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 14px;
border: 1px solid ${s("inputBorder")};
transition: all 100ms ease-in-out;
&: ${hover} {
border-color: ${s("inputBorderFocused")};
}
`;
const LargeMobileBuiltinColors = styled(BuiltinColors)`
max-width: 400px;
padding-right: 8px;
`;
const LargeMobileCustomColor = styled(CustomColor)`
padding-left: 8px;
border-left: 1px solid ${s("inputBorder")};
width: 120px;
`;
const CustomColorInput = styled.input.attrs(() => ({
type: "text",
autocomplete: "off",
}))`
font-size: 14px;
color: ${s("textSecondary")};
background: transparent;
border: 0;
outline: 0;
`;
export default ColorPicker;
@@ -1,12 +0,0 @@
import Flex from "@shared/components/Flex";
import styled from "styled-components";
import InputSearch from "~/components/InputSearch";
export const UserInputContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
export const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
@@ -1,170 +0,0 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Flex from "~/components/Flex";
import useStores from "~/hooks/useStores";
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
import { IconType } from "@shared/types";
import { DisplayCategory } from "../utils";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
import Emoji from "~/models/Emoji";
const GRID_HEIGHT = 410;
type Props = {
panelWidth: number;
height?: number;
query: string;
panelActive: boolean;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
const CustomEmojiPanel = ({
query,
panelActive,
panelWidth,
height = GRID_HEIGHT,
onEmojiChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const [searchData, setSearchData] = useState<DataNode[]>([]);
const [freqEmojis, setFreqEmojis] = useState<EmojiNode[]>([]);
const { getFrequentIcons, incrementIconCount } = useIconState(
IconType.Custom
);
const { emojis } = useStores();
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
useEffect(() => {
if (query.trim()) {
const initialData = emojis.findByQuery(query);
if (initialData.length) {
setSearchData([
{
category: DisplayCategory.Search,
icons: initialData?.map(toIcon),
},
]);
}
emojis
.fetchAll({
query,
})
.then((data) => {
if (data.length) {
const iconMap = new Map([
...initialData.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
...data.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
]);
setSearchData([
{
category: DisplayCategory.Search,
icons: Array.from(iconMap.values()),
},
]);
return;
}
setSearchData([]);
});
} else {
setSearchData([]);
}
}, [query, emojis]);
useEffect(() => {
getFrequentIcons().forEach((id) => {
emojis
.fetch(id)
.then((emoji) => {
setFreqEmojis((prev) => {
if (prev.some((item) => item.id === id)) {
return prev;
}
return [...prev, toIcon(emoji)];
});
})
.catch(() => {
// ignore
});
});
}, [getFrequentIcons, emojis]);
const handleEmojiSelection = React.useCallback(
({ id }: { id: string }) => {
onEmojiChange(id);
incrementIconCount(id);
},
[onEmojiChange, incrementIconCount]
);
const templateData: DataNode[] = React.useMemo(
() => [
{
category: DisplayCategory.Frequent,
icons: freqEmojis,
},
{
category: DisplayCategory.All,
icons: emojis.orderedData.map(toIcon),
},
],
[emojis.orderedData, freqEmojis]
);
React.useLayoutEffect(() => {
if (!panelActive) {
return;
}
scrollableRef.current?.scroll({ top: 0 });
requestAnimationFrame(() => searchRef.current?.focus());
}, [panelActive]);
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search")}`}
onChange={handleFilter}
/>
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={height - 48}
data={searchData.length ? searchData : templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const toIcon = (emoji: Emoji): EmojiNode => ({
type: IconType.Custom,
id: emoji.id,
value: emoji.id,
name: emoji.name,
});
export default CustomEmojiPanel;
@@ -1,17 +1,77 @@
import concat from "lodash/concat";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import { DisplayCategory } from "../utils";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
emojiSkinToneKey,
emojisFreqKey,
lastEmojiKey,
sortFrequencies,
} from "../utils";
import GridTemplate, { DataNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
const GRID_HEIGHT = 410;
const useEmojiState = () => {
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
emojiSkinToneKey,
EmojiSkinTone.Default
);
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
emojisFreqKey,
{}
);
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
lastEmojiKey,
undefined
);
const incrementEmojiCount = React.useCallback(
(emoji: string) => {
emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
setEmojisFreq({ ...emojisFreq });
setLastEmoji(emoji);
},
[emojisFreq, setEmojisFreq, setLastEmoji]
);
const getFreqEmojis = React.useCallback(() => {
const freqs = Object.entries(emojisFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setEmojisFreq(Object.fromEntries(freqs));
}
const emojis = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([emoji, _]) => emoji);
const isLastPresent = emojis.includes(lastEmoji ?? "");
if (lastEmoji && !isLastPresent) {
emojis.pop();
emojis.push(lastEmoji);
}
return emojis;
}, [emojisFreq, setEmojisFreq, lastEmoji]);
return {
emojiSkinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
};
};
type Props = {
panelWidth: number;
query: string;
@@ -37,14 +97,11 @@ const EmojiPanel = ({
const {
emojiSkinTone: skinTone,
setEmojiSkinTone,
incrementIconCount,
getFrequentIcons,
} = useIconState(IconType.Emoji);
incrementEmojiCount,
getFreqEmojis,
} = useEmojiState();
const freqEmojis = React.useMemo(
() => getFrequentIcons(),
[getFrequentIcons]
);
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
@@ -63,9 +120,9 @@ const EmojiPanel = ({
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
incrementIconCount(id);
incrementEmojiCount(id);
},
[onEmojiChange, incrementIconCount]
[onEmojiChange, incrementEmojiCount]
);
const isSearch = query !== "";
@@ -138,7 +195,7 @@ const getAllEmojis = ({
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
const getFrequentIcons = (): DataNode => {
const getFrequentEmojis = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
return {
category: DisplayCategory.Frequent,
@@ -163,7 +220,7 @@ const getAllEmojis = ({
};
return concat(
getFrequentIcons(),
getFrequentEmojis(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
getCategoryData(EmojiCategory.Foods),
@@ -175,4 +232,13 @@ const getAllEmojis = ({
);
};
const UserInputContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default EmojiPanel;
@@ -9,7 +9,6 @@ import Text from "~/components/Text";
import { TRANSLATED_CATEGORIES } from "../utils";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
import { CustomEmoji } from "@shared/components/CustomEmoji";
/**
* icon/emoji size is 24px; and we add 4px padding on all sides,
@@ -24,11 +23,10 @@ type OutlineNode = {
delay: number;
};
export type EmojiNode = {
type: IconType.Emoji | IconType.Custom;
type EmojiNode = {
type: IconType.Emoji;
id: string;
value: string;
name?: string;
};
export type DataNode = {
@@ -88,11 +86,7 @@ const GridTemplate = (
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji width={24} height={24}>
{item.type === IconType.Custom ? (
<CustomEmoji value={item.value} title={item.name} />
) : (
item.value
)}
{item.value}
</Emoji>
</IconButton>
);
@@ -5,10 +5,16 @@ import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import { DisplayCategory } from "../utils";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
iconsFreqKey,
lastIconKey,
sortFrequencies,
} from "../utils";
import ColorPicker from "./ColorPicker";
import GridTemplate, { DataNode } from "./GridTemplate";
import { useIconState } from "../useIconState";
const IconNames = Object.keys(IconLibrary.mapping);
const TotalIcons = IconNames.length;
@@ -19,6 +25,52 @@ const TotalIcons = IconNames.length;
*/
const GRID_HEIGHT = 314;
const useIconState = () => {
const [iconsFreq, setIconsFreq] = usePersistedState<Record<string, number>>(
iconsFreqKey,
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
lastIconKey,
undefined
);
const incrementIconCount = React.useCallback(
(icon: string) => {
iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
setIconsFreq({ ...iconsFreq });
setLastIcon(icon);
},
[iconsFreq, setIconsFreq, setLastIcon]
);
const getFreqIcons = React.useCallback(() => {
const freqs = Object.entries(iconsFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setIconsFreq(Object.fromEntries(freqs));
}
const icons = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([icon, _]) => icon);
const isLastPresent = icons.includes(lastIcon ?? "");
if (lastIcon && !isLastPresent) {
icons.pop();
icons.push(lastIcon);
}
return icons;
}, [iconsFreq, setIconsFreq, lastIcon]);
return {
incrementIconCount,
getFreqIcons,
};
};
type Props = {
panelWidth: number;
initial: string;
@@ -45,9 +97,9 @@ const IconPanel = ({
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const { incrementIconCount, getFrequentIcons } = useIconState(IconType.SVG);
const { incrementIconCount, getFreqIcons } = useIconState();
const freqIcons = React.useMemo(() => getFrequentIcons(), [getFrequentIcons]);
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
const totalFreqIcons = freqIcons.length;
const filteredIcons = React.useMemo(
+2 -27
View File
@@ -21,13 +21,10 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
import CustomEmojiPanel from "./components/CustomEmojiPanel";
import useStores from "~/hooks/useStores";
const TAB_NAMES = {
Icon: "icon",
Emoji: "emoji",
Custom: "custom",
} as const;
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
@@ -38,7 +35,7 @@ type Props = {
icon: string | null;
color: string;
size?: number;
initial: string;
initial?: string;
className?: string;
popoverPosition: "bottom-start" | "right";
allowDelete?: boolean;
@@ -64,7 +61,7 @@ const IconPicker = ({
children,
}: Props) => {
const { t } = useTranslation();
const { emojis } = useStores();
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
@@ -171,12 +168,6 @@ const IconPicker = ({
setActiveTab(defaultTab);
}, [defaultTab]);
React.useEffect(() => {
if (open) {
void emojis.fetchAll();
}
}, [open]);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
@@ -254,13 +245,6 @@ const Content = ({
>
{t("Emojis")}
</StyledTab>
<StyledTab
value={TAB_NAMES["Custom"]}
aria-label={t("Custom Emojis")}
$active={activeTab === TAB_NAMES["Custom"]}
>
{t("Custom")}
</StyledTab>
</Tabs.List>
{allowDelete && (
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
@@ -287,15 +271,6 @@ const Content = ({
onQueryChange={onQueryChange}
/>
</StyledTabContent>
<StyledTabContent value={TAB_NAMES["Custom"]}>
<CustomEmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={open && activeTab === TAB_NAMES["Custom"]}
onEmojiChange={onIconChange}
onQueryChange={onQueryChange}
/>
</StyledTabContent>
</Tabs.Root>
);
};
@@ -1,89 +0,0 @@
import {
customEmojisFreqKey,
emojisFreqKey,
emojiSkinToneKey,
FREQUENTLY_USED_COUNT,
iconsFreqKey,
lastCustomEmojiKey,
lastEmojiKey,
lastIconKey,
sortFrequencies,
} from "./utils";
import usePersistedState from "~/hooks/usePersistedState";
import { EmojiSkinTone, IconType } from "@shared/types";
import React from "react";
const lastIconKeys = {
[IconType.Custom]: lastCustomEmojiKey,
[IconType.Emoji]: lastEmojiKey,
[IconType.SVG]: lastIconKey,
};
const freqIconKeys = {
[IconType.Custom]: customEmojisFreqKey,
[IconType.Emoji]: emojisFreqKey,
[IconType.SVG]: iconsFreqKey,
};
const skinToneKeys = {
[IconType.Custom]: "",
[IconType.Emoji]: emojiSkinToneKey,
[IconType.SVG]: "",
};
export const useIconState = (type: IconType) => {
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
skinToneKeys[type],
EmojiSkinTone.Default
);
const [iconFreq, setIconFreq] = usePersistedState<Record<string, number>>(
freqIconKeys[type],
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
lastIconKeys[type],
undefined
);
const incrementIconCount = React.useCallback(
(emoji: string) => {
iconFreq[emoji] = (iconFreq[emoji] ?? 0) + 1;
setIconFreq({ ...iconFreq });
setLastIcon(emoji);
},
[iconFreq, setIconFreq, setLastIcon]
);
const getFrequentIcons = React.useCallback((): string[] => {
const freqs = Object.entries(iconFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
const trimmed = sortFrequencies(freqs).slice(
0,
FREQUENTLY_USED_COUNT.Track
);
setIconFreq(Object.fromEntries(trimmed));
}
const emojis = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([emoji, _]) => emoji);
const isLastPresent = emojis.includes(lastIcon ?? "");
if (lastIcon && !isLastPresent) {
emojis.pop();
emojis.push(lastIcon);
}
return emojis;
}, [iconFreq, lastIcon, setIconFreq]);
return {
emojiSkinTone,
setEmojiSkinTone,
incrementIconCount,
getFrequentIcons,
};
};
-9
View File
@@ -18,7 +18,6 @@ export const TRANSLATED_CATEGORIES = {
Objects: i18next.t("Objects"),
Symbols: i18next.t("Symbols"),
Flags: i18next.t("Flags"),
Custom: i18next.t("Custom"),
};
export const FREQUENTLY_USED_COUNT = {
@@ -33,8 +32,6 @@ const STORAGE_KEYS = {
EmojisFrequency: "emojis-freq",
LastIcon: "last-icon",
LastEmoji: "last-emoji",
CustomEmojisFrequency: "custom-emojis-freq",
LastCustomEmoji: "last-custom-emoji",
};
const getStorageKey = (key: string) => `${STORAGE_KEYS.Base}.${key}`;
@@ -49,11 +46,5 @@ export const lastIconKey = getStorageKey(STORAGE_KEYS.LastIcon);
export const lastEmojiKey = getStorageKey(STORAGE_KEYS.LastEmoji);
export const customEmojisFreqKey = getStorageKey(
STORAGE_KEYS.CustomEmojisFrequency
);
export const lastCustomEmojiKey = getStorageKey(STORAGE_KEYS.LastCustomEmoji);
export const sortFrequencies = (freqs: [string, number][]) =>
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));
+69 -25
View File
@@ -1,41 +1,85 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DelayedMount from "./DelayedMount";
import Input, { Props as InputProps } from "./Input";
import NudeButton from "./NudeButton";
import Relative from "./Sidebar/components/Relative";
import { SwatchButton } from "./SwatchButton";
import Text from "./Text";
import { Popover, PopoverContent, PopoverTrigger } from "./primitives/Popover";
/**
* Props for the InputColor component.
*/
type Props = Omit<InputProps, "onChange"> & {
/** The current color value in hex format */
value: string | undefined;
/** Callback function invoked when the color value changes */
onChange: (value: string) => void;
};
/**
* A color input component that combines a text input with a color picker swatch button.
* Automatically formats hex color values with a leading # character.
*/
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => (
<Relative>
<Input
value={value}
onChange={(event) => onChange(event.target.value.replace(/^#?/, "#"))}
placeholder="#"
maxLength={7}
{...rest}
/>
<PositionedSwatchButton color={value} onChange={onChange} size={22} />
</Relative>
);
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
const { t } = useTranslation();
const PositionedSwatchButton = styled(SwatchButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
return (
<Relative>
<Input
value={value}
onChange={(event) => onChange(event.target.value.replace(/^#?/, "#"))}
placeholder="#"
maxLength={7}
{...rest}
/>
<Popover modal={true}>
<PopoverTrigger>
<SwatchButton aria-label={t("Show menu")} $background={value} />
</PopoverTrigger>
<StyledContent aria-label={t("Select a color")} align="end">
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={value}
onChange={(color) => onChange(color.hex)}
/>
</React.Suspense>
</StyledContent>
</Popover>
</Relative>
);
};
const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
background: ${(props) => props.$background};
border: 1px solid ${s("inputBorder")};
border-radius: 50%;
position: absolute;
bottom: 21px;
bottom: 20px;
right: 6px;
`;
const StyledContent = styled(PopoverContent)`
width: auto;
padding: 8px;
`;
const ColorPicker = lazyWithRetry(
() => import("react-color/lib/components/chrome/Chrome")
);
const StyledColorPicker = styled(ColorPicker)`
background: inherit !important;
box-shadow: none !important;
border: 0 !important;
border-radius: 0 !important;
user-select: none;
input {
user-select: text;
color: ${s("text")} !important;
}
`;
export default InputColor;
+1 -1
View File
@@ -50,7 +50,7 @@ export type Item = {
export type Option = Item | Separator;
type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
type Props = {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
+11 -4
View File
@@ -4,21 +4,20 @@ import { Helmet } from "react-helmet-async";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { isModKey } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import SkipNavContent from "~/components/SkipNavContent";
import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
type Props = {
/** Main content to render in the layout. */
children?: React.ReactNode;
/** Page title to display in the browser tab. Defaults to app name if not provided. */
title?: string;
/** Left sidebar content. */
sidebar?: React.ReactNode;
/** Right sidebar content. */
sidebarRight?: React.ReactNode;
};
@@ -29,6 +28,14 @@ const Layout = React.forwardRef(function Layout_(
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
useAutoRefresh();
useKeyDown(".", (event) => {
if (isModKey(event)) {
ui.toggleCollapsedSidebar();
}
});
return (
<Container column auto ref={ref}>
<Helmet>
+7 -39
View File
@@ -25,7 +25,6 @@ import {
NextIcon,
ZoomInIcon,
ZoomOutIcon,
EditIcon,
} from "outline-icons";
import { depths, extraArea, s } from "@shared/styles";
import NudeButton from "./NudeButton";
@@ -51,10 +50,6 @@ import {
} from "react-zoom-pan-pinch";
import { transparentize } from "polished";
import { mergeRefs } from "react-merge-refs";
import { useEditor } from "~/editor/components/EditorContext";
import { NodeSelection } from "prosemirror-state";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -91,7 +86,7 @@ const ANIMATION_DURATION = 0.3 * Second.ms;
type Props = {
/** List of allowed images */
images: LightboxImage[];
/** The currently active image in the document */
/** The position of the currently active image in the document */
activeImage: LightboxImage;
/** Callback triggered when the active image is updated */
onUpdate: (activeImage: LightboxImage | null) => void;
@@ -230,11 +225,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
height: number;
} | null>(null);
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
const editor = useEditor();
const currentImageIndex = findIndex(
images,
(img) => img.pos === activeImage.pos
(img) => img.getPos() === activeImage.getPos()
);
// Debugging status changes
@@ -623,9 +617,9 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
URL.revokeObjectURL(imageURL);
};
const handleDownload = useCallback(() => {
const download = useCallback(() => {
if (activeImage && status.lightbox === LightboxStatus.OPENED) {
void downloadImage(activeImage.src, activeImage.alt);
void downloadImage(activeImage.getSrc(), activeImage.getAlt());
}
}, [activeImage, status.lightbox]);
@@ -676,17 +670,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
};
const handleEditDiagram = () => {
const { state, dispatch } = editor.view;
// Select the node at the position
const tr = state.tr.setSelection(
NodeSelection.create(state.doc, activeImage.pos)
);
dispatch(tr);
editor.commands.editDiagram();
};
return (
<Dialog.Root open={true}>
<Dialog.Portal>
@@ -762,7 +745,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={handleDownload}
onClick={download}
aria-label={t("Download")}
size={32}
icon={<DownloadIcon />}
@@ -770,21 +753,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
neutral
/>
</Tooltip>
{activeImage.source === ImageSource.DiagramsNet &&
!Desktop.isElectron() && (
<Tooltip content={t("Edit diagram")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={handleEditDiagram}
aria-label={t("Edit diagram")}
size={32}
icon={<EditIcon />}
borderOnHover
neutral
/>
</Tooltip>
)}
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
@@ -824,8 +792,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
>
<Image
ref={imgRef}
src={activeImage.src}
alt={activeImage.alt}
src={activeImage.getSrc()}
alt={activeImage.getAlt()}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
+13 -11
View File
@@ -1,8 +1,8 @@
import * as React from "react";
import { actionToMenuItem } from "~/actions";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { ActionVariant, ActionWithChildren } from "~/types";
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
import { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
@@ -11,7 +11,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action?: ActionWithChildren;
action?: ActionV2WithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
@@ -30,13 +30,15 @@ export const ContextMenu = observer(
isMenu: true,
});
const menuItems = useComputed(
() =>
((action?.children as ActionVariant[]) ?? []).map((childAction) =>
actionToMenuItem(childAction, actionContext)
),
[action?.children, actionContext]
);
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return ((action?.children as ActionV2Variant[]) ?? []).map(
(childAction) => actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action?.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
@@ -46,7 +48,7 @@ export const ContextMenu = observer(
onClose?.();
}
},
[open, onOpen, onClose]
[onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
+6 -6
View File
@@ -10,12 +10,12 @@ import {
} from "~/components/primitives/Drawer";
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { actionToMenuItem } from "~/actions";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import {
ActionVariant,
ActionWithChildren,
ActionV2Variant,
ActionV2WithChildren,
MenuItem,
MenuItemWithChildren,
} from "~/types";
@@ -25,7 +25,7 @@ import { useComputed } from "~/hooks/useComputed";
type Props = {
/** Root action with children representing the menu items */
action: ActionWithChildren;
action: ActionV2WithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** Alignment w.r.t trigger - defaults to start */
@@ -69,8 +69,8 @@ export const DropdownMenu = observer(
return [];
}
return (action.children as ActionVariant[]).map((childAction) =>
actionToMenuItem(childAction, actionContext)
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
+1 -1
View File
@@ -166,7 +166,7 @@ export function toMobileMenuItems(
<Components.MenuLabel>{item.title}</Components.MenuLabel>
{item.selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{item.selected ? <CheckmarkIcon size={18} /> : null}
{item.selected ? <CheckmarkIcon /> : null}
</Components.SelectedIconWrapper>
)}
</Components.MenuButton>
+1 -2
View File
@@ -88,7 +88,7 @@ const Modal: React.FC<Props> = ({
column
reverse
>
<DesktopContent style={style} topShadow>
<DesktopContent style={style} shadow>
<ErrorBoundary component="div">{children}</ErrorBoundary>
</DesktopContent>
<Header>
@@ -213,7 +213,6 @@ const Header = styled(Flex)`
justify-content: space-between;
font-weight: 600;
padding: 24px 24px 12px;
flex-shrink: 0;
`;
const Wrapper = styled.div<{
+9 -3
View File
@@ -12,11 +12,17 @@ type Props = React.ComponentProps<typeof NavLink> & {
| null,
location: LocationDescriptorObject
) => React.ReactNode;
/** If true, the tab will only be active if the path matches exactly */
/**
* If true, the tab will only be active if the path matches exactly.
*/
exact?: boolean;
/** CSS properties to apply to the link when it is active */
/**
* CSS properties to apply to the link when it is active.
*/
activeStyle?: React.CSSProperties;
/** The path to match against the current location */
/**
* The path to match against the current location.
*/
to: LocationDescriptor;
};
@@ -1,37 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { InputSelect } from "../InputSelect";
/**
* An input that allows a choice of OAuth client type.
*/
export const InputClientType = React.forwardRef(
(
props: Omit<React.ComponentProps<typeof InputSelect>, "options" | "label">,
ref: React.Ref<HTMLButtonElement>
) => {
const { t } = useTranslation();
return (
<InputSelect
{...props}
label={t("Client type")}
ref={ref}
style={{ marginBottom: "1em" }}
options={[
{
type: "item",
label: t("Confidential"),
value: "confidential",
description: t("Suitable for server-side applications"),
},
{
type: "item",
label: t("Public"),
value: "public",
description: t("Suitable for client-side or mobile applications"),
},
]}
/>
);
}
);
+11 -28
View File
@@ -10,8 +10,6 @@ import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import isCloudHosted from "~/utils/isCloudHosted";
import Switch from "../Switch";
import EventBoundary from "@shared/components/EventBoundary";
import { InputClientType } from "./InputClientType";
export interface FormData {
name: string;
@@ -21,7 +19,6 @@ export interface FormData {
avatarUrl: string;
redirectUris: string[];
published: boolean;
clientType: "confidential" | "public";
}
export const OAuthClientForm = observer(function OAuthClientForm_({
@@ -49,7 +46,6 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
avatarUrl: oauthClient?.avatarUrl ?? "",
redirectUris: oauthClient?.redirectUris ?? [],
published: oauthClient?.published ?? false,
clientType: oauthClient?.clientType ?? "confidential",
},
});
@@ -66,33 +62,20 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
control={control}
name="avatarUrl"
render={({ field }) => (
<EventBoundary>
<ImageInput
alt={t("OAuth client icon")}
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
id: oauthClient?.id,
avatarUrl: field.value,
initial: getValues().name[0],
}}
borderRadius={0}
/>
</EventBoundary>
<ImageInput
alt={t("OAuth client icon")}
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
id: oauthClient?.id,
avatarUrl: field.value,
initial: getValues().name[0],
}}
borderRadius={0}
/>
)}
/>
</label>
<Controller
control={control}
name="clientType"
render={({ field }) => (
<InputClientType
value={field.value}
onChange={field.onChange}
ref={field.ref}
/>
)}
/>
<Input
type="text"
label={t("Name")}
+3 -19
View File
@@ -13,28 +13,12 @@ const ReadingTime = ({ document }: { document: Document }) => {
);
const stats = useTextStats(markdown);
const readingTimeMinutes = stats.total.readingTime;
const hours = Math.floor(readingTimeMinutes / 60);
const minutes = readingTimeMinutes % 60;
let readingTimeText;
if (hours > 0) {
if (minutes > 0) {
readingTimeText = t(`{{ hours }}h {{ minutes }}m read`, {
hours,
minutes,
});
} else {
readingTimeText = t(`{{ hours }}h read`, { hours });
}
} else {
readingTimeText = t(`{{ minutes }}m read`, { minutes: readingTimeMinutes });
}
return (
<>
<EyeIcon size={18} />
{readingTimeText}
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
+1 -6
View File
@@ -48,11 +48,6 @@ function SearchPopover({ shareId, className }: Props) {
}
}, [searchResults, query]);
// Clear search results when the query changes to prevent stale results
React.useEffect(() => {
setSearchResults(undefined);
}, [query]);
const performSearch = React.useCallback(
async ({ query: searchQuery, ...options }) => {
if (searchQuery?.length > 0) {
@@ -63,7 +58,7 @@ function SearchPopover({ shareId, className }: Props) {
});
if (response.length) {
setSearchResults((state) => [...(state ?? []), ...response]);
setSearchResults(response);
}
return response;
@@ -23,7 +23,6 @@ import { Separator } from "../components";
import { ListItem } from "../components/ListItem";
import { Placeholder } from "../components/Placeholder";
import { PublicAccess } from "./PublicAccess";
import Flex from "@shared/components/Flex";
type Props = {
/** Collection to which team members are supposed to be invited */
@@ -78,12 +77,9 @@ export const AccessControlList = observer(
}, [fetchMemberships, fetchGroupMemberships]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessHeight = publicAccessRef.current?.clientHeight || 0;
const { maxHeight, calcMaxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
margin: 24,
});
React.useEffect(() => {
@@ -115,177 +111,162 @@ export const AccessControlList = observer(
);
return (
<Wrapper>
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{
maxHeight: maxHeight ? maxHeight - publicAccessHeight : undefined,
}}
>
{showLoading ? (
<Placeholder count={2} />
) : (
<>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}}
disabled={!can.update}
value={collection?.permission}
hideLabel
nude
shrink
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{ maxHeight }}
>
{showLoading ? (
<Placeholder count={2} />
) : (
<>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}}
disabled={!can.update}
value={collection?.permission}
hideLabel
nude
shrink
/>
</div>
}
/>
{groupMembershipsInCollection
.filter((membership) => membership.group)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") +
a.group.name
).localeCompare(b.group.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<GroupAvatar
group={membership.group}
backgroundColor={theme.modalBackground}
/>
</div>
}
/>
{groupMembershipsInCollection
.filter((membership) => membership.group)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") +
a.group.name
).localeCompare(b.group.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<GroupAvatar
group={membership.group}
backgroundColor={theme.modalBackground}
/>
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
return true;
}}
disabled={!can.update}
value={membership.permission}
/>
</div>
}
/>
))}
{membershipsInCollection
.filter((membership) => membership.user)
.sort((a, b) =>
(
(invitedInSession.includes(a.user.id) ? "_" : "") +
a.user.name
).localeCompare(b.user.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Avatar
model={membership.user}
size={AvatarSize.Medium}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
/>
}
title={membership.user.name}
subtitle={membership.user.email}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
</div>
}
/>
))}
{membershipsInCollection
.filter((membership) => membership.user)
.sort((a, b) =>
(
(invitedInSession.includes(a.user.id) ? "_" : "") +
a.user.name
).localeCompare(b.user.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Avatar model={membership.user} size={AvatarSize.Medium} />
}
title={membership.user.name}
subtitle={membership.user.email}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
return true;
}}
disabled={!can.update}
value={membership.permission}
/>
</div>
}
/>
))}
</>
)}
</ScrollableContainer>
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
/>
</div>
}
/>
))}
</>
)}
{team.sharing && can.share && collection.sharing && visible && (
<Sticky>
{collection.members.length ? <Separator /> : null}
<PublicAccess
ref={publicAccessRef}
collection={collection}
share={share}
/>
<PublicAccess collection={collection} share={share} />
</Sticky>
)}
</Wrapper>
</ScrollableContainer>
);
}
);
const Wrapper = styled(Flex)`
flex-direction: column;
`;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
@@ -2,7 +2,7 @@ import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
import * as React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
@@ -31,24 +31,21 @@ type Props = {
share: Share | null | undefined;
};
function InnerPublicAccess(
{ collection, share }: Props,
ref: React.RefObject<HTMLDivElement>
) {
function InnerPublicAccess({ collection, share }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [validationError, setValidationError] = React.useState("");
const [urlId, setUrlId] = React.useState(share?.urlId);
const inputRef = React.useRef<HTMLInputElement>(null);
const [validationError, setValidationError] = useState("");
const [urlId, setUrlId] = useState(share?.urlId);
const inputRef = useRef<HTMLInputElement>(null);
const can = usePolicy(share);
const collectionAbilities = usePolicy(collection);
const canPublish = can.update && collectionAbilities.share;
React.useEffect(() => {
useEffect(() => {
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = React.useCallback(
const handleIndexingChanged = useCallback(
async (checked: boolean) => {
try {
await share?.save({
@@ -61,7 +58,7 @@ function InnerPublicAccess(
[share]
);
const handleShowLastModifiedChanged = React.useCallback(
const handleShowLastModifiedChanged = useCallback(
async (checked: boolean) => {
try {
await share?.save({
@@ -74,7 +71,7 @@ function InnerPublicAccess(
[share]
);
const handleShowTOCChanged = React.useCallback(
const handleShowTOCChanged = useCallback(
async (checked: boolean) => {
try {
await share?.save({
@@ -87,7 +84,7 @@ function InnerPublicAccess(
[share]
);
const handlePublishedChange = React.useCallback(
const handlePublishedChange = useCallback(
async (checked: boolean) => {
try {
await share?.save({
@@ -100,7 +97,7 @@ function InnerPublicAccess(
[share]
);
const handleUrlChange = React.useMemo(
const handleUrlChange = useMemo(
() =>
debounce(async (ev) => {
if (!share) {
@@ -131,7 +128,7 @@ function InnerPublicAccess(
[t, share]
);
const handleCopied = React.useCallback(() => {
const handleCopied = useCallback(() => {
toast.success(t("Public link copied to clipboard"));
}, [t]);
@@ -146,7 +143,7 @@ function InnerPublicAccess(
);
return (
<Wrapper ref={ref}>
<Wrapper>
<ListItem
title={t("Web")}
subtitle={<>{t("Allow anyone with the link to access")}</>}
@@ -305,4 +302,4 @@ const StyledInfoIcon = styled(InfoIcon)`
flex-shrink: 0;
`;
export const PublicAccess = observer(React.forwardRef(InnerPublicAccess));
export const PublicAccess = observer(InnerPublicAccess);
@@ -67,11 +67,9 @@ export const AccessControlList = observer(
const documentId = document.id;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessHeight = publicAccessRef.current?.clientHeight || 0;
const { maxHeight, calcMaxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 45,
maxViewportPercentage: 65,
margin: 24,
});
@@ -111,106 +109,101 @@ export const AccessControlList = observer(
});
return (
<Wrapper>
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{
maxHeight: maxHeight ? maxHeight - publicAccessHeight : undefined,
}}
>
{showLoading ? (
<Placeholder />
) : (
<>
{collection && canCollection.readDocument ? (
<>
{collection.permission ? (
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<AccessTooltip>
{collection?.permission ===
CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
</AccessTooltip>
}
/>
) : usersInCollection ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<ListItem
image={<Avatar model={user} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : (
<>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{ maxHeight }}
>
{showLoading ? (
<Placeholder />
) : (
<>
{collection && canCollection.readDocument ? (
<>
{collection.permission ? (
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<AccessTooltip
content={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
<AccessTooltip>
{collection?.permission ===
CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
</AccessTooltip>
}
/>
</>
)}
</>
)}
</ScrollableContainer>
) : usersInCollection ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<ListItem
image={<Avatar model={user} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : (
<>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
actions={
<AccessTooltip
content={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
}
/>
</>
)}
</>
)}
{team.sharing && can.share && !collectionSharingDisabled && visible && (
<Sticky>
{document.members.length ? <Separator /> : null}
<PublicAccess
ref={publicAccessRef}
document={document}
share={share}
sharedParent={sharedParent}
@@ -218,7 +211,7 @@ export const AccessControlList = observer(
/>
</Sticky>
)}
</Wrapper>
</ScrollableContainer>
);
}
);
@@ -283,14 +276,10 @@ function useUsersInCollection(collection?: Collection) {
: false;
}
const Wrapper = styled(Flex)`
flex-direction: column;
`;
const Sticky = styled.div`
background: ${s("menuBackground")};
position: sticky;
bottom: 0;
bottom: -12px;
`;
const ScrollableContainer = styled(Scrollable)`
@@ -37,10 +37,7 @@ type Props = {
onRequestClose?: () => void;
};
function PublicAccess(
{ document, share, sharedParent }: Props,
ref: React.RefObject<HTMLDivElement>
) {
function PublicAccess({ document, share, sharedParent }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [validationError, setValidationError] = React.useState("");
@@ -156,7 +153,7 @@ function PublicAccess(
);
return (
<Wrapper ref={ref}>
<Wrapper>
<ListItem
title={t("Web")}
subtitle={
@@ -356,4 +353,4 @@ const StyledLink = styled(Link)`
text-decoration: underline;
`;
export default observer(React.forwardRef(PublicAccess));
export default observer(PublicAccess);
+1 -1
View File
@@ -14,7 +14,7 @@ export const Wrapper = styled.div`
export const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 0 0 8px 0;
margin: 8px 0;
`;
export const HeaderInput = styled(Flex)`
-2
View File
@@ -30,7 +30,6 @@ import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import ToggleButton from "./components/ToggleButton";
import TrashLink from "./components/TrashLink";
import BulkSelectionToolbar from "../BulkSelectionToolbar";
function AppSidebar() {
const { t } = useTranslation();
@@ -132,7 +131,6 @@ function AppSidebar() {
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
<BulkSelectionToolbar />
</DndProvider>
)}
</Sidebar>
+5 -17
View File
@@ -23,7 +23,6 @@ import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useEffect } from "react";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
type Props = {
share: Share;
@@ -32,16 +31,12 @@ type Props = {
function SharedSidebar({ share }: Props) {
const team = useTeamContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const { ui, documents, collections } = useStores();
const { ui, documents } = useStores();
const { t } = useTranslation();
const teamAvailable = !!team?.name;
const rootNode = share.tree;
const shareId = share.urlId || share.id;
const collection = collections.get(rootNode?.id);
const hideRootNode = collection
? ProsemirrorHelper.isEmptyData(collection?.data)
: false;
useEffect(() => {
ui.tocVisible = share.showTOC;
@@ -52,18 +47,15 @@ function SharedSidebar({ share }: Props) {
}
return (
<StyledSidebar $hoverTransition={!teamAvailable} canCollapse={false}>
<StyledSidebar $hoverTransition={!teamAvailable} canResize={false}>
{teamAvailable && (
<SidebarButton
title={team.name}
image={
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
}
disabled={hideRootNode}
onClick={
hideRootNode
? undefined
: () => history.push(user ? homePath() : sharedModelPath(shareId))
onClick={() =>
history.push(user ? homePath() : sharedModelPath(shareId))
}
/>
)}
@@ -80,11 +72,7 @@ function SharedSidebar({ share }: Props) {
</TopSection>
<Section>
{share.collectionId ? (
<SharedCollectionLink
node={rootNode}
shareId={shareId}
hideRootNode={hideRootNode}
/>
<SharedCollectionLink node={rootNode} shareId={shareId} />
) : (
<SharedDocumentLink
index={0}
+17 -29
View File
@@ -21,24 +21,19 @@ import ResizeBorder from "./components/ResizeBorder";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useTranslation } from "react-i18next";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "@shared/utils/keyboard";
const ANIMATION_MS = 250;
type Props = {
/** Whether to hide the sidebar content (sets opacity to 0). */
hidden?: boolean;
/** Whether the sidebar can be collapsed, defaults to true. */
canCollapse?: boolean;
/** CSS class name(s) to apply to the sidebar container. */
/** Whether the sidebar can be resized and collapsed, defaults to true. */
canResize?: boolean;
className?: string;
/** Content to render inside the sidebar. */
children: React.ReactNode;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children, hidden = false, canCollapse = true, className }: Props,
{ children, hidden = false, canResize = true, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
@@ -50,7 +45,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobile = useMobile();
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && canCollapse;
const collapsed = ui.sidebarIsClosed && canResize;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -60,13 +55,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const [isResizing, setResizing] = React.useState(false);
const [hasPointerMoved, setPointerMoved] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
useKeyDown(".", (event) => {
if (isModKey(event)) {
ui.toggleCollapsedSidebar();
}
});
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
@@ -76,15 +66,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const newWidth = Math.min(event.pageX - offset, maxWidth);
const isSmallerThanCollapsePoint = newWidth < minWidth / 2;
if (canCollapse) {
ui.set({
sidebarWidth: isSmallerThanCollapsePoint
? theme.sidebarCollapsedWidth
: newWidth,
});
} else {
ui.set({ sidebarWidth: Math.max(newWidth, minWidth) });
}
ui.set({
sidebarWidth: isSmallerThanCollapsePoint
? theme.sidebarCollapsedWidth
: newWidth,
});
},
[ui, theme, offset, minWidth, maxWidth]
);
@@ -99,7 +85,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
if (isSmallerThanMinimum) {
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint && canCollapse) {
if (isSmallerThanCollapsePoint) {
setAnimating(false);
setCollapsing(true);
ui.collapseSidebar();
@@ -270,10 +256,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
</SidebarButton>
</AccountMenu>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
{canResize && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
)}
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</TooltipProvider>
@@ -2,6 +2,8 @@ import { useState, useCallback } from "react";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import Relative from "./Relative";
type Props = {
collection: Collection;
@@ -10,6 +12,7 @@ type Props = {
export function ArchivedCollectionLink({ collection, depth }: Props) {
const { documents } = useStores();
const [expanded, setExpanded] = useState(false);
const handleDisclosureClick = useCallback((ev) => {
@@ -23,13 +26,22 @@ export function ArchivedCollectionLink({ collection, depth }: Props) {
}, []);
return (
<CollectionLink
depth={depth ? depth : 0}
collection={collection}
expanded={expanded}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
<>
<CollectionLink
depth={depth ? depth : 0}
collection={collection}
expanded={expanded}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={expanded}
prefetchDocument={documents.prefetchDocument}
/>
</Relative>
</>
);
}
@@ -27,12 +27,11 @@ import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { ActionContextProvider } from "~/hooks/useActionContext";
import CollectionLinkChildren from "./CollectionLinkChildren";
type Props = {
collection: Collection;
expanded?: boolean;
onDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void;
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
activeDocument: Document | undefined;
isDraggingAnyCollection?: boolean;
depth?: number;
@@ -186,30 +185,24 @@ const CollectionLink: React.FC<Props> = ({
/>
</DropToImport>
</Relative>
<CollectionLinkChildren
collection={collection}
expanded={!!expanded}
prefetchDocument={documents.prefetchDocument}
>
{isAddingNewChild ? (
<SidebarLink
depth={2}
isActive={() => true}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
) : undefined}
</CollectionLinkChildren>
{isAddingNewChild && (
<SidebarLink
depth={2}
isActive={() => true}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
)}
</ActionContextProvider>
);
};
@@ -29,15 +29,12 @@ type Props = {
expanded: boolean;
/** Function to prefetch a document by ID. */
prefetchDocument?: (documentId: string) => Promise<Document | void>;
/** Element to display above the child documents */
children?: React.ReactNode;
};
function CollectionLinkChildren({
collection,
expanded,
prefetchDocument,
children,
}: Props) {
const pageSize = DEFAULT_PAGE_SIZE;
const { documents } = useStores();
@@ -61,7 +58,6 @@ function CollectionLinkChildren({
<Folder expanded={expanded}>
<DynamicDropCursor collection={collection} />
<DocumentsLoader collection={collection} enabled={expanded}>
{children}
{!childDocuments && (
<ResizingHeightContainer hideOverflow>
<Loading />
@@ -79,7 +75,7 @@ function CollectionLinkChildren({
index={index}
/>
))}
{childDocuments?.length === 0 && !children && (
{childDocuments?.length === 0 && (
<SidebarLink
label={
<Text type="tertiary" size="small" italic>
@@ -92,6 +92,7 @@ function Collections() {
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
)}
@@ -1,22 +1,24 @@
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { extraArea, s } from "@shared/styles";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof Button> & {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
expanded: boolean;
root?: boolean;
};
function Disclosure({ onClick, expanded, ...rest }: Props) {
function Disclosure({ onClick, root, expanded, ...rest }: Props) {
const { t } = useTranslation();
return (
<Button
size={20}
onClick={onClick}
$root={root}
aria-label={expanded ? t("Collapse") : t("Expand")}
{...rest}
>
@@ -25,18 +27,28 @@ function Disclosure({ onClick, expanded, ...rest }: Props) {
);
}
const Button = styled(NudeButton)`
const Button = styled(NudeButton)<{ $root?: boolean }>`
position: absolute;
left: -24px;
flex-shrink: 0;
color: ${s("textSecondary")};
margin: 2px;
${extraArea(4)}
&:hover {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
${(props) =>
props.$root &&
css`
opacity: 0;
left: -18px;
&:hover {
opacity: 1;
background: none;
}
`}
`;
const StyledCollapsedIcon = styled(CollapsedIcon)<{
@@ -78,18 +78,6 @@ function InnerDocumentLink(
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
// Selection state for bulk operations
const isSelected = documents.isSelected(node.id);
const hasAnySelection = documents.selectedDocumentIds.length > 0;
const handleSelectionChange = React.useCallback(() => {
if (isSelected) {
documents.deselect(node.id);
} else {
documents.select(node.id);
}
}, [documents, node.id, isSelected]);
React.useEffect(() => {
if (
isActiveDocument &&
@@ -142,13 +130,17 @@ function InnerDocumentLink(
}
}, [setCollapsed, expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(() => {
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
}, [setCollapsed, setExpanded, expanded]);
const handleDisclosureClick = React.useCallback(
(ev) => {
ev?.preventDefault();
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
},
[setCollapsed, setExpanded, expanded]
);
const handlePrefetch = React.useCallback(() => {
void prefetchDocument?.(node.id);
@@ -225,19 +217,6 @@ function InnerDocumentLink(
useDropToReparentDocument(node, setExpanded, parentRef);
// Drop to reorder
const [{ isOverReorder: isOverReorderAbove }, dropToReorderAbove] =
useDropToReorderDocument(node, collection, (item) => {
if (!collection) {
return;
}
return {
documentId: item.id,
collectionId: collection.id,
parentDocumentId: parentId,
index,
};
});
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] =
useDropToReorderDocument(node, collection, (item) => {
if (!collection) {
@@ -323,7 +302,6 @@ function InnerDocumentLink(
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
membership?.addDocument(newDocument, node.id);
closeAddingNewChild();
history.push({
@@ -411,13 +389,6 @@ function InnerDocumentLink(
}}
>
<Relative ref={parentRef}>
{isDraggingAnyDocument && collection?.isManualSort && index === 0 && (
<DropCursor
isActiveDrop={isOverReorderAbove}
innerRef={dropToReorderAbove}
position="top"
/>
)}
<Draggable
key={node.id}
ref={drag}
@@ -426,7 +397,7 @@ function InnerDocumentLink(
onKeyDown={handleKeyDown}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
@@ -446,12 +417,6 @@ function InnerDocumentLink(
isDraft={isDraft}
ref={ref}
menu={menuElement}
onSelectionChange={handleSelectionChange}
selectionState={{
isSelected,
hasAnySelection,
showCheckbox: true,
}}
/>
</DropToImport>
</div>
@@ -11,6 +11,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { DragObject } from "../hooks/useDragAndDrop";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
import Relative from "./Relative";
import { useSidebarContext } from "./SidebarContext";
@@ -18,12 +19,14 @@ import { useSidebarContext } from "./SidebarContext";
type Props = {
collection: Collection;
activeDocument: Document | undefined;
prefetchDocument: (id: string) => Promise<Document | void>;
belowCollection: Collection | void;
};
function DraggableCollectionLink({
collection,
activeDocument,
prefetchDocument,
belowCollection,
}: Props) {
const locationSidebarContext = useLocationSidebarContext();
@@ -113,6 +116,11 @@ function DraggableCollectionLink({
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
prefetchDocument={prefetchDocument}
/>
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
@@ -1,13 +1,12 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import invariant from "invariant";
import { observer } from "mobx-react";
import { useCallback, useState } from "react";
import { useCallback } from "react";
import Dropzone from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { css } from "styled-components";
import LoadingIndicator from "~/components/LoadingIndicator";
import useEventListener from "~/hooks/useEventListener";
import useImportDocument from "~/hooks/useImportDocument";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -23,7 +22,6 @@ type Props = {
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const [prerender, setPreRendered] = useState(false);
const { handleFiles, isImporting } = useImportDocument(
collectionId,
documentId
@@ -32,7 +30,6 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
collectionId || documentId,
"Must provide either collectionId or documentId"
);
useEventListener("dragenter", () => setPreRendered(true));
const canCollection = usePolicy(collectionId);
const canDocument = usePolicy(documentId);
@@ -45,7 +42,6 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
if (
disabled ||
!prerender ||
(collectionId && !canCollection.createDocument) ||
(documentId && !canDocument.createChildDocument)
) {
@@ -54,7 +50,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
return (
<Dropzone
accept={documents.importFileTypesString}
accept={documents.importFileTypes.join(", ")}
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
noClick
+18 -2
View File
@@ -1,14 +1,30 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
expanded: boolean;
children?: React.ReactNode;
};
const Folder: React.FC<Props> = ({ expanded, children }: Props) => {
if (!expanded) {
const [openedOnce, setOpenedOnce] = React.useState(expanded);
// allows us to avoid rendering all children when the folder hasn't been opened
React.useEffect(() => {
if (expanded) {
setOpenedOnce(true);
}
}, [expanded]);
if (!openedOnce) {
return null;
}
return <>{children}</>;
return <Wrapper $expanded={expanded}>{children}</Wrapper>;
};
const Wrapper = styled.div<{ $expanded?: boolean }>`
display: ${(props) => (props.$expanded ? "block" : "none")};
`;
export default Folder;
@@ -8,19 +8,32 @@ import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { RequestResponse } from "~/hooks/usePaginatedRequest";
import GroupMembership from "~/models/GroupMembership";
import { t } from "i18next";
import { toast } from "sonner";
type Props = {
/** The group to render */
group: Group;
/** The response from the group memberships request */
response: RequestResponse<GroupMembership>;
};
const GroupLink: React.FC<Props> = ({ group }) => {
const GroupLink: React.FC<Props> = ({ group, response }) => {
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = groupSidebarContext(group.id);
const { loading, next, end, error } = response;
const [expanded, setExpanded] = React.useState(
locationSidebarContext === sidebarContext
);
React.useEffect(() => {
if (error) {
toast.error(t("Could not load shared documents"));
}
}, [error, t]);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
@@ -50,6 +63,14 @@ const GroupLink: React.FC<Props> = ({ group }) => {
depth={1}
/>
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={loading}
depth={0}
/>
)}
</Folder>
</SidebarContext.Provider>
</Relative>
+3 -32
View File
@@ -29,33 +29,18 @@ const normalizeToLocation = (
const joinClassnames = (...classnames: (string | undefined)[]) =>
classnames.filter((i) => i).join(" ");
/**
* Props for the NavLink component.
* Extends standard anchor element attributes with React Router navigation functionality.
*/
export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
/** CSS class name to apply when the link is active */
activeClassName?: string;
/** Inline styles to apply when the link is active */
activeStyle?: React.CSSProperties;
/** Whether to automatically scroll the link into view when it becomes active */
scrollIntoViewIfNeeded?: boolean;
/** If true, only matches when the path matches the location.pathname exactly */
exact?: boolean;
/** If true, use history.replace instead of history.push when navigating */
replace?: boolean;
/** Custom function to determine if the link is active */
isActive?: (match: match | null, location: Location) => boolean;
/** The location to match against. Defaults to the current history location */
location?: Location;
/** If true, trailing slashes on the path will be considered when matching */
strict?: boolean;
/** The location to navigate to. Can be a string path or location descriptor object */
to: LocationDescriptor;
/** Custom component to use instead of the default anchor element */
component?: React.ComponentType;
/** Callback fired when an active link is clicked */
onActiveClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
onBeforeClick?: () => void;
}
/**
@@ -74,7 +59,7 @@ const NavLink = ({
style: styleProp,
scrollIntoViewIfNeeded,
onClick,
onActiveClick,
onBeforeClick,
to,
...rest
}: Props) => {
@@ -141,10 +126,6 @@ const NavLink = ({
(event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
if (isActive) {
onActiveClick?.(event);
}
if (shouldFastClick(event)) {
event.currentTarget.focus();
@@ -157,16 +138,7 @@ const NavLink = ({
});
}
},
[onClick, navigateTo, isActive, shouldFastClick]
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (isActive) {
event.preventDefault();
}
},
[isActive]
[onClick, navigateTo, shouldFastClick]
);
React.useEffect(() => {
@@ -190,7 +162,6 @@ const NavLink = ({
// Note do not use `onPointerDown` here as it makes the mobile sidebar unscrollable
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
onClick={handleClick}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -10,41 +10,35 @@ import SidebarLink from "./SidebarLink";
type Props = {
node: NavigationNode;
shareId: string;
hideRootNode?: boolean;
};
function CollectionLink({ node, shareId, hideRootNode }: Props) {
function CollectionLink({ node, shareId }: Props) {
const { t } = useTranslation();
const { documents, ui } = useStores();
const icon = node.icon ?? node.emoji;
return (
<>
{!hideRootNode && (
<SidebarLink
to={{
pathname: sharedModelPath(shareId),
state: {
title: node.title,
},
}}
icon={
icon && (
<Icon value={icon} initial={node.title} color={node.color} />
)
}
label={node.title || t("Untitled")}
depth={0}
exact={false}
scrollIntoViewIfNeeded={true}
isActive={() => ui.activeCollectionId === node.id}
/>
)}
<SidebarLink
to={{
pathname: sharedModelPath(shareId),
state: {
title: node.title,
},
}}
icon={icon && <Icon value={icon} color={node.color} />}
label={node.title || t("Untitled")}
depth={0}
exact={false}
scrollIntoViewIfNeeded={true}
isActive={() => ui.activeCollectionId === node.id}
/>
{node.children.map((childNode, index) => (
<SharedDocumentLink
key={childNode.id}
index={index}
depth={hideRootNode ? 1 : 2}
depth={2}
shareId={shareId}
node={childNode}
prefetchDocument={documents.prefetchDocument}
@@ -8,7 +8,7 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { sharedModelPath } from "~/utils/routeHelpers";
import { descendants } from "@shared/utils/tree";
import { descendants } from "~/utils/tree";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -108,7 +108,6 @@ function DocumentLink(
t("Untitled");
const icon = node.icon ?? node.emoji;
const initial = title ? title.charAt(0).toUpperCase() : "?";
return (
<>
@@ -122,9 +121,7 @@ function DocumentLink(
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
icon={
icon && <Icon value={icon} color={node.color} initial={initial} />
}
icon={icon && <Icon value={icon} color={node.color} />}
label={title}
depth={depth}
exact={false}
@@ -30,7 +30,9 @@ function SharedWithMe() {
const history = useHistory();
const locationSidebarContext = useLocationSidebarContext();
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
const gmResponse = usePaginatedRequest<GroupMembership>(
groupMemberships.fetchAll
);
const { loading, next, end, error, page } =
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
@@ -108,7 +110,7 @@ function SharedWithMe() {
<Flex column>
<Header id="shared" title={t("Shared with me")}>
{user.groupsWithDocumentMemberships.map((group) => (
<GroupLink key={group.id} group={group} />
<GroupLink key={group.id} group={group} response={gmResponse} />
))}
<Relative>
{reorderProps.isDragging && (
@@ -2,11 +2,11 @@ import invariant from "invariant";
import { observer } from "mobx-react";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { ActionVariant, ActionWithChildren } from "~/types";
import { Action } from "~/types";
import SidebarLink from "./SidebarLink";
type Props = {
action: Exclude<ActionVariant, ActionWithChildren>;
action: Action;
depth?: number;
};
@@ -25,7 +25,6 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
image,
title,
children,
onClick,
...rest
}: SidebarButtonProps,
ref
@@ -39,7 +38,6 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
>
<Button
{...rest}
onClick={onClick}
$position={position}
as="button"
ref={ref}
@@ -98,19 +96,17 @@ const Button = styled(Flex)<{
text-decoration: none;
text-align: left;
user-select: none;
cursor: var(--pointer);
position: relative;
${undraggableOnDesktop()}
${extraArea(4)}
&:not(:disabled) {
&:active,
&:${hover},
&[aria-expanded="true"] {
color: ${s("sidebarText")};
background: ${s("sidebarActiveBackground")};
cursor: var(--pointer);
}
&:active,
&:${hover},
&[aria-expanded="true"] {
color: ${s("sidebarText")};
background: ${s("sidebarActiveBackground")};
}
&:last-child {
+99 -187
View File
@@ -3,7 +3,7 @@ import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { ellipsis, hover, s } from "@shared/styles";
import { s } from "@shared/styles";
import { isMobile } from "@shared/utils/browser";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
@@ -11,60 +11,32 @@ import useClickIntent from "~/hooks/useClickIntent";
import { undraggableOnDesktop } from "~/styles";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
import { ActionWithChildren } from "~/types";
import { ActionV2WithChildren } from "~/types";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useTranslation } from "react-i18next";
import { CheckboxIcon } from "outline-icons";
import useBoolean from "~/hooks/useBoolean";
/**
* Props for the SidebarLink component.
* Extends NavLink props with additional sidebar-specific functionality.
*/
type Props = Omit<NavLinkProps, "to"> & {
/** The location to navigate to when the link is clicked */
to?: LocationDescriptor;
/** Ref callback to access the underlying HTML element */
innerRef?: (ref: HTMLElement | null | undefined) => void;
/** Callback fired when the link is clicked */
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
/** Callback when we expect the user to click on the link. Used for prefetching data. */
onClickIntent?: React.MouseEventHandler<HTMLElement>;
/** Callback fired when the disclosure icon is clicked */
onDisclosureClick?: React.MouseEventHandler<HTMLElement>;
/** Icon to display on the left side of the link */
onClickIntent?: () => void;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
icon?: React.ReactNode;
/** Text label or content to display for the link */
label?: React.ReactNode;
/** Optional menu to display on hover or interaction */
menu?: React.ReactNode;
/** Whether to show an unread badge indicator */
unreadBadge?: boolean;
/** Whether to show action buttons on hover */
showActions?: boolean;
/** Whether the link is disabled and non-interactive */
disabled?: boolean;
/** Whether the link is currently active */
active?: boolean;
/** If set, a disclosure will be rendered to the left of any icon */
expanded?: boolean;
/** Whether this link is the current active drop target for drag and drop */
isActiveDrop?: boolean;
/** Whether this link represents a draft document */
isDraft?: boolean;
/** Nesting depth level for indentation (0-based) */
depth?: number;
/** Whether to automatically scroll this link into view if needed */
scrollIntoViewIfNeeded?: boolean;
/** Optional context menu action to display */
contextAction?: ActionWithChildren;
/** State of the selection checkbox */
selectionState?: {
isSelected: boolean;
showCheckbox: boolean;
hasAnySelection: boolean;
};
/** Callback fired when the selection checkbox is toggled */
onSelectionChange?: () => void;
contextAction?: ActionV2WithChildren;
};
const activeDropStyle = {
@@ -73,7 +45,6 @@ const activeDropStyle = {
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
function SidebarLink(
@@ -97,27 +68,19 @@ function SidebarLink(
disabled,
unreadBadge,
contextAction,
selectionState = {
isSelected: false,
showCheckbox: false,
hasAnySelection: false,
},
onSelectionChange,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const hasDisclosure = expanded !== undefined;
const { t } = useTranslation();
const theme = useTheme();
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const { isSelected, showCheckbox, hasAnySelection } = selectionState;
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + (icon ? -8 : 12)}px`,
paddingLeft: `${(depth || 0) * 16 + 12}px`,
paddingRight: unreadBadge ? "32px" : undefined,
}),
[depth, icon, unreadBadge]
[depth]
);
const unreadStyle = React.useMemo(
@@ -136,119 +99,94 @@ function SidebarLink(
[theme.text, theme.sidebarActiveBackground, style]
);
const handleClick = React.useCallback(
(ev: React.MouseEvent<HTMLAnchorElement>) => {
if (onClick && !disabled && ev.isDefaultPrevented() === false) {
onClick(ev);
}
},
[onClick, disabled, expanded]
const hoverStyle = React.useMemo(
() => ({
color: theme.text,
...style,
}),
[theme.text, style]
);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
if (!hasDisclosure) {
return;
}
ev.preventDefault();
ev.stopPropagation();
onDisclosureClick?.(ev);
},
[onDisclosureClick]
);
const DisclosureComponent = icon ? HiddenDisclosure : Disclosure;
const [openContextMenu, setOpen, setClosed] = useBoolean(false);
return (
<ContextMenu action={contextAction} ariaLabel={t("Link options")}>
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
$hasCheckbox={showCheckbox}
style={style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
onActiveClick={handleDisclosureClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onDragEnter={handleMouseEnter}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
<>
<ContextMenu
action={contextAction}
ariaLabel={t("Link options")}
onOpen={setOpen}
onClose={setClosed}
>
<Content>
{showCheckbox && (
<CheckboxWrapper $alwaysVisible={hasAnySelection}>
<NudeButton
type="button"
onClick={onSelectionChange}
aria-label={t("Select")}
>
<CheckboxIcon checked={isSelected} />
</NudeButton>
</CheckboxWrapper>
)}
{hasDisclosure && (
<DisclosureComponent
expanded={expanded}
onClick={preventDefault}
onPointerDown={handleDisclosureClick}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label $ellipsis={typeof label === "string"}>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</Link>
</ContextMenu>
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={openContextMenu ? hoverStyle : active ? activeStyle : style}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
>
<Content>
{expanded !== undefined && (
<Disclosure
expanded={expanded}
onMouseDown={onDisclosureClick}
onClick={preventDefault}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</Link>
</ContextMenu>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</>
);
}
export const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
transition: opacity 150ms ease-in-out;
`;
const CheckboxWrapper = styled(EventBoundary)<{ $alwaysVisible?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
margin-left: -11px;
flex-shrink: 0;
opacity: ${(props) => (props.$alwaysVisible ? 1 : 0)};
transition: opacity 150ms ease-in-out;
`;
const Content = styled.span`
display: flex;
align-items: start;
position: relative;
width: 100%;
${Disclosure} {
margin-top: 2px;
margin-left: 2px;
}
`;
// accounts for whitespace around icon
export const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
display: inline-flex;
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
position: absolute;
top: 3px;
top: 4px;
right: 4px;
gap: 4px;
color: ${s("textTertiary")};
transition: opacity 50ms;
height: 24px;
background: var(--background);
svg {
color: ${s("textSecondary")};
@@ -265,42 +203,21 @@ const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
}
`;
const HiddenDisclosure = styled(Disclosure)`
position: inherit;
left: initial;
display: none;
margin-left: -2px;
margin-right: 6px;
`;
const Link = styled(NavLink)<{
$isActiveDrop?: boolean;
$isDraft?: boolean;
$disabled?: boolean;
$hasCheckbox?: boolean;
}>`
&:hover,
&:active {
--background: ${s("sidebarHoverBackground")};
}
&[aria-current="page"] ${Actions} {
--background: ${s("sidebarActiveBackground")};
}
${(props) => props.$isActiveDrop && `--background: ${props.theme.slateDark};`}
display: flex;
position: relative;
text-overflow: ellipsis;
font-weight: 475;
padding: ${isMobile() ? 12 : 6}px 16px;
border-radius: 4px;
min-height: 30px;
min-height: 32px;
user-select: none;
white-space: nowrap;
margin-top: 1px;
background: var(--background);
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 16px;
@@ -336,24 +253,34 @@ const Link = styled(NavLink)<{
transition: fill 50ms;
}
&: ${hover} {
${HiddenDisclosure} {
display: block;
}
${HiddenDisclosure} + ${IconWrapper} {
visibility: hidden;
opacity: 0;
width: 0;
&:hover svg {
display: inline;
}
& + ${Actions} {
background: ${s("sidebarBackground")};
${NudeButton} {
background: transparent;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
}
}
&[aria-current="page"] + ${Actions} {
background: ${s("sidebarActiveBackground")};
}
${breakpoint("tablet")`
padding: 3px 8px 3px 12px;
padding: 4px 8px 4px 16px;
font-size: 14px;
`}
@media (hover: hover) {
&:hover ${Actions}, &:active ${Actions} {
&:hover + ${Actions}, &:active + ${Actions} {
visibility: visible;
svg {
@@ -365,34 +292,19 @@ const Link = styled(NavLink)<{
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
${(props) =>
props.$hasCheckbox &&
css`
&:hover ${CheckboxWrapper} {
opacity: 1;
}
`}
}
& ${Actions} {
${NudeButton} {
background: transparent;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
&:hover {
${Disclosure} {
opacity: 1;
}
}
`;
const Label = styled.div<{ $ellipsis: boolean }>`
const Label = styled.div`
position: relative;
width: 100%;
line-height: 24px;
margin-left: 2px;
${(props) => props.$ellipsis && ellipsis()}
* {
unicode-bidi: plaintext;
@@ -18,6 +18,7 @@ import {
} from "../hooks/useDragAndDrop";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
@@ -41,7 +42,7 @@ type StarredDocumentLinkProps = {
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: React.MouseEventHandler<HTMLElement>;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
handlePrefetch: () => void;
icon: React.ReactNode;
label: React.ReactNode;
@@ -184,7 +185,13 @@ function StarredCollectionLink({
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<Relative>{cursor}</Relative>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
);
}
@@ -234,7 +241,7 @@ function StarredLink({ star }: Props) {
}, [documentId, documents]);
const handleDisclosureClick = React.useCallback(
(ev?: React.MouseEvent<HTMLElement>) => {
(ev?: React.MouseEvent<HTMLButtonElement>) => {
ev?.preventDefault();
ev?.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
@@ -24,7 +24,12 @@ function TrashLink() {
title: t("Delete {{ documentName }}", {
documentName: document?.noun,
}),
content: <DocumentDelete documents={[document]} />,
content: (
<DocumentDelete
document={document}
onSubmit={dialogs.closeAllModals}
/>
),
});
},
canDrop: (item) => policies.abilities(item.id).delete,
@@ -176,7 +176,6 @@ export function useDragDocument(
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
const initial = document?.initial || node.title;
const [{ isDragging }, draggableRef, preview] = useDrag<
DragObject,
@@ -188,9 +187,7 @@ export function useDragDocument(
({
...node,
depth,
icon: icon ? (
<Icon initial={initial} value={icon} color={color} />
) : undefined,
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
}) as DragObject,
canDrag: () => !!document?.isActive && !isEditing,
-115
View File
@@ -1,115 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import lazyWithRetry from "~/utils/lazyWithRetry";
import useMobile from "~/hooks/useMobile";
import DelayedMount from "./DelayedMount";
import { Drawer, DrawerContent, DrawerTrigger } from "./primitives/Drawer";
import { Popover, PopoverTrigger, PopoverContent } from "./primitives/Popover";
import Text from "./Text";
import { ColorButton } from "./ColorButton";
/**
* Props for the SwatchButton component.
*/
type SwatchButtonProps = {
/** The current color value in hex format. If no color is passed a radial gradient will be shown */
color?: string;
/** Whether the swatch button is currently active/selected */
active?: boolean;
/** The size of the button in pixels */
size?: number;
/** Callback function invoked when the color is changed */
onChange: (color: string) => void;
/** Additional CSS class name to apply to the button */
className?: string;
/** Whether to render the color picker in a modal popover. Defaults to true */
pickerInModal?: boolean;
};
export const SwatchButton: React.FC<SwatchButtonProps> = ({
color,
active = false,
size = 24,
onChange,
className,
pickerInModal = true,
}) => {
const { t } = useTranslation();
const isMobile = useMobile();
const pickerTrigger = (
<ColorButton
aria-label={t("Select a color")}
className={className}
color={color}
active={active}
size={size}
/>
);
const pickerContent = (
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={color}
onChange={(c) => onChange(c.hex)}
/>
</React.Suspense>
);
if (isMobile) {
return (
<Drawer>
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
<DrawerContent aria-label={t("Select a color")}>
{pickerContent}
</DrawerContent>
</Drawer>
);
}
return (
<Popover modal={pickerInModal}>
<PopoverTrigger>{pickerTrigger}</PopoverTrigger>
<StyledContent
side="bottom"
align="end"
aria-label={t("Select a color")}
shrink
>
{pickerContent}
</StyledContent>
</Popover>
);
};
const StyledContent = styled(PopoverContent)`
width: auto;
padding: 8px;
`;
const ColorPicker = lazyWithRetry(
() => import("react-color/lib/components/chrome/Chrome")
);
const StyledColorPicker = styled(ColorPicker)`
background: inherit !important;
box-shadow: none !important;
border: 0 !important;
border-radius: 0 !important;
user-select: none;
width: auto !important;
input {
user-select: text;
color: ${s("text")} !important;
}
`;
+1 -7
View File
@@ -219,11 +219,7 @@ function Table<TData>({
$columns={gridColumns}
>
{row.getAllCells().map((cell) => (
<TD
role="cell"
key={cell.id}
className={cell.column.id === "action" ? "actions" : ""}
>
<TD role="cell" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TD>
))}
@@ -388,8 +384,6 @@ const TD = styled.span`
}
${NudeButton}[aria-haspopup="menu"] {
vertical-align: middle;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
+2
View File
@@ -1,4 +1,5 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import { Avatar } from "./Avatar";
import { AvatarVariant } from "./Avatar/Avatar";
@@ -6,6 +7,7 @@ const TeamLogo = styled(Avatar).attrs({
variant: AvatarVariant.Square,
})`
border-radius: 4px;
box-shadow: inset 0 0 0 1px ${s("divider")};
border: 0;
`;
+3 -4
View File
@@ -30,10 +30,9 @@ const Theme: React.FC = ({ children }: Props) => {
<ThemeProvider theme={theme}>
<>
<GlobalStyles
useCursorPointer={
// Default to showing the cursor pointer if no user is logged in (public share)
auth.user?.getPreference(UserPreference.UseCursorPointer) ?? true
}
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer
)}
/>
{children}
</>

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