mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
293 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b9275dff0 | |||
| 39f0f78ff4 | |||
| b2a0a9cf21 | |||
| c0ee1aa3d7 | |||
| b9e34e4227 | |||
| b405e1e985 | |||
| e0e6b3f3db | |||
| b2b0bd8c8f | |||
| 1a8d75b81b | |||
| 6c3816e07c | |||
| d264848024 | |||
| 65a3d1ac47 | |||
| af98549ca7 | |||
| ce1d2a90c0 | |||
| 9746df193c | |||
| 9c3956f72d | |||
| 0d51b43ebe | |||
| 6976f01d7d | |||
| d02f35b770 | |||
| 55f21bfbb3 | |||
| 1d2f6e7c55 | |||
| 321d0ee124 | |||
| 94252672f8 | |||
| a8048962f6 | |||
| f009236144 | |||
| 977e01e96a | |||
| 66c31c2b99 | |||
| 61e06cfe86 | |||
| d75f8d64db | |||
| 8546a2bada | |||
| 430883f186 | |||
| 25a1bf6889 | |||
| 71d15a3f9b | |||
| ac06a06a66 | |||
| d9c50edd98 | |||
| b90da88341 | |||
| 1c6fd082a0 | |||
| 19858845ff | |||
| 65db323ce6 | |||
| 7ce407910e | |||
| e85fbf3299 | |||
| 42959d66db | |||
| 4212e0e8d4 | |||
| 8b205fdf09 | |||
| df5fd8d0db | |||
| b6a8986235 | |||
| ac820e4e2a | |||
| e3c5be6e57 | |||
| 9dfdf9a1ec | |||
| 9c0d6fcc42 | |||
| 8b2214fa5e | |||
| 747fde1105 | |||
| b51692cdc5 | |||
| 74f66af232 | |||
| 8ba1dfb708 | |||
| cae0c5c8fc | |||
| e767ec34db | |||
| 1be502105c | |||
| 82021b685e | |||
| 9925c692c1 | |||
| 142985c6d7 | |||
| f4d9b6b257 | |||
| 893e451f7f | |||
| bd9b54e8dc | |||
| 1e36b459dc | |||
| 2abc1e97ae | |||
| 8619ef2bea | |||
| a9263afa2c | |||
| 9d60deae60 | |||
| 5cc7601972 | |||
| 5bc2e7e62e | |||
| 22317e550a | |||
| 64526538ec | |||
| 8ab107571d | |||
| c7d7c3a66f | |||
| 0d1c8490b5 | |||
| 9bdf904b7e | |||
| 1c0191f9ed | |||
| c034255c96 | |||
| 66474c82a3 | |||
| 1125581fd0 | |||
| 6cfc7da40b | |||
| 523526b236 | |||
| d5e651436b | |||
| 31dee071dd | |||
| 9a180e486d | |||
| 5a8a8d3fb0 | |||
| d242eb1d5a | |||
| 7adb64ff39 | |||
| 8e95f13793 | |||
| c2762ce087 | |||
| 9ebcf6cc4c | |||
| 8b9c4962a6 | |||
| b134aa8220 | |||
| 8033416053 | |||
| 958c9e1e66 | |||
| 2e9847e8b9 | |||
| 6a564a575c | |||
| 4abd36195c | |||
| 4b6db34583 | |||
| 83bb628a90 | |||
| 6dd4e846b7 | |||
| 468620b208 | |||
| f9b137e5f8 | |||
| 105fbbf6e1 | |||
| 7f657f43a1 | |||
| e6100c4bc9 | |||
| e85e31a481 | |||
| 2c74a801a4 | |||
| 77e6f0c6a3 | |||
| 7a0e88cd3c | |||
| 959dccf119 | |||
| 40f8cbaa0f | |||
| e9daf9c292 | |||
| ef94f10fae | |||
| 73c607896d | |||
| 99167bbdd6 | |||
| f683822852 | |||
| 9c065b229c | |||
| a544a01835 | |||
| 90e19e8097 | |||
| 3f3a70d996 | |||
| 265f2721f8 | |||
| 636153a56b | |||
| c44a3c0f69 | |||
| c9076b0be0 | |||
| a533d0c462 | |||
| 72bf9b86f6 | |||
| b925854247 | |||
| 3ca7c7369e | |||
| cb153ded8f | |||
| 5761903407 | |||
| 248cc1ba8b | |||
| ad0f0b39b8 | |||
| cdcff3899d | |||
| 7be11eb44e | |||
| 5a77217d25 | |||
| 89425ccdab | |||
| 1c64b6c93f | |||
| c2d516c5f1 | |||
| e4268c9a1f | |||
| bf9065d6e6 | |||
| 3e5ae49ad9 | |||
| 9a4d754a39 | |||
| 0009a08278 | |||
| 84bc914940 | |||
| da6a449cf3 | |||
| 4631b5ccaa | |||
| 4d5895d2a8 | |||
| 3543fafee3 | |||
| e77cdc2903 | |||
| ecba11b786 | |||
| 6d13347806 | |||
| 36773febd2 | |||
| fa8d82d82a | |||
| cc6d2dc471 | |||
| 5035ad2027 | |||
| 06ec6fdfbb | |||
| acc8d99ca0 | |||
| 7da3108412 | |||
| 7e56d04285 | |||
| 3987b7de3d | |||
| 6daed33b4a | |||
| 3551d16bd8 | |||
| 641c0da603 | |||
| 7768273255 | |||
| 9cadcc668c | |||
| adc11aee9f | |||
| 7ab247f367 | |||
| 9ec5c473f1 | |||
| 02bdb2e464 | |||
| 77d50f8323 | |||
| 76691e8aaa | |||
| 633d41e67f | |||
| 3db845b395 | |||
| 3269eacf68 | |||
| eef2ea4347 | |||
| a2ce13a7dd | |||
| ff13f1a452 | |||
| a5d065e5ec | |||
| fc6152bd55 | |||
| 06d4d7e893 | |||
| a85f36d896 | |||
| 5231318e55 | |||
| 916032508c | |||
| 1a3478a228 | |||
| 1028edaa03 | |||
| 6a736072f0 | |||
| 94f302f712 | |||
| d2ef7e770d | |||
| 323094ce57 | |||
| 0e596f61c8 | |||
| a23888f5d6 | |||
| 515e160bdb | |||
| c853063d1f | |||
| e86593f234 | |||
| 285b770b3d | |||
| 2c27ef9c2c | |||
| 3704dc2a4d | |||
| d37422ab8a | |||
| a75af8759b | |||
| 7c048ef168 | |||
| b3b4ed1dc0 | |||
| 1417a4b958 | |||
| c33d9fd6ec | |||
| 84b874c1a3 | |||
| 2da2081b6f | |||
| 0c3c92aebf | |||
| 6ed666fb38 | |||
| 79ea6279d5 | |||
| fd7f359489 | |||
| 3d7f971d86 | |||
| 9e8f206ebf | |||
| 61d8c2bdb6 | |||
| e77d918871 | |||
| dddf28a834 | |||
| b694250f51 | |||
| d7374730e3 | |||
| 908d0408f5 | |||
| 269bd60b5a | |||
| 87c03fd088 | |||
| b9a8b0f6d6 | |||
| 34ee3b7ea7 | |||
| 5ffe02bcc0 | |||
| 670428d322 | |||
| 3e58a6ca46 | |||
| b21d548d06 | |||
| cadbd0d698 | |||
| 6fdba0ecba | |||
| bb72774f2d | |||
| 76868a3083 | |||
| 0865052bb8 | |||
| de6bc9beca | |||
| e97944ab40 | |||
| 5cfea207e6 | |||
| 95f0c42d56 | |||
| ee7738c141 | |||
| 76701e35ec | |||
| ae8c2aae15 | |||
| a9fa2ed72b | |||
| 0deb7e7f09 | |||
| a544559de2 | |||
| 79fe08e9b6 | |||
| c8d8ba3914 | |||
| 7a148b0353 | |||
| ca891a56da | |||
| 294d3e896a | |||
| d947f8fda2 | |||
| 6dd228a533 | |||
| c7d847215c | |||
| 6995ca8521 | |||
| 8a3452e664 | |||
| f6315875b4 | |||
| f4e53da1bf | |||
| 643188b2f3 | |||
| 6f8f25b0d1 | |||
| 10c3edded7 | |||
| 398943d084 | |||
| a02677c2b1 | |||
| ebf2029539 | |||
| 0df42cb4c7 | |||
| 72c9091b7e | |||
| 740e33156d | |||
| d8ef7b2892 | |||
| 0f9146066c | |||
| 06a1428cbc | |||
| e71a425268 | |||
| 12d31468f8 | |||
| 211c57f6aa | |||
| bb475f3e4e | |||
| 9b95a58822 | |||
| fce02996f9 | |||
| 1aa05b797c | |||
| b69feb50a7 | |||
| 640ecca9ca | |||
| 5fbaa32f18 | |||
| 50b2cf2706 | |||
| db9deb2a46 | |||
| 72cc740b1c | |||
| 4d9717631d | |||
| 69e07a9c21 | |||
| 7b27b74e24 | |||
| 92db179230 | |||
| fa93092f79 | |||
| 63c5938a43 | |||
| a4f77e4438 | |||
| 7bb8ff4797 | |||
| bcdedd53d8 | |||
| 37b18ab940 | |||
| 42d699fabe | |||
| 8026dac146 | |||
| a5e1f613fc | |||
| 3d1f55b605 |
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Close unsigned PRs
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const now = new Date();
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- name: Checkout Branch
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v5
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
minPctChange: "10"
|
||||
- name: Create Pull Request
|
||||
# If it's not a Pull Request then commit any changes as a new PR.
|
||||
if: |
|
||||
|
||||
+13
-13
@@ -25,9 +25,9 @@ jobs:
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
@@ -38,8 +38,8 @@ jobs:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
@@ -50,8 +50,8 @@ jobs:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
@@ -92,8 +92,8 @@ jobs:
|
||||
matrix:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
@@ -124,8 +124,8 @@ jobs:
|
||||
shard: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
@@ -141,8 +141,8 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
if: startsWith(github.actor, 'codegen-sh')
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# Give the default GITHUB_TOKEN write permission to commit and push the
|
||||
# added or changed files to the repository.
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint --fix
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "Applied automatic fixes"
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
|
||||
+3
-4
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"workerIdleMemoryLimit": "0.75",
|
||||
"maxWorkers": "50%",
|
||||
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
@@ -20,8 +21,7 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
@@ -48,8 +48,7 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
|
||||
var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
'config': path.resolve('server/config', 'database.json'),
|
||||
'config': path.resolve('server/config', 'database.js'),
|
||||
'migrations-path': path.resolve('server', 'migrations'),
|
||||
'models-path': path.resolve('server', 'models'),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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.
|
||||
+12
-13
@@ -6,7 +6,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:22-slim AS runner
|
||||
FROM node:22.21.0-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
@@ -14,7 +14,13 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=base $APP_PATH/build ./build
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
COPY --from=base $APP_PATH/public ./public
|
||||
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
@@ -23,20 +29,13 @@ COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
# Install wget to healthcheck the server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
|
||||
VOLUME /var/lib/outline/data
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:20 AS deps
|
||||
FROM node:22.21.0 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.87.4
|
||||
Licensed Work: Outline 1.1.0
|
||||
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-09-18
|
||||
Change Date: 2029-11-16
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -194,6 +194,11 @@
|
||||
"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
|
||||
|
||||
+7
-7
@@ -5,6 +5,13 @@
|
||||
{
|
||||
"files": ["**/*.{jsx,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
"name": "crypto",
|
||||
"message": "Do not use, does not work in environments without SSL."
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
@@ -13,13 +20,6 @@
|
||||
"group": ["mime-types"],
|
||||
"message": "Do not use the mime-types package in the browser."
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
"importNames": ["useMenuState"],
|
||||
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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, createActionV2 } from "..";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
|
||||
export const createApiKey = createAction({
|
||||
@@ -26,7 +26,7 @@ export const createApiKey = createAction({
|
||||
});
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createActionV2({
|
||||
createAction({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu
|
||||
? apiKey.isExpired
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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,
|
||||
];
|
||||
@@ -1,8 +1,12 @@
|
||||
import {
|
||||
AlphabeticalReverseSortIcon,
|
||||
AlphabeticalSortIcon,
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
ManualSortIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
@@ -22,12 +26,11 @@ import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createInternalLinkActionV2,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
@@ -37,12 +40,18 @@ import {
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
);
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Collection/SharePopover")
|
||||
);
|
||||
|
||||
export const openCollection = createAction({
|
||||
export const openCollection = createActionWithChildren({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
analyticsName: "Open collection",
|
||||
section: CollectionSection,
|
||||
@@ -50,15 +59,17 @@ export const openCollection = createAction({
|
||||
icon: <CollectionIcon />,
|
||||
children: ({ stores }) => {
|
||||
const collections = stores.collections.orderedData;
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,7 +91,7 @@ export const createCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollection = createActionV2({
|
||||
export const editCollection = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}…` : t("Edit collection")),
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -105,7 +116,7 @@ export const editCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollectionPermissions = createActionV2({
|
||||
export const editCollectionPermissions = createAction({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "Collection permissions",
|
||||
@@ -137,7 +148,130 @@ export const editCollectionPermissions = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const searchInCollection = createInternalLinkActionV2({
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypesString;
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const sortCollection = createActionWithChildren({
|
||||
name: ({ t }) => t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
!!stores.policies.abilities(activeCollectionId).update,
|
||||
icon: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
const sortAlphabetical = collection?.sort.field === "title";
|
||||
const sortDir = collection?.sort.direction;
|
||||
|
||||
return sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<AlphabeticalSortIcon />
|
||||
) : (
|
||||
<AlphabeticalReverseSortIcon />
|
||||
)
|
||||
) : (
|
||||
<ManualSortIcon />
|
||||
);
|
||||
},
|
||||
children: [
|
||||
createAction({
|
||||
name: ({ t }) => t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "asc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createAction({
|
||||
name: ({ t }) => t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "desc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "desc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createAction({
|
||||
name: ({ t }) => t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.sort.field !== "title";
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "index",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const searchInCollection = createInternalLinkAction({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -168,7 +302,7 @@ export const searchInCollection = createInternalLinkActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const starCollection = createActionV2({
|
||||
export const starCollection = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -195,7 +329,7 @@ export const starCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarCollection = createActionV2({
|
||||
export const unstarCollection = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -221,7 +355,7 @@ export const unstarCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeCollection = createActionV2({
|
||||
export const subscribeCollection = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -252,7 +386,7 @@ export const subscribeCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeCollection = createActionV2({
|
||||
export const unsubscribeCollection = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -283,7 +417,7 @@ export const unsubscribeCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createActionV2({
|
||||
export const archiveCollection = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -324,7 +458,7 @@ export const archiveCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createActionV2({
|
||||
export const restoreCollection = createAction({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
@@ -349,7 +483,7 @@ export const restoreCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createActionV2({
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -383,7 +517,7 @@ export const deleteCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const exportCollection = createActionV2({
|
||||
export const exportCollection = createAction({
|
||||
name: ({ t }) => `${t("Export")}…`,
|
||||
analyticsName: "Export collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -393,10 +527,7 @@ export const exportCollection = createActionV2({
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!stores.policies.abilities(currentTeamId).createExport &&
|
||||
!!stores.policies.abilities(activeCollectionId).export
|
||||
);
|
||||
return !!stores.policies.abilities(activeCollectionId).export;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
@@ -419,7 +550,7 @@ export const exportCollection = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createInternalLinkActionV2({
|
||||
export const createDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -441,7 +572,7 @@ export const createDocument = createInternalLinkActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createInternalLinkActionV2({
|
||||
export const createTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: ActiveCollectionSection,
|
||||
|
||||
@@ -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 { createActionV2 } from "..";
|
||||
import { createAction } from "..";
|
||||
import { ActiveDocumentSection } from "../sections";
|
||||
|
||||
export const deleteCommentFactory = ({
|
||||
@@ -13,7 +13,7 @@ export const deleteCommentFactory = ({
|
||||
comment: Comment;
|
||||
onDelete: () => void;
|
||||
}) =>
|
||||
createActionV2({
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete comment",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -39,7 +39,7 @@ export const resolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onResolve: () => void;
|
||||
}) =>
|
||||
createActionV2({
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as resolved"),
|
||||
analyticsName: "Resolve thread",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -61,7 +61,7 @@ export const unresolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onUnresolve: () => void;
|
||||
}) =>
|
||||
createActionV2({
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as unresolved"),
|
||||
analyticsName: "Unresolve thread",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -80,7 +80,7 @@ export const viewCommentReactionsFactory = ({
|
||||
}: {
|
||||
comment: Comment;
|
||||
}) =>
|
||||
createActionV2({
|
||||
createAction({
|
||||
name: ({ t }) => `${t("View reactions")}`,
|
||||
analyticsName: "View comment reactions",
|
||||
section: ActiveDocumentSection,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
UserIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import { createAction } from "~/actions";
|
||||
import { createAction, createActionWithChildren } 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 = createAction({
|
||||
export const copyId = createActionWithChildren({
|
||||
name: ({ t }) => t("Copy ID"),
|
||||
icon: <CopyIcon />,
|
||||
keywords: "uuid",
|
||||
@@ -176,7 +176,22 @@ export const toggleDebugLogging = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleFeatureFlag = createAction({
|
||||
export const toggleDebugSafeArea = createAction({
|
||||
name: () => "Toggle menu safe area debugging",
|
||||
icon: <ToolsIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: ({ stores }) => {
|
||||
stores.ui.toggleDebugSafeArea();
|
||||
toast.message(
|
||||
stores.ui.debugSafeArea
|
||||
? "Menu safe area debugging enabled"
|
||||
: "Menu safe area debugging disabled"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleFeatureFlag = createActionWithChildren({
|
||||
name: "Toggle feature flag",
|
||||
icon: <BeakerIcon />,
|
||||
section: DeveloperSection,
|
||||
@@ -200,7 +215,7 @@ export const toggleFeatureFlag = createAction({
|
||||
),
|
||||
});
|
||||
|
||||
export const developer = createAction({
|
||||
export const developer = createActionWithChildren({
|
||||
name: ({ t }) => t("Development"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
@@ -209,6 +224,7 @@ export const developer = createAction({
|
||||
children: [
|
||||
copyId,
|
||||
toggleDebugLogging,
|
||||
toggleDebugSafeArea,
|
||||
toggleFeatureFlag,
|
||||
createToast,
|
||||
createTestUsers,
|
||||
|
||||
@@ -47,18 +47,15 @@ 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 SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createActionV2Group,
|
||||
createActionV2WithChildren,
|
||||
createInternalLinkActionV2,
|
||||
createActionGroup,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import {
|
||||
ActiveDocumentSection,
|
||||
@@ -81,10 +78,18 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
|
||||
import Insights from "~/scenes/Document/components/Insights";
|
||||
import { Action, ActionGroup, ActionSeparator } from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DocumentArchive from "~/scenes/DocumentArchive";
|
||||
|
||||
export const openDocument = createAction({
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Document/SharePopover")
|
||||
);
|
||||
|
||||
export const openDocument = createActionWithChildren({
|
||||
name: ({ t }) => t("Open document"),
|
||||
analyticsName: "Open document",
|
||||
section: DocumentSection,
|
||||
@@ -98,23 +103,29 @@ export const openDocument = createAction({
|
||||
);
|
||||
const documents = stores.documents.orderedData;
|
||||
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const editDocument = createInternalLinkActionV2({
|
||||
export const editDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("Edit"),
|
||||
analyticsName: "Edit document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -146,7 +157,7 @@ export const editDocument = createInternalLinkActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createAction({
|
||||
export const createDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -164,13 +175,18 @@ export const createDocument = createAction({
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
history.push(newDocumentPath(activeCollectionId), {
|
||||
sidebarContext,
|
||||
}),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createDraftDocument = createAction({
|
||||
export const createDraftDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("New draft"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -178,13 +194,13 @@ export const createDraftDocument = createAction({
|
||||
keywords: "create document",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
perform: ({ sidebarContext }) =>
|
||||
history.push(newDocumentPath(), {
|
||||
sidebarContext,
|
||||
}),
|
||||
to: ({ sidebarContext }) => ({
|
||||
pathname: newDocumentPath(),
|
||||
state: { sidebarContext },
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkActionV2({
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -231,7 +247,7 @@ export const createDocumentFromTemplate = createInternalLinkActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const createNestedDocument = createInternalLinkActionV2({
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -254,7 +270,7 @@ export const createNestedDocument = createInternalLinkActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const starDocument = createActionV2({
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -280,7 +296,7 @@ export const starDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarDocument = createActionV2({
|
||||
export const unstarDocument = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -306,7 +322,7 @@ export const unstarDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const publishDocument = createActionV2({
|
||||
export const publishDocument = createAction({
|
||||
name: ({ t }) => t("Publish"),
|
||||
analyticsName: "Publish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -348,7 +364,7 @@ export const publishDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const unpublishDocument = createActionV2({
|
||||
export const unpublishDocument = createAction({
|
||||
name: ({ t }) => t("Unpublish"),
|
||||
analyticsName: "Unpublish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -379,7 +395,7 @@ export const unpublishDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeDocument = createActionV2({
|
||||
export const subscribeDocument = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -425,7 +441,7 @@ export const subscribeDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeDocument = createActionV2({
|
||||
export const unsubscribeDocument = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -473,7 +489,7 @@ export const unsubscribeDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const shareDocument = createActionV2({
|
||||
export const shareDocument = createAction({
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
analyticsName: "Share document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -506,7 +522,7 @@ export const shareDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsHTML = createActionV2({
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -525,7 +541,7 @@ export const downloadDocumentAsHTML = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createActionV2({
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -551,7 +567,7 @@ export const downloadDocumentAsPDF = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createActionV2({
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -570,7 +586,7 @@ export const downloadDocumentAsMarkdown = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createActionV2WithChildren({
|
||||
export const downloadDocument = createActionWithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -585,7 +601,7 @@ export const downloadDocument = createActionV2WithChildren({
|
||||
],
|
||||
});
|
||||
|
||||
export const copyDocumentAsMarkdown = createActionV2({
|
||||
export const copyDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -593,18 +609,21 @@ export const copyDocumentAsMarkdown = createActionV2({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
perform: async ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toMarkdown());
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toMarkdown(document));
|
||||
toast.success(t("Markdown copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentAsPlainText = createActionV2({
|
||||
export const copyDocumentAsPlainText = createAction({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -612,18 +631,21 @@ export const copyDocumentAsPlainText = createActionV2({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
perform: async ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toPlainText());
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toPlainText(document));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentShareLink = createActionV2({
|
||||
export const copyDocumentShareLink = createAction({
|
||||
name: ({ t }) => t("Copy public link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard share",
|
||||
@@ -644,7 +666,7 @@ export const copyDocumentShareLink = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentLink = createActionV2({
|
||||
export const copyDocumentLink = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -662,7 +684,7 @@ export const copyDocumentLink = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocument = createActionV2WithChildren({
|
||||
export const copyDocument = createActionWithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -676,7 +698,7 @@ export const copyDocument = createActionV2WithChildren({
|
||||
],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createActionV2({
|
||||
export const duplicateDocument = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
|
||||
analyticsName: "Duplicate document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -711,7 +733,7 @@ export const duplicateDocument = createActionV2({
|
||||
* 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 = createActionV2({
|
||||
export const pinDocumentToCollection = createAction({
|
||||
name: ({ activeDocumentId = "", t, stores }) => {
|
||||
const selectedDocument = stores.documents.get(activeDocumentId);
|
||||
const collectionName = selectedDocument
|
||||
@@ -756,7 +778,7 @@ export const pinDocumentToCollection = createActionV2({
|
||||
* 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 = createActionV2({
|
||||
export const pinDocumentToHome = createAction({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
analyticsName: "Pin document to home",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -788,7 +810,7 @@ export const pinDocumentToHome = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const pinDocument = createActionV2WithChildren({
|
||||
export const pinDocument = createActionWithChildren({
|
||||
name: ({ t }) => t("Pin"),
|
||||
analyticsName: "Pin document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -796,7 +818,7 @@ export const pinDocument = createActionV2WithChildren({
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
});
|
||||
|
||||
export const searchInDocument = createInternalLinkActionV2({
|
||||
export const searchInDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -826,7 +848,7 @@ export const searchInDocument = createInternalLinkActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const printDocument = createActionV2({
|
||||
export const printDocument = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
|
||||
analyticsName: "Print document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -837,7 +859,7 @@ export const printDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createActionV2({
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: DocumentSection,
|
||||
@@ -849,7 +871,7 @@ export const importDocument = createActionV2({
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).update;
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -858,11 +880,10 @@ export const importDocument = createActionV2({
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
input.accept = documents.importFileTypesString;
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
@@ -884,7 +905,7 @@ export const importDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplateFromDocument = createActionV2({
|
||||
export const createTemplateFromDocument = createAction({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -935,7 +956,7 @@ export const openRandomDocument = createAction({
|
||||
});
|
||||
|
||||
export const searchDocumentsForQuery = (query: string) =>
|
||||
createAction({
|
||||
createInternalLinkAction({
|
||||
id: "search",
|
||||
name: ({ t }) =>
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
|
||||
@@ -946,7 +967,7 @@ export const searchDocumentsForQuery = (query: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createActionV2({
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
@@ -976,7 +997,7 @@ export const moveTemplateToWorkspace = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createActionV2({
|
||||
export const moveDocumentToCollection = createAction({
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
@@ -1007,13 +1028,13 @@ export const moveDocumentToCollection = createActionV2({
|
||||
title: t("Move {{ documentType }}", {
|
||||
documentType: document.noun,
|
||||
}),
|
||||
content: <DocumentMove document={document} />,
|
||||
content: <DocumentMove documents={[document]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocument = createActionV2({
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1032,7 +1053,7 @@ export const moveDocument = createActionV2({
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionV2WithChildren({
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1051,7 +1072,7 @@ export const moveTemplate = createActionV2WithChildren({
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createActionV2({
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1073,25 +1094,13 @@ export const archiveDocument = createActionV2({
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to archive this 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>
|
||||
),
|
||||
content: <DocumentArchive documents={[document]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreDocument = createActionV2({
|
||||
export const restoreDocument = createAction({
|
||||
name: ({ t }) => `${t("Restore")}`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1131,7 +1140,7 @@ export const restoreDocument = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreDocumentToCollection = createActionV2WithChildren({
|
||||
export const restoreDocumentToCollection = createActionWithChildren({
|
||||
name: ({ t }) => `${t("Restore")}…`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1166,7 +1175,7 @@ export const restoreDocumentToCollection = createActionV2WithChildren({
|
||||
|
||||
const actions = collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
return createActionV2({
|
||||
return createAction({
|
||||
name: collection.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
@@ -1182,11 +1191,11 @@ export const restoreDocumentToCollection = createActionV2WithChildren({
|
||||
});
|
||||
});
|
||||
|
||||
return [createActionV2Group({ name: t("Choose a collection"), actions })];
|
||||
return [createActionGroup({ name: t("Choose a collection"), actions })];
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createActionV2({
|
||||
export const deleteDocument = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1209,18 +1218,13 @@ export const deleteDocument = createActionV2({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
content: (
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
content: <DocumentDelete documents={[document]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const permanentlyDeleteDocument = createActionV2({
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
analyticsName: "Permanently delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1275,7 +1279,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentComments = createActionV2({
|
||||
export const openDocumentComments = createAction({
|
||||
name: ({ t }) => t("Comments"),
|
||||
analyticsName: "Open comments",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1298,7 +1302,7 @@ export const openDocumentComments = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentHistory = createInternalLinkActionV2({
|
||||
export const openDocumentHistory = createInternalLinkAction({
|
||||
name: ({ t }) => t("History"),
|
||||
analyticsName: "Open document history",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1325,7 +1329,7 @@ export const openDocumentHistory = createInternalLinkActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInsights = createActionV2({
|
||||
export const openDocumentInsights = createAction({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1358,7 +1362,7 @@ export const openDocumentInsights = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const leaveDocument = createActionV2({
|
||||
export const leaveDocument = createAction({
|
||||
name: ({ t }) => t("Leave document"),
|
||||
analyticsName: "Leave document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1397,9 +1401,9 @@ export const leaveDocument = createActionV2({
|
||||
export const applyTemplateFactory = ({
|
||||
actions,
|
||||
}: {
|
||||
actions: (ActionV2 | ActionV2Group | ActionV2Separator)[];
|
||||
actions: (Action | ActionGroup | ActionSeparator)[];
|
||||
}) =>
|
||||
createActionV2WithChildren({
|
||||
createActionWithChildren({
|
||||
name: ({ t }) => t("Apply template"),
|
||||
analyticsName: "Apply template",
|
||||
section: ActiveDocumentSection,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -3,8 +3,27 @@ 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 { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
|
||||
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"));
|
||||
},
|
||||
});
|
||||
|
||||
export const disconnectAnalyticsIntegrationFactory = (
|
||||
integration?: Integration<IntegrationType.Analytics>
|
||||
|
||||
@@ -21,9 +21,8 @@ import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createExternalLinkActionV2,
|
||||
createInternalLinkActionV2,
|
||||
createExternalLinkAction,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
@@ -37,7 +36,7 @@ import {
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const navigateToHome = createAction({
|
||||
export const navigateToHome = createInternalLinkAction({
|
||||
name: ({ t }) => t("Home"),
|
||||
analyticsName: "Navigate to home",
|
||||
section: NavigationSection,
|
||||
@@ -48,7 +47,7 @@ export const navigateToHome = createAction({
|
||||
});
|
||||
|
||||
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
createAction({
|
||||
createInternalLinkAction({
|
||||
section: RecentSearchesSection,
|
||||
name: searchQuery.query,
|
||||
analyticsName: "Navigate to recent search query",
|
||||
@@ -56,7 +55,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
to: searchPath({ query: searchQuery.query }),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createAction({
|
||||
export const navigateToDrafts = createInternalLinkAction({
|
||||
name: ({ t }) => t("Drafts"),
|
||||
analyticsName: "Navigate to drafts",
|
||||
section: NavigationSection,
|
||||
@@ -65,7 +64,7 @@ export const navigateToDrafts = createAction({
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToSearch = createAction({
|
||||
export const navigateToSearch = createInternalLinkAction({
|
||||
name: ({ t }) => t("Search"),
|
||||
analyticsName: "Navigate to search",
|
||||
section: NavigationSection,
|
||||
@@ -74,7 +73,7 @@ export const navigateToSearch = createAction({
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createAction({
|
||||
export const navigateToArchive = createInternalLinkAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Navigate to archive",
|
||||
section: NavigationSection,
|
||||
@@ -84,7 +83,7 @@ export const navigateToArchive = createAction({
|
||||
visible: ({ location }) => location.pathname !== archivePath(),
|
||||
});
|
||||
|
||||
export const navigateToTrash = createAction({
|
||||
export const navigateToTrash = createInternalLinkAction({
|
||||
name: ({ t }) => t("Trash"),
|
||||
analyticsName: "Navigate to trash",
|
||||
section: NavigationSection,
|
||||
@@ -93,7 +92,7 @@ export const navigateToTrash = createAction({
|
||||
visible: ({ location }) => location.pathname !== trashPath(),
|
||||
});
|
||||
|
||||
export const navigateToSettings = createAction({
|
||||
export const navigateToSettings = createInternalLinkAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to settings",
|
||||
section: NavigationSection,
|
||||
@@ -103,7 +102,7 @@ export const navigateToSettings = createAction({
|
||||
to: settingsPath(),
|
||||
});
|
||||
|
||||
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
|
||||
export const navigateToWorkspaceSettings = createInternalLinkAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to workspace settings",
|
||||
section: NavigationSection,
|
||||
@@ -112,7 +111,7 @@ export const navigateToWorkspaceSettings = createInternalLinkActionV2({
|
||||
to: settingsPath("details"),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createInternalLinkActionV2({
|
||||
export const navigateToProfileSettings = createInternalLinkAction({
|
||||
name: ({ t }) => t("Profile"),
|
||||
analyticsName: "Navigate to profile settings",
|
||||
section: NavigationSection,
|
||||
@@ -121,7 +120,7 @@ export const navigateToProfileSettings = createInternalLinkActionV2({
|
||||
to: settingsPath(),
|
||||
});
|
||||
|
||||
export const navigateToTemplateSettings = createAction({
|
||||
export const navigateToTemplateSettings = createInternalLinkAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
analyticsName: "Navigate to template settings",
|
||||
section: NavigationSection,
|
||||
@@ -130,7 +129,7 @@ export const navigateToTemplateSettings = createAction({
|
||||
to: settingsPath("templates"),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createInternalLinkActionV2({
|
||||
export const navigateToNotificationSettings = createInternalLinkAction({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? t("Notification settings") : t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
@@ -140,7 +139,7 @@ export const navigateToNotificationSettings = createInternalLinkActionV2({
|
||||
to: settingsPath("notifications"),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createInternalLinkActionV2({
|
||||
export const navigateToAccountPreferences = createInternalLinkAction({
|
||||
name: ({ t }) => t("Preferences"),
|
||||
analyticsName: "Navigate to account preferences",
|
||||
section: NavigationSection,
|
||||
@@ -149,7 +148,7 @@ export const navigateToAccountPreferences = createInternalLinkActionV2({
|
||||
to: settingsPath("preferences"),
|
||||
});
|
||||
|
||||
export const openDocumentation = createExternalLinkActionV2({
|
||||
export const openDocumentation = createExternalLinkAction({
|
||||
name: ({ t }) => t("Documentation"),
|
||||
analyticsName: "Open documentation",
|
||||
section: NavigationSection,
|
||||
@@ -159,7 +158,7 @@ export const openDocumentation = createExternalLinkActionV2({
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createExternalLinkActionV2({
|
||||
export const openAPIDocumentation = createExternalLinkAction({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
analyticsName: "Open API documentation",
|
||||
section: NavigationSection,
|
||||
@@ -177,7 +176,7 @@ export const toggleSidebar = createAction({
|
||||
perform: () => stores.ui.toggleCollapsedSidebar(),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createExternalLinkActionV2({
|
||||
export const openFeedbackUrl = createExternalLinkAction({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
section: NavigationSection,
|
||||
@@ -187,7 +186,7 @@ export const openFeedbackUrl = createExternalLinkActionV2({
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openBugReportUrl = createExternalLinkActionV2({
|
||||
export const openBugReportUrl = createExternalLinkAction({
|
||||
name: ({ t }) => t("Report a bug"),
|
||||
analyticsName: "Open bug report",
|
||||
section: NavigationSection,
|
||||
@@ -197,7 +196,7 @@ export const openBugReportUrl = createExternalLinkActionV2({
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openChangelog = createExternalLinkActionV2({
|
||||
export const openChangelog = createExternalLinkAction({
|
||||
name: ({ t }) => t("Changelog"),
|
||||
analyticsName: "Open changelog",
|
||||
section: NavigationSection,
|
||||
@@ -207,7 +206,7 @@ export const openChangelog = createExternalLinkActionV2({
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openKeyboardShortcuts = createActionV2({
|
||||
export const openKeyboardShortcuts = createAction({
|
||||
name: ({ t }) => t("Keyboard shortcuts"),
|
||||
analyticsName: "Open keyboard shortcuts",
|
||||
section: NavigationSection,
|
||||
@@ -222,7 +221,7 @@ export const openKeyboardShortcuts = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadApp = createAction({
|
||||
export const downloadApp = createExternalLinkAction({
|
||||
name: ({ t }) =>
|
||||
t("Download {{ platform }} app", {
|
||||
platform: isMac() ? "macOS" : "Windows",
|
||||
@@ -232,13 +231,11 @@ export const downloadApp = createAction({
|
||||
iconInContextMenu: false,
|
||||
icon: <BrowserIcon />,
|
||||
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
|
||||
to: {
|
||||
url: "https://desktop.getoutline.com",
|
||||
target: "_blank",
|
||||
},
|
||||
url: "https://desktop.getoutline.com",
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const logout = createActionV2({
|
||||
export const logout = createAction({
|
||||
name: ({ t }) => t("Log out"),
|
||||
analyticsName: "Log out",
|
||||
section: NavigationSection,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { createAction, createActionV2 } from "..";
|
||||
import { createAction } 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 = createActionV2({
|
||||
export const markNotificationsAsArchived = createAction({
|
||||
name: ({ t }) => t("Archive all notifications"),
|
||||
analyticsName: "Mark notifications as archived",
|
||||
section: NotificationSection,
|
||||
|
||||
@@ -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, createActionV2 } from "~/actions";
|
||||
import { createAction } 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 = createActionV2({
|
||||
export const restoreRevision = createAction({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore revision",
|
||||
icon: <RestoreIcon />,
|
||||
@@ -73,7 +73,7 @@ export const deleteRevision = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyLinkToRevision = createActionV2({
|
||||
export const copyLinkToRevision = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { createActionV2, createActionV2WithChildren } from "~/actions";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { SettingsSection } from "~/actions/sections";
|
||||
|
||||
export const changeToDarkTheme = createActionV2({
|
||||
export const changeToDarkTheme = createAction({
|
||||
name: ({ t }) => t("Dark"),
|
||||
analyticsName: "Change to dark theme",
|
||||
icon: <MoonIcon />,
|
||||
@@ -14,7 +14,7 @@ export const changeToDarkTheme = createActionV2({
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
|
||||
});
|
||||
|
||||
export const changeToLightTheme = createActionV2({
|
||||
export const changeToLightTheme = createAction({
|
||||
name: ({ t }) => t("Light"),
|
||||
analyticsName: "Change to light theme",
|
||||
icon: <SunIcon />,
|
||||
@@ -25,7 +25,7 @@ export const changeToLightTheme = createActionV2({
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createActionV2({
|
||||
export const changeToSystemTheme = createAction({
|
||||
name: ({ t }) => t("System"),
|
||||
analyticsName: "Change to system theme",
|
||||
icon: <BrowserIcon />,
|
||||
@@ -36,7 +36,7 @@ export const changeToSystemTheme = createActionV2({
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
|
||||
});
|
||||
|
||||
export const changeTheme = createActionV2WithChildren({
|
||||
export const changeTheme = createActionWithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import Share from "~/models/Share";
|
||||
import { createActionV2, createInternalLinkActionV2 } from "..";
|
||||
import { createAction, createInternalLinkAction } 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 }) =>
|
||||
createActionV2({
|
||||
createAction({
|
||||
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 }) =>
|
||||
createInternalLinkActionV2({
|
||||
createInternalLinkAction({
|
||||
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>;
|
||||
}) =>
|
||||
createActionV2({
|
||||
createAction({
|
||||
name: ({ t }) => t("Revoke link"),
|
||||
analyticsName: "Revoke share",
|
||||
section: ShareSection,
|
||||
|
||||
@@ -6,17 +6,17 @@ import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import {
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
createExternalLinkActionV2,
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { ActionContext, ExternalLinkActionV2 } from "~/types";
|
||||
import { ActionContext, ExternalLinkAction } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
|
||||
createExternalLinkActionV2({
|
||||
stores.auth.availableTeams?.map<ExternalLinkAction>((session) =>
|
||||
createExternalLinkAction({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
@@ -41,7 +41,7 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
})
|
||||
) ?? [];
|
||||
|
||||
export const switchTeam = createActionV2WithChildren({
|
||||
export const switchTeam = createActionWithChildren({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
placeholder: ({ t }) => t("Select a workspace"),
|
||||
analyticsName: "Switch workspace",
|
||||
@@ -52,7 +52,7 @@ export const switchTeam = createActionV2WithChildren({
|
||||
children: switchTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createActionV2({
|
||||
export const createTeam = createAction({
|
||||
name: ({ t }) => `${t("New workspace")}…`,
|
||||
analyticsName: "New workspace",
|
||||
keywords: "create change switch workspace organization team",
|
||||
@@ -74,7 +74,7 @@ export const createTeam = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const desktopLoginTeam = createActionV2({
|
||||
export const desktopLoginTeam = createAction({
|
||||
name: ({ t }) => t("Login to workspace"),
|
||||
analyticsName: "Login to workspace",
|
||||
keywords: "change switch workspace organization team",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UserChangeRoleDialog,
|
||||
UserDeleteDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import { createAction, createActionV2 } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
|
||||
export const inviteUser = createAction({
|
||||
@@ -22,13 +22,14 @@ export const inviteUser = createAction({
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Invite to workspace"),
|
||||
width: "500px",
|
||||
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
createActionV2({
|
||||
createAction({
|
||||
name: ({ t }) =>
|
||||
UserRoleHelper.isRoleHigher(role, user!.role)
|
||||
? `${t("Promote to {{ role }}", {
|
||||
@@ -63,7 +64,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
});
|
||||
|
||||
export const deleteUserActionFactory = (userId: string) =>
|
||||
createActionV2({
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete user")}…`,
|
||||
analyticsName: "Delete user",
|
||||
keywords: "leave",
|
||||
|
||||
+38
-192
@@ -1,23 +1,17 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
ActionV2,
|
||||
ActionV2Group,
|
||||
ActionV2Separator as TActionV2Separator,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
ExternalLinkActionV2,
|
||||
InternalLinkActionV2,
|
||||
MenuExternalLink,
|
||||
MenuInternalLink,
|
||||
Action,
|
||||
ActionGroup,
|
||||
ActionSeparator as TActionSeparator,
|
||||
ActionVariant,
|
||||
ActionWithChildren,
|
||||
ExternalLinkAction,
|
||||
InternalLinkAction,
|
||||
MenuItem,
|
||||
MenuItemButton,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import Analytics from "~/utils/Analytics";
|
||||
import history from "~/utils/history";
|
||||
@@ -27,161 +21,13 @@ export function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
}
|
||||
|
||||
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 = {
|
||||
export const ActionSeparator: TActionSeparator = {
|
||||
type: "action_separator",
|
||||
};
|
||||
|
||||
export function createActionV2(
|
||||
definition: Optional<Omit<ActionV2, "type" | "variant">, "id">
|
||||
): ActionV2 {
|
||||
export function createAction(
|
||||
definition: Optional<Omit<Action, "type" | "variant">, "id">
|
||||
): Action {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
@@ -206,9 +52,9 @@ export function createActionV2(
|
||||
};
|
||||
}
|
||||
|
||||
export function createInternalLinkActionV2(
|
||||
definition: Optional<Omit<InternalLinkActionV2, "type" | "variant">, "id">
|
||||
): InternalLinkActionV2 {
|
||||
export function createInternalLinkAction(
|
||||
definition: Optional<Omit<InternalLinkAction, "type" | "variant">, "id">
|
||||
): InternalLinkAction {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
@@ -217,9 +63,9 @@ export function createInternalLinkActionV2(
|
||||
};
|
||||
}
|
||||
|
||||
export function createExternalLinkActionV2(
|
||||
definition: Optional<Omit<ExternalLinkActionV2, "type" | "variant">, "id">
|
||||
): ExternalLinkActionV2 {
|
||||
export function createExternalLinkAction(
|
||||
definition: Optional<Omit<ExternalLinkAction, "type" | "variant">, "id">
|
||||
): ExternalLinkAction {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
@@ -228,9 +74,9 @@ export function createExternalLinkActionV2(
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionV2WithChildren(
|
||||
definition: Optional<Omit<ActionV2WithChildren, "type" | "variant">, "id">
|
||||
): ActionV2WithChildren {
|
||||
export function createActionWithChildren(
|
||||
definition: Optional<Omit<ActionWithChildren, "type" | "variant">, "id">
|
||||
): ActionWithChildren {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
@@ -239,9 +85,9 @@ export function createActionV2WithChildren(
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionV2Group(
|
||||
definition: Omit<ActionV2Group, "type">
|
||||
): ActionV2Group {
|
||||
export function createActionGroup(
|
||||
definition: Omit<ActionGroup, "type">
|
||||
): ActionGroup {
|
||||
return {
|
||||
...definition,
|
||||
type: "action_group",
|
||||
@@ -249,8 +95,8 @@ export function createActionV2Group(
|
||||
}
|
||||
|
||||
export function createRootMenuAction(
|
||||
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
|
||||
): ActionV2WithChildren {
|
||||
actions: (ActionVariant | ActionGroup | TActionSeparator)[]
|
||||
): ActionWithChildren {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: "action",
|
||||
@@ -261,8 +107,8 @@ export function createRootMenuAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function actionV2ToMenuItem(
|
||||
action: ActionV2Variant | ActionV2Group | TActionV2Separator,
|
||||
export function actionToMenuItem(
|
||||
action: ActionVariant | ActionGroup | TActionSeparator,
|
||||
context: ActionContext
|
||||
): MenuItem {
|
||||
switch (action.type) {
|
||||
@@ -286,7 +132,7 @@ export function actionV2ToMenuItem(
|
||||
tooltip: resolve<React.ReactChild>(action.tooltip, context),
|
||||
selected: resolve<boolean>(action.selected, context),
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => performActionV2(action, context),
|
||||
onClick: () => performAction(action, context),
|
||||
};
|
||||
|
||||
case "internal_link": {
|
||||
@@ -315,10 +161,10 @@ export function actionV2ToMenuItem(
|
||||
|
||||
case "action_with_children": {
|
||||
const children = resolve<
|
||||
(ActionV2Variant | ActionV2Group | TActionV2Separator)[]
|
||||
(ActionVariant | ActionGroup | TActionSeparator)[]
|
||||
>(action.children, context);
|
||||
const subMenuItems = children.map((a) =>
|
||||
actionV2ToMenuItem(a, context)
|
||||
actionToMenuItem(a, context)
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
@@ -337,7 +183,7 @@ export function actionV2ToMenuItem(
|
||||
|
||||
case "action_group": {
|
||||
const groupItems = action.actions.map((a) =>
|
||||
actionV2ToMenuItem(a, context)
|
||||
actionToMenuItem(a, context)
|
||||
);
|
||||
return {
|
||||
type: "group",
|
||||
@@ -352,8 +198,8 @@ export function actionV2ToMenuItem(
|
||||
}
|
||||
}
|
||||
|
||||
export function actionV2ToKBar(
|
||||
action: ActionV2Variant,
|
||||
export function actionToKBar(
|
||||
action: ActionVariant,
|
||||
context: ActionContext
|
||||
): KbarAction[] {
|
||||
const visible = resolve<boolean>(action.visible, context);
|
||||
@@ -385,18 +231,18 @@ export function actionV2ToKBar(
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
perform: () => performActionV2(action, context),
|
||||
perform: () => performAction(action, context),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case "action_with_children": {
|
||||
const resolvedChildren = resolve<ActionV2Variant[]>(
|
||||
const resolvedChildren = resolve<ActionVariant[]>(
|
||||
action.children,
|
||||
context
|
||||
);
|
||||
const children = resolvedChildren
|
||||
.map((a) => actionV2ToKBar(a, context))
|
||||
.map((a) => actionToKBar(a, context))
|
||||
.flat()
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -422,8 +268,8 @@ export function actionV2ToKBar(
|
||||
}
|
||||
}
|
||||
|
||||
export async function performActionV2(
|
||||
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
|
||||
export async function performAction(
|
||||
action: Exclude<ActionVariant, ActionWithChildren>,
|
||||
context: ActionContext
|
||||
) {
|
||||
const perform =
|
||||
|
||||
@@ -38,6 +38,8 @@ 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;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* oxlint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction, performActionV2, resolve } from "~/actions";
|
||||
import { performAction, 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?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
|
||||
action?: Exclude<ActionVariant, ActionWithChildren>;
|
||||
/** 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} />;
|
||||
}
|
||||
|
||||
if (
|
||||
action.visible &&
|
||||
!resolve<boolean>(action.visible, actionContext) &&
|
||||
hideOnActionDisabled
|
||||
) {
|
||||
const actionIsDisabled =
|
||||
action.visible && !resolve<boolean>(action.visible, actionContext);
|
||||
|
||||
if (actionIsDisabled && hideOnActionDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const disabled = rest.disabled || actionIsDisabled;
|
||||
|
||||
const label =
|
||||
rest["aria-label"] ??
|
||||
(typeof action.name === "function"
|
||||
@@ -61,10 +61,7 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
? (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const response =
|
||||
"variant" in action
|
||||
? performActionV2(action, actionContext)
|
||||
: performAction(action, actionContext);
|
||||
const response = performAction(action, actionContext);
|
||||
if (response?.finally) {
|
||||
setExecuting(true);
|
||||
void response.finally(
|
||||
|
||||
@@ -13,7 +13,6 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
@@ -37,8 +37,9 @@ const DocumentComments = lazyWithRetry(
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -130,9 +131,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<React.Suspense fallback={null}>
|
||||
<CommandBar />
|
||||
</React.Suspense>
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import Initials from "./Initials";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
export enum AvatarSize {
|
||||
Small = 16,
|
||||
@@ -22,6 +23,7 @@ export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
initial?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
@@ -42,6 +44,8 @@ type Props = {
|
||||
className?: string;
|
||||
/** Optional style */
|
||||
style?: React.CSSProperties;
|
||||
/** Whether to show a tooltip */
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
@@ -50,12 +54,15 @@ function Avatar(props: Props) {
|
||||
style,
|
||||
variant = AvatarVariant.Round,
|
||||
className,
|
||||
showTooltip,
|
||||
...rest
|
||||
} = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
const initial =
|
||||
model?.initial || (model?.name ? model.name[0] : "").toUpperCase();
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<Relative
|
||||
style={style}
|
||||
$variant={variant}
|
||||
@@ -66,13 +73,19 @@ function Avatar(props: Props) {
|
||||
<Image onError={handleError} src={src} {...rest} />
|
||||
) : model ? (
|
||||
<Initials color={model.color} {...rest}>
|
||||
{model.initial}
|
||||
{initial}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials {...rest} />
|
||||
)}
|
||||
</Relative>
|
||||
);
|
||||
|
||||
return showTooltip ? (
|
||||
<Tooltip content={props.alt || model?.name || ""}>{content}</Tooltip>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
Avatar.defaultProps = {
|
||||
|
||||
@@ -26,6 +26,7 @@ export function GroupAvatar({
|
||||
return (
|
||||
<Squircle color={color ?? theme.text} size={size} className={className}>
|
||||
<GroupIcon
|
||||
data-fixed-color
|
||||
color={backgroundColor ?? theme.background}
|
||||
size={size * 0.75}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
@@ -44,4 +45,4 @@ const Link = styled.a`
|
||||
}
|
||||
`;
|
||||
|
||||
export default Branding;
|
||||
export default React.memo(Branding);
|
||||
|
||||
@@ -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 { InternalLinkActionV2, MenuInternalLink } from "~/types";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import { InternalLinkAction, MenuInternalLink } from "~/types";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
type TopLevelAction =
|
||||
| InternalLinkActionV2
|
||||
| { type: "menu"; actions: InternalLinkActionV2[] };
|
||||
| InternalLinkAction
|
||||
| { type: "menu"; actions: InternalLinkAction[] };
|
||||
|
||||
type Props = React.PropsWithChildren<{
|
||||
actions: InternalLinkActionV2[];
|
||||
actions: InternalLinkAction[];
|
||||
max?: number;
|
||||
highlightFirstItem?: boolean;
|
||||
}>;
|
||||
@@ -46,7 +46,7 @@ function Breadcrumb(
|
||||
const menuActions = topLevelActions.splice(
|
||||
halfMax,
|
||||
totalVisibleActions - max
|
||||
) as InternalLinkActionV2[];
|
||||
) as InternalLinkAction[];
|
||||
|
||||
topLevelActions.splice(halfMax, 0, {
|
||||
type: "menu",
|
||||
@@ -60,10 +60,7 @@ function Breadcrumb(
|
||||
return <BreadcrumbMenu key="menu" actions={action.actions} />;
|
||||
}
|
||||
|
||||
const item = actionV2ToMenuItem(
|
||||
action,
|
||||
actionContext
|
||||
) as MenuInternalLink;
|
||||
const item = actionToMenuItem(action, actionContext) as MenuInternalLink;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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);
|
||||
@@ -34,6 +34,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
appearance: none !important;
|
||||
transition: background 200ms ease-out;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
@@ -44,6 +45,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => darken(0.05, props.theme.accent)};
|
||||
transition: background 0s;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -78,6 +80,7 @@ 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 {
|
||||
@@ -103,6 +106,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
transition: background 0s;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -67,7 +67,13 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const fallbackIcon = <Icon value="collection" color={iconColor} />;
|
||||
const fallbackIcon = (
|
||||
<Icon
|
||||
value="collection"
|
||||
initial={collection?.initial ?? "?"}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
|
||||
@@ -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 { createInternalLinkActionV2 } from "~/actions";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
@@ -17,14 +17,14 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createInternalLinkActionV2({
|
||||
createInternalLinkAction({
|
||||
name: t("Archive"),
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: collection.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
createInternalLinkAction({
|
||||
name: collection.name,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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 { createAction } from "~/actions";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
@@ -15,12 +15,16 @@ const useRecentDocumentActions = (count = 6) => {
|
||||
.filter((document) => document.id !== ui.activeDocumentId)
|
||||
.slice(0, count)
|
||||
.map((item) =>
|
||||
createAction({
|
||||
createInternalLinkAction({
|
||||
name: item.titleWithDefault,
|
||||
analyticsName: "Recently viewed document",
|
||||
section: RecentSection,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
<Icon
|
||||
value={item.icon}
|
||||
initial={item.initial}
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SettingsIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { createAction } from "~/actions";
|
||||
import { createActionWithChildren, createInternalLinkAction } 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 {
|
||||
return createInternalLinkAction({
|
||||
id: item.path,
|
||||
name: item.name,
|
||||
icon: <Icon />,
|
||||
section: NavigationSection,
|
||||
to: item.path,
|
||||
};
|
||||
});
|
||||
}),
|
||||
[config]
|
||||
);
|
||||
|
||||
const navigateToSettings = useMemo(
|
||||
() =>
|
||||
createAction({
|
||||
createActionWithChildren({
|
||||
id: "settings",
|
||||
name: ({ t }) => t("Settings"),
|
||||
section: NavigationSection,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { createActionWithChildren, createInternalLinkAction } 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 = () => {
|
||||
@@ -21,14 +20,18 @@ const useTemplatesAction = () => {
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
createAction({
|
||||
createInternalLinkAction({
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
section: template.isWorkspaceTemplate
|
||||
? TeamSection
|
||||
: ActiveCollectionSection,
|
||||
icon: template.icon ? (
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
<Icon
|
||||
value={template.icon}
|
||||
initial={template.initial}
|
||||
color={template.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<NewDocumentIcon />
|
||||
),
|
||||
@@ -47,15 +50,20 @@ const useTemplatesAction = () => {
|
||||
template.isWorkspaceTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(template.collectionId ?? activeCollectionId, {
|
||||
templateId: template.id,
|
||||
}),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(
|
||||
template.collectionId ?? activeCollectionId,
|
||||
{
|
||||
sidebarContext,
|
||||
templateId: template.id,
|
||||
}
|
||||
),
|
||||
).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
})
|
||||
),
|
||||
[documents.templatesAlphabetical]
|
||||
@@ -63,7 +71,7 @@ const useTemplatesAction = () => {
|
||||
|
||||
const newFromTemplate = useMemo(
|
||||
() =>
|
||||
createAction({
|
||||
createActionWithChildren({
|
||||
id: "templates",
|
||||
name: ({ t }) => t("New from template"),
|
||||
placeholder: ({ t }) => t("Choose a template"),
|
||||
@@ -78,7 +86,7 @@ const useTemplatesAction = () => {
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: () => actions,
|
||||
children: actions,
|
||||
}),
|
||||
[actions]
|
||||
);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${s("sidebarText")};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 6px;
|
||||
margin-left: -4px;
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default MenuIconWrapper;
|
||||
@@ -1,217 +0,0 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { ellipsis, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "../Text";
|
||||
import MenuIconWrapper from "./MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClick?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
href?: string;
|
||||
target?: string;
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
{
|
||||
onClick,
|
||||
onPointerMove,
|
||||
children,
|
||||
active,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const content = React.useCallback(
|
||||
(props) => {
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const preventDefault = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
const handleClick = async (ev: React.MouseEvent) => {
|
||||
hide?.();
|
||||
|
||||
if (onClick) {
|
||||
preventDefault(ev);
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onPointerDown={preventDefault}
|
||||
onMouseDown={preventDefault}
|
||||
ref={mergeRefs([
|
||||
ref,
|
||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<SelectedWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
</SelectedWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
<Title>{children}</Title>
|
||||
</MenuAnchor>
|
||||
);
|
||||
},
|
||||
[active, as, hide, icon, onClick, ref, children, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
${ellipsis()}
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
level?: number;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
disclosure?: boolean;
|
||||
$active?: boolean;
|
||||
};
|
||||
|
||||
export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) => props.disabled && "pointer-events: none;"}
|
||||
|
||||
${(props) =>
|
||||
props.$active === undefined &&
|
||||
!props.disabled &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
|
||||
${Text} {
|
||||
color: ${transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
padding-right: ${(props: MenuAnchorProps) =>
|
||||
props.disclosure ? 32 : 12}px;
|
||||
font-size: 14px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
const SelectedWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
margin-left: -8px;
|
||||
flex-shrink: 0;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||
@@ -1,27 +0,0 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({ className, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton
|
||||
className={className}
|
||||
aria-label={t("More options")}
|
||||
{...props}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
</MenuSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 6px 0;
|
||||
`;
|
||||
@@ -1,264 +0,0 @@
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
|
||||
import Flex from "~/components/Flex";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
MenuSeparator,
|
||||
MenuHeading,
|
||||
MenuItem as TMenuItem,
|
||||
} from "~/types";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import MouseSafeArea from "./MouseSafeArea";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type Props = Omit<MenuStateReturn, "items"> & {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
showIcons?: boolean;
|
||||
};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
`;
|
||||
|
||||
type SubMenuProps = MenuStateReturn & {
|
||||
templateItems: TMenuItem[];
|
||||
parentMenuState: Omit<MenuStateReturn, "items">;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(function _Template(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState({
|
||||
parentId: parentMenuState.baseId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
parentMenuState={parentMenuState}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return items
|
||||
.filter((item) => item.visible !== false)
|
||||
.reduce((acc, item) => {
|
||||
// trim separator if the previous item was a separator
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
acc[acc.length - 1]?.type === "separator"
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, item];
|
||||
}, [] as TMenuItem[])
|
||||
.filter((item, index, arr) => {
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
(index === 0 || index === arr.length - 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const templateItems = actions
|
||||
? actions.map((item) =>
|
||||
item.type === "separator" || item.type === "heading"
|
||||
? item
|
||||
: actionToMenuItem(item, ctx)
|
||||
)
|
||||
: items || [];
|
||||
|
||||
const filteredTemplates = filterTemplateItems(templateItems);
|
||||
|
||||
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
|
||||
(item) =>
|
||||
item.type !== "separator" && item.type !== "heading" && !!item.icon
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredTemplates.map((item, index) => {
|
||||
if (
|
||||
iconIsPresentInAnyMenuItem &&
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading" &&
|
||||
showIcons !== false
|
||||
) {
|
||||
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
||||
}
|
||||
|
||||
if (item.type === "route") {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "button") {
|
||||
const menuItem = (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return item.tooltip ? (
|
||||
<Tooltip
|
||||
content={item.tooltip}
|
||||
placement={"bottom"}
|
||||
key={`tooltip-${item.title}-${index}`}
|
||||
>
|
||||
<div>{menuItem}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
|
||||
{menuItem}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
// Skip rendering empty submenus
|
||||
return item.items.length > 0 ? (
|
||||
<BaseMenuItem
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={
|
||||
<Title
|
||||
title={item.title}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
/>
|
||||
}
|
||||
{...menu}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <Separator key={`separator-${index}`} />;
|
||||
}
|
||||
|
||||
if (item.type === "heading") {
|
||||
return (
|
||||
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
|
||||
);
|
||||
}
|
||||
|
||||
// This should never be reached for Reakit dropdown menu.
|
||||
// Added for exhaustiveness check.
|
||||
if (item.type === "group") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const _exhaustiveCheck: never = item;
|
||||
return _exhaustiveCheck;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Title({
|
||||
title,
|
||||
icon,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Flex align="center">
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
{title}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
@@ -1,317 +0,0 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuStateReturn } from "reakit/Menu";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeAndSlideUp,
|
||||
fadeAndSlideDown,
|
||||
mobileContextMenu,
|
||||
} from "~/styles/animations";
|
||||
|
||||
export type Placement =
|
||||
| "auto-start"
|
||||
| "auto"
|
||||
| "auto-end"
|
||||
| "top-start"
|
||||
| "top"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right"
|
||||
| "right-end"
|
||||
| "bottom-end"
|
||||
| "bottom"
|
||||
| "bottom-start"
|
||||
| "left-end"
|
||||
| "left"
|
||||
| "left-start";
|
||||
|
||||
type Props = MenuStateReturn & {
|
||||
"aria-label"?: string;
|
||||
/** Reference to the rendered menu div element */
|
||||
menuRef?: React.RefObject<HTMLDivElement>;
|
||||
/** The parent menu state if this is a submenu. */
|
||||
parentMenuState?: Omit<MenuStateReturn, "items">;
|
||||
/** Called when the context menu is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Called when the context menu is closed. */
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
/** The maximum width of the context menu. */
|
||||
maxWidth?: number;
|
||||
/** The minimum height of the context menu. */
|
||||
minHeight?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
menuRef,
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { setIsMenuOpen } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
const isSubMenu = !!parentMenuState;
|
||||
|
||||
useUnmount(() => {
|
||||
setIsMenuOpen(false);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
onOpen?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest.visible && previousVisible) {
|
||||
onClose?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
previousVisible,
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
isSubMenu,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// sets the menu height based on the available space between the disclosure/
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
hideOnClickOutside={!isMobile}
|
||||
preventBodyScroll={false}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
<InnerContextMenu
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
menuProps={props as any}
|
||||
{...rest}
|
||||
isSubMenu={isSubMenu}
|
||||
>
|
||||
{children}
|
||||
</InnerContextMenu>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type InnerContextMenuProps = MenuStateReturn & {
|
||||
isSubMenu: boolean;
|
||||
menuProps: { style?: React.CSSProperties; placement: string };
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inner context menu allows deferring expensive window measurement hooks etc
|
||||
* until the menu is actually opened.
|
||||
*/
|
||||
const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
const { menuProps } = props;
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
// positioning.
|
||||
const topAnchor =
|
||||
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
|
||||
const rightAnchor = menuProps.placement === "bottom-end";
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobile();
|
||||
|
||||
const maxHeight = useMenuHeight({
|
||||
visible: props.visible,
|
||||
elementRef: props.unstable_disclosureRef,
|
||||
});
|
||||
|
||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||
// element can be passed into body-scroll-lock. See:
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (props.visible && scrollElement && !props.isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (scrollElement && !props.isSubMenu) {
|
||||
enableBodyScroll(scrollElement);
|
||||
}
|
||||
};
|
||||
}, [props.isSubMenu, props.visible]);
|
||||
|
||||
useEventListener(
|
||||
"animationstart",
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const parent = event.target.parentElement;
|
||||
if (parent) {
|
||||
parent.style.pointerEvents = "none";
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundRef.current
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const parent = event.target.parentElement;
|
||||
if (parent) {
|
||||
parent.style.pointerEvents = "auto";
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundRef.current
|
||||
);
|
||||
|
||||
const style =
|
||||
topAnchor && !isMobile
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<Backdrop
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
props.hide?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Position {...menuProps}>
|
||||
<Background
|
||||
dir="auto"
|
||||
maxWidth={props.maxWidth}
|
||||
minHeight={props.minHeight}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={style}
|
||||
>
|
||||
{props.visible || props.animating ? props.children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
|
||||
export const Backdrop = styled.div`
|
||||
animation: ${fadeIn} 200ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${s("backdrop")};
|
||||
z-index: ${depths.menu - 1};
|
||||
`;
|
||||
|
||||
export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${depths.menu};
|
||||
|
||||
// Note: pointer events are re-enabled after the animation ends, see event listeners above
|
||||
pointer-events: none;
|
||||
|
||||
&:focus-visible {
|
||||
transition-delay: 250ms;
|
||||
transition-property: outline-width;
|
||||
transition-duration: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* overrides make mobile-first coding style challenging
|
||||
* so we explicitly define mobile breakpoint here
|
||||
*/
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
top: auto !important;
|
||||
right: 8px !important;
|
||||
bottom: 16px !important;
|
||||
left: 8px !important;
|
||||
`};
|
||||
`;
|
||||
|
||||
type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
min-height: ${(props) => props.minHeight || 44}px;
|
||||
max-height: 75vh;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props: BackgroundProps) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props: BackgroundProps) =>
|
||||
props.rightAnchor ? "75%" : "25%"} 0;
|
||||
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
|
||||
max-height: 100vh;
|
||||
background: ${(props: BackgroundProps) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
|
||||
`};
|
||||
`;
|
||||
@@ -1,7 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Guide from "~/components/Guide";
|
||||
import Modal from "~/components/Modal";
|
||||
import { Suspense } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Guide = lazyWithRetry(() => import("~/components/Guide"));
|
||||
const Modal = lazyWithRetry(() => import("~/components/Modal"));
|
||||
|
||||
function Dialogs() {
|
||||
const { dialogs } = useStores();
|
||||
@@ -9,7 +12,7 @@ function Dialogs() {
|
||||
const modals = [...modalStack];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
@@ -29,11 +32,13 @@ function Dialogs() {
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
width={modal.width}
|
||||
height={modal.height}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { createInternalLinkActionV2 } from "~/actions";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
@@ -53,28 +53,28 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
const outputActions = [
|
||||
createInternalLinkActionV2({
|
||||
createInternalLinkAction({
|
||||
name: t("Trash"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
visible: document.isDeleted,
|
||||
to: trashPath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
createInternalLinkAction({
|
||||
name: t("Archive"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: document.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
createInternalLinkAction({
|
||||
name: t("Templates"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: document.template,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
createInternalLinkAction({
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: collection ? (
|
||||
@@ -88,7 +88,7 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
: "",
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
createInternalLinkAction({
|
||||
name: t("Deleted Collection"),
|
||||
section: ActiveDocumentSection,
|
||||
visible: document.isCollectionDeleted,
|
||||
@@ -96,10 +96,15 @@ function DocumentBreadcrumb(
|
||||
}),
|
||||
...path.map((node) => {
|
||||
const title = node.title || t("Untitled");
|
||||
return createInternalLinkActionV2({
|
||||
return createInternalLinkAction({
|
||||
name: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
<StyledIcon
|
||||
value={node.icon}
|
||||
color={node.color}
|
||||
initial={node.title.charAt(0).toUpperCase()}
|
||||
/>{" "}
|
||||
{title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
|
||||
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import { useRef, useCallback, useMemo } from "react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import { useRef, useCallback, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
@@ -19,10 +19,12 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const ReadingTime = lazyWithRetry(() => import("./ReadingTime"));
|
||||
|
||||
type Props = {
|
||||
/** The pin record */
|
||||
@@ -76,6 +78,13 @@ function DocumentCard(props: Props) {
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
const updatedAt = (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -150,12 +159,11 @@ function DocumentCard(props: Props) {
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
updatedAt
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
<Suspense fallback={updatedAt}>
|
||||
<ReadingTime document={document} />
|
||||
</Suspense>
|
||||
)}
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
@@ -177,29 +185,14 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
initial,
|
||||
color,
|
||||
}: {
|
||||
icon: string;
|
||||
initial: string;
|
||||
color?: string;
|
||||
initial?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const iconType = determineIconType(icon)!;
|
||||
|
||||
@@ -3,6 +3,7 @@ import concat from "lodash/concat";
|
||||
import difference from "lodash/difference";
|
||||
import fill from "lodash/fill";
|
||||
import filter from "lodash/filter";
|
||||
import flatten from "lodash/flatten";
|
||||
import includes from "lodash/includes";
|
||||
import map from "lodash/map";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -17,6 +18,7 @@ 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";
|
||||
@@ -26,8 +28,6 @@ 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";
|
||||
import flatten from "lodash/flatten";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
@@ -49,8 +49,13 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
() => {
|
||||
const node =
|
||||
defaultValue && items.find((item) => item.id === defaultValue);
|
||||
if (!defaultValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search through all nodes in the tree, not just top-level items
|
||||
const allNodes = flatten(items.map(flattenTree));
|
||||
const node = allNodes.find((item) => item.id === defaultValue);
|
||||
return node || null;
|
||||
}
|
||||
);
|
||||
@@ -59,7 +64,9 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
const node = items.find((item) => item.id === defaultValue);
|
||||
// Search through all nodes in the tree, not just top-level items
|
||||
const allNodes = flatten(items.map(flattenTree));
|
||||
const node = allNodes.find((item) => item.id === defaultValue);
|
||||
if (node) {
|
||||
return ancestors(node).map((ancestorNode) => ancestorNode.id);
|
||||
}
|
||||
@@ -104,19 +111,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
);
|
||||
}, [items.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue && selectedNode && listRef) {
|
||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
||||
if (index > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
@@ -130,6 +124,19 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue && selectedNode && listRef) {
|
||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
||||
if (index > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, [defaultValue, selectedNode, nodes]);
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
@@ -261,7 +268,9 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
title = doc?.title ?? node.title;
|
||||
|
||||
if (icon) {
|
||||
renderedIcon = <Icon value={icon} color={color} />;
|
||||
renderedIcon = (
|
||||
<Icon value={icon} initial={node.title} color={color} />
|
||||
);
|
||||
} else if (doc?.isStarred) {
|
||||
renderedIcon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
|
||||
@@ -94,7 +94,7 @@ function DocumentListItem(
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ document });
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -39,8 +40,9 @@ function DocumentTasks({ document }: Props) {
|
||||
const done = completed === total;
|
||||
const previousDone = usePrevious(done);
|
||||
const message = getMessage(t, total, completed);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex align="center" style={{ padding: "0 1px" }} gap={2}>
|
||||
{completed === total ? (
|
||||
<Done
|
||||
color={theme.accent}
|
||||
@@ -50,8 +52,8 @@ function DocumentTasks({ document }: Props) {
|
||||
) : (
|
||||
<CircularProgressBar percentage={tasksPercentage} />
|
||||
)}
|
||||
{message}
|
||||
</>
|
||||
{message}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s, truncateMultiline } from "@shared/styles";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
@@ -32,6 +32,7 @@ function EditableTitle(
|
||||
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setIsEditing,
|
||||
@@ -41,30 +42,54 @@ function EditableTitle(
|
||||
setValue(title);
|
||||
}, [title]);
|
||||
|
||||
const handleChange = React.useCallback((event) => {
|
||||
setValue(event.target.value);
|
||||
}, []);
|
||||
const handleChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDoubleClick = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
const handleDoubleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLSpanElement>) => {
|
||||
if (event.altKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditing(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const stopPropagation = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const stopPropagation = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleFocus = React.useCallback((event) => {
|
||||
event.target.select();
|
||||
}, []);
|
||||
const handleFocus = React.useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
event.target.select();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSave = React.useCallback(
|
||||
async (ev) => {
|
||||
async (
|
||||
ev:
|
||||
| React.FocusEvent<HTMLInputElement>
|
||||
| React.KeyboardEvent<HTMLInputElement>
|
||||
| React.FormEvent<HTMLFormElement>
|
||||
) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue === originalValue || trimmedValue.length === 0) {
|
||||
@@ -74,22 +99,26 @@ function EditableTitle(
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
setValue(value);
|
||||
setIsEditing(true);
|
||||
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
[originalValue, value, onCancel, onSubmit, isSubmitting]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
async (ev) => {
|
||||
async (ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
@@ -139,8 +168,8 @@ function EditableTitle(
|
||||
);
|
||||
}
|
||||
|
||||
const Text = styled.span`
|
||||
${truncateMultiline(3)}
|
||||
const Text = styled.div`
|
||||
${ellipsis()}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
|
||||
@@ -21,6 +21,7 @@ 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"));
|
||||
|
||||
@@ -33,7 +34,6 @@ 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, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
|
||||
props;
|
||||
const { id, onChange, onCreateCommentMark, onDeleteCommentMark } = props;
|
||||
const { comments } = useStores();
|
||||
const { shareId } = useShare();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
@@ -202,6 +202,7 @@ 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) => (
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
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;
|
||||
`;
|
||||
@@ -46,20 +46,22 @@ 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/)
|
||||
error.message.match(/dynamically imported module/) &&
|
||||
!this.isRepeatedError
|
||||
) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
);
|
||||
const [includeAttachments, setIncludeAttachments] =
|
||||
React.useState<boolean>(true);
|
||||
const [includePrivate, setIncludePrivate] = React.useState<boolean>(true);
|
||||
const user = useCurrentUser();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -44,6 +45,13 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleIncludePrivateChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludePrivate(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (collection) {
|
||||
await collection.export(format, includeAttachments);
|
||||
@@ -59,7 +67,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await collections.export(format, includeAttachments);
|
||||
await collections.export({ format, includeAttachments, includePrivate });
|
||||
toast.success(t("Export started"));
|
||||
}
|
||||
onSubmit();
|
||||
@@ -123,37 +131,64 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="small">{item.description}</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{item.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
<hr />
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeAttachments"
|
||||
checked={includeAttachments}
|
||||
onChange={handleIncludeAttachmentsChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include attachments")}
|
||||
</Text>
|
||||
<Text size="small">
|
||||
{t("Including uploaded images and files in the exported data")}.
|
||||
</Text>{" "}
|
||||
</div>
|
||||
</Option>
|
||||
<HR />
|
||||
<Flex gap={12} column>
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeAttachments"
|
||||
checked={includeAttachments}
|
||||
onChange={handleIncludeAttachmentsChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include attachments")}
|
||||
</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{t("Including uploaded images and files in the exported data")}.
|
||||
</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>
|
||||
)}
|
||||
</Flex>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const HR = styled.hr`
|
||||
margin: 16px 0;
|
||||
`;
|
||||
|
||||
const Option = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
|
||||
input {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import styled from "styled-components";
|
||||
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 */
|
||||
@@ -21,6 +23,8 @@ type Props = {
|
||||
model: User;
|
||||
}
|
||||
>;
|
||||
/** Whether to show tooltips on hover, defaults to true */
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
function Facepile({
|
||||
@@ -29,6 +33,7 @@ function Facepile({
|
||||
size = AvatarSize.Large,
|
||||
limit = 8,
|
||||
renderAvatar = Avatar,
|
||||
showTooltip = true,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -51,6 +56,7 @@ function Facepile({
|
||||
<Component
|
||||
key={model.id}
|
||||
{...{
|
||||
showTooltip,
|
||||
model,
|
||||
size,
|
||||
style: {
|
||||
@@ -63,7 +69,9 @@ function Facepile({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<FacepileClip size={size} />
|
||||
<VisuallyHidden>
|
||||
<FacepileClip size={size} />
|
||||
</VisuallyHidden>
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +109,11 @@ const Avatars = styled(Flex)`
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
cursor: var(--pointer);
|
||||
|
||||
*:hover {
|
||||
clip-path: none !important;
|
||||
box-shadow: 0 0 0 2px ${s("background")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Facepile);
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -55,7 +56,11 @@ const FilterOptions = ({
|
||||
(option) => (
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={option.icon}
|
||||
icon={
|
||||
option.icon ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined
|
||||
}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
@@ -77,30 +82,45 @@ const FilterOptions = ({
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const normalizedQuery = deburr(query.toLowerCase());
|
||||
|
||||
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;
|
||||
})
|
||||
const filtered = query
|
||||
? options.filter((option) =>
|
||||
deburr(option.label).toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
: options;
|
||||
}, [options, query]);
|
||||
|
||||
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]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
@@ -108,6 +128,10 @@ 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);
|
||||
|
||||
@@ -13,6 +13,7 @@ import useStores from "~/hooks/useStores";
|
||||
import LoadingIndicator from "../LoadingIndicator";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewGroup from "./HoverPreviewGroup";
|
||||
import HoverPreviewIssue from "./HoverPreviewIssue";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
@@ -116,12 +117,31 @@ const HoverPreviewDesktop = observer(
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
filter: "blur(5px)",
|
||||
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
|
||||
@@ -132,6 +152,14 @@ const HoverPreviewDesktop = observer(
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Group ? (
|
||||
<HoverPreviewGroup
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
description={data.description}
|
||||
memberCount={data.memberCount}
|
||||
users={data.users}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
@@ -295,10 +323,10 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
${({ direction, theme }) =>
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
|
||||
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
|
||||
? `border-bottom-color: rgba(0, 0, 0, 0.1)`
|
||||
: `border-top-color: rgba(0, 0, 0, 0.1)`};
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP ? "right: -1px" : "left: -1px"};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Info,
|
||||
Card,
|
||||
CardContent,
|
||||
Description,
|
||||
} from "./Components";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
|
||||
|
||||
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
|
||||
{ name, description, 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 && (
|
||||
<Facepile
|
||||
users={users.map(
|
||||
(member) =>
|
||||
({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
avatarUrl: member.avatarUrl,
|
||||
color: member.color,
|
||||
initial: member.name ? member.name[0] : "?",
|
||||
}) as User
|
||||
)}
|
||||
overflow={Math.max(0, memberCount - users.length)}
|
||||
limit={MAX_AVATAR_DISPLAY}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{description && <Description>{description}</Description>}
|
||||
</Flex>
|
||||
</ErrorBoundary>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewGroup;
|
||||
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ avatarUrl, name, lastActive, color, email }: Props,
|
||||
{ avatarUrl, name, lastActive, color }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
@@ -25,7 +25,6 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{name}</Title>
|
||||
{email && <Info>{email}</Info>}
|
||||
<Info>{lastActive}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import styled from "styled-components";
|
||||
import { breakpoints, s, hover } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { validateColorHex } from "@shared/utils/color";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
enum Panel {
|
||||
Builtin,
|
||||
Hex,
|
||||
}
|
||||
import { SwatchButton } from "~/components/SwatchButton";
|
||||
import { ColorButton } from "~/components/ColorButton";
|
||||
|
||||
type Props = {
|
||||
width: number;
|
||||
@@ -19,128 +13,77 @@ type Props = {
|
||||
onSelect: (color: string) => void;
|
||||
};
|
||||
|
||||
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
|
||||
const [localValue, setLocalValue] = React.useState(activeColor);
|
||||
const ColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
const [selectedColor, setSelectedColor] = React.useState(activeColor);
|
||||
const isBuiltInColor = colorPalette.includes(selectedColor);
|
||||
const color = isBuiltInColor ? undefined : selectedColor;
|
||||
|
||||
const [panel, setPanel] = React.useState(
|
||||
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
|
||||
const debouncedOnSelect = React.useMemo(
|
||||
() =>
|
||||
debounce((color: string) => {
|
||||
onSelect(color);
|
||||
}, 250),
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
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(
|
||||
() => () => {
|
||||
debouncedOnSelect.cancel();
|
||||
},
|
||||
[debouncedOnSelect]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalValue(activeColor);
|
||||
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
|
||||
setSelectedColor(activeColor);
|
||||
}, [activeColor]);
|
||||
|
||||
return isLargeMobile ? (
|
||||
<Container justify="space-between">
|
||||
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
|
||||
<LargeMobileCustomColor
|
||||
value={localValue}
|
||||
setLocalValue={setLocalValue}
|
||||
onValidHex={onSelect}
|
||||
const handleSelect = (color: string) => {
|
||||
setSelectedColor(color);
|
||||
debouncedOnSelect(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
|
||||
<Divider />
|
||||
<SwatchButton
|
||||
color={color}
|
||||
active={!isBuiltInColor}
|
||||
onChange={handleSelect}
|
||||
pickerInModal
|
||||
/>
|
||||
</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>
|
||||
</BuiltinColors>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}) => (
|
||||
<Flex className={className} justify="space-between" align="center" auto>
|
||||
<Container 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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
))}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
|
||||
const Container = styled(Flex)`
|
||||
height: 48px;
|
||||
@@ -148,71 +91,4 @@ const Container = styled(Flex)`
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
`;
|
||||
@@ -0,0 +1,170 @@
|
||||
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,77 +1,17 @@
|
||||
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 InputSearch from "~/components/InputSearch";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import {
|
||||
FREQUENTLY_USED_COUNT,
|
||||
DisplayCategory,
|
||||
emojiSkinToneKey,
|
||||
emojisFreqKey,
|
||||
lastEmojiKey,
|
||||
sortFrequencies,
|
||||
} from "../utils";
|
||||
import { DisplayCategory } 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;
|
||||
@@ -97,11 +37,14 @@ const EmojiPanel = ({
|
||||
const {
|
||||
emojiSkinTone: skinTone,
|
||||
setEmojiSkinTone,
|
||||
incrementEmojiCount,
|
||||
getFreqEmojis,
|
||||
} = useEmojiState();
|
||||
incrementIconCount,
|
||||
getFrequentIcons,
|
||||
} = useIconState(IconType.Emoji);
|
||||
|
||||
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
|
||||
const freqEmojis = React.useMemo(
|
||||
() => getFrequentIcons(),
|
||||
[getFrequentIcons]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -120,9 +63,9 @@ const EmojiPanel = ({
|
||||
const handleEmojiSelection = React.useCallback(
|
||||
({ id, value }: { id: string; value: string }) => {
|
||||
onEmojiChange(value);
|
||||
incrementEmojiCount(id);
|
||||
incrementIconCount(id);
|
||||
},
|
||||
[onEmojiChange, incrementEmojiCount]
|
||||
[onEmojiChange, incrementIconCount]
|
||||
);
|
||||
|
||||
const isSearch = query !== "";
|
||||
@@ -195,7 +138,7 @@ const getAllEmojis = ({
|
||||
}): DataNode[] => {
|
||||
const emojisWithCategory = getEmojisWithCategory({ skinTone });
|
||||
|
||||
const getFrequentEmojis = (): DataNode => {
|
||||
const getFrequentIcons = (): DataNode => {
|
||||
const emojis = getEmojis({ ids: freqEmojis, skinTone });
|
||||
return {
|
||||
category: DisplayCategory.Frequent,
|
||||
@@ -220,7 +163,7 @@ const getAllEmojis = ({
|
||||
};
|
||||
|
||||
return concat(
|
||||
getFrequentEmojis(),
|
||||
getFrequentIcons(),
|
||||
getCategoryData(EmojiCategory.People),
|
||||
getCategoryData(EmojiCategory.Nature),
|
||||
getCategoryData(EmojiCategory.Foods),
|
||||
@@ -232,13 +175,4 @@ const getAllEmojis = ({
|
||||
);
|
||||
};
|
||||
|
||||
const UserInputContainer = styled(Flex)`
|
||||
height: 48px;
|
||||
padding: 6px 12px 0px;
|
||||
`;
|
||||
|
||||
const StyledInputSearch = styled(InputSearch)`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
export default EmojiPanel;
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -23,10 +24,11 @@ type OutlineNode = {
|
||||
delay: number;
|
||||
};
|
||||
|
||||
type EmojiNode = {
|
||||
type: IconType.Emoji;
|
||||
export type EmojiNode = {
|
||||
type: IconType.Emoji | IconType.Custom;
|
||||
id: string;
|
||||
value: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type DataNode = {
|
||||
@@ -86,7 +88,11 @@ const GridTemplate = (
|
||||
onClick={() => onIconSelect({ id: item.id, value: item.value })}
|
||||
>
|
||||
<Emoji width={24} height={24}>
|
||||
{item.value}
|
||||
{item.type === IconType.Custom ? (
|
||||
<CustomEmoji value={item.value} title={item.name} />
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</Emoji>
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
@@ -5,16 +5,10 @@ import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import {
|
||||
FREQUENTLY_USED_COUNT,
|
||||
DisplayCategory,
|
||||
iconsFreqKey,
|
||||
lastIconKey,
|
||||
sortFrequencies,
|
||||
} from "../utils";
|
||||
import { DisplayCategory } 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;
|
||||
@@ -25,52 +19,6 @@ 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;
|
||||
@@ -97,9 +45,9 @@ const IconPanel = ({
|
||||
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { incrementIconCount, getFreqIcons } = useIconState();
|
||||
const { incrementIconCount, getFrequentIcons } = useIconState(IconType.SVG);
|
||||
|
||||
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
|
||||
const freqIcons = React.useMemo(() => getFrequentIcons(), [getFrequentIcons]);
|
||||
const totalFreqIcons = freqIcons.length;
|
||||
|
||||
const filteredIcons = React.useMemo(
|
||||
|
||||
@@ -21,10 +21,13 @@ 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];
|
||||
@@ -35,7 +38,7 @@ type Props = {
|
||||
icon: string | null;
|
||||
color: string;
|
||||
size?: number;
|
||||
initial?: string;
|
||||
initial: string;
|
||||
className?: string;
|
||||
popoverPosition: "bottom-start" | "right";
|
||||
allowDelete?: boolean;
|
||||
@@ -61,7 +64,7 @@ const IconPicker = ({
|
||||
children,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { emojis } = useStores();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = useMobile();
|
||||
|
||||
@@ -168,6 +171,12 @@ const IconPicker = ({
|
||||
setActiveTab(defaultTab);
|
||||
}, [defaultTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
void emojis.fetchAll();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
@@ -245,6 +254,13 @@ 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>
|
||||
@@ -271,6 +287,15 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -18,6 +18,7 @@ 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 = {
|
||||
@@ -32,6 +33,8 @@ 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}`;
|
||||
@@ -46,5 +49,11 @@ 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));
|
||||
|
||||
@@ -1,85 +1,41 @@
|
||||
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 Text from "./Text";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./primitives/Popover";
|
||||
import { SwatchButton } from "./SwatchButton";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
|
||||
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%;
|
||||
const PositionedSwatchButton = styled(SwatchButton)`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
bottom: 21px;
|
||||
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,12 +1,10 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { QuestionMarkIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Separator from "./ContextMenu/Separator";
|
||||
import Flex from "./Flex";
|
||||
import { LabelText } from "./Input";
|
||||
import NudeButton from "./NudeButton";
|
||||
@@ -52,7 +50,7 @@ export type Item = {
|
||||
|
||||
export type Option = Item | Separator;
|
||||
|
||||
type Props = {
|
||||
type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
|
||||
/* Options to display in the select menu. */
|
||||
options: Option[];
|
||||
/* Current chosen value. */
|
||||
@@ -219,9 +217,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
(option: Option, idx: number) => {
|
||||
if (option.type === "separator") {
|
||||
return <Separator />;
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
@@ -343,9 +341,9 @@ function Option({
|
||||
{option.description && (
|
||||
<>
|
||||
|
||||
<Description type="tertiary" size="small" ellipsis>
|
||||
<Text type="tertiary" size="small" ellipsis>
|
||||
– {option.description}
|
||||
</Description>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</OptionContainer>
|
||||
@@ -361,15 +359,6 @@ const OptionContainer = styled(Flex)`
|
||||
min-height: 24px;
|
||||
`;
|
||||
|
||||
const Description = styled(Text)`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
+31
-41
@@ -4,21 +4,21 @@ 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 { MenuProvider } from "~/hooks/useMenuContext";
|
||||
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,50 +29,40 @@ const Layout = React.forwardRef(function Layout_(
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
|
||||
useAutoRefresh();
|
||||
|
||||
useKeyDown(".", (event) => {
|
||||
if (isModKey(event)) {
|
||||
ui.toggleCollapsedSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<MenuProvider>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
|
||||
<SkipNavLink />
|
||||
<SkipNavLink />
|
||||
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
|
||||
<Container auto>
|
||||
<MenuProvider>{sidebar}</MenuProvider>
|
||||
<Container auto>
|
||||
{sidebar}
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
</MenuProvider>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+491
-116
@@ -1,12 +1,21 @@
|
||||
import { useEditor } from "~/editor/components/EditorContext";
|
||||
import { observer } from "mobx-react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { findChildren } from "@shared/editor/queries/findChildren";
|
||||
import findIndex from "lodash/findIndex";
|
||||
import styled, { css, Keyframes, keyframes } from "styled-components";
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { sanitizeUrl } from "@shared/utils/urls";
|
||||
import { Error } from "@shared/editor/components/Image";
|
||||
import {
|
||||
ComponentProps,
|
||||
createContext,
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { Error as ImageError } from "@shared/editor/components/Image";
|
||||
import {
|
||||
BackIcon,
|
||||
CloseIcon,
|
||||
@@ -14,12 +23,14 @@ import {
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
NextIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
EditIcon,
|
||||
} from "outline-icons";
|
||||
import { depths, extraArea, s } from "@shared/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
import { Second } from "@shared/utils/time";
|
||||
import { downloadImageNode } from "@shared/editor/nodes/Image";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -29,6 +40,21 @@ import Button from "./Button";
|
||||
import CopyToClipboard from "./CopyToClipboard";
|
||||
import { Separator } from "./Actions";
|
||||
import useSwipe from "~/hooks/useSwipe";
|
||||
import { toast } from "sonner";
|
||||
import { findIndex } from "lodash";
|
||||
import { LightboxImage } from "@shared/editor/lib/Lightbox";
|
||||
import {
|
||||
TransformWrapper,
|
||||
TransformComponent,
|
||||
useTransformEffect,
|
||||
ReactZoomPanPinchRef,
|
||||
} 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,
|
||||
@@ -43,6 +69,9 @@ export enum ImageStatus {
|
||||
LOADING,
|
||||
ERROR,
|
||||
LOADED,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
ZOOMED,
|
||||
}
|
||||
type Status = {
|
||||
lightbox: LightboxStatus | null;
|
||||
@@ -60,46 +89,153 @@ type Animation = {
|
||||
const ANIMATION_DURATION = 0.3 * Second.ms;
|
||||
|
||||
type Props = {
|
||||
/** Callback triggered when the active image position is updated */
|
||||
onUpdate: (pos: number | null) => void;
|
||||
/** The position of the currently active image in the document */
|
||||
activePos: number | null;
|
||||
/** List of allowed images */
|
||||
images: LightboxImage[];
|
||||
/** The currently active image in the document */
|
||||
activeImage: LightboxImage;
|
||||
/** Callback triggered when the active image is updated */
|
||||
onUpdate: (activeImage: LightboxImage | null) => void;
|
||||
/** Callback triggered when Lightbox closes */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function Lightbox({ onUpdate, activePos }: Props) {
|
||||
const { view } = useEditor();
|
||||
const ZoomPanPinchContext = createContext({ isImagePanning: false });
|
||||
type ZoomablePannablePinchableProps = {
|
||||
children: ReactNode;
|
||||
panningDisabled: boolean;
|
||||
disabled: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const ZoomablePannablePinchable = forwardRef<
|
||||
ReactZoomPanPinchRef,
|
||||
ZoomablePannablePinchableProps
|
||||
>(({ children, panningDisabled, disabled, onClose }, ref) => {
|
||||
const { isPanning, ...panningHandlers } = usePanning();
|
||||
const wrapperRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
const scale = wrapperRef.current?.instance.transformState.scale ?? 1;
|
||||
|
||||
const wrapperProps = useMemo(
|
||||
() =>
|
||||
({
|
||||
onClick: (event) => {
|
||||
if (scale > 1) {
|
||||
return;
|
||||
}
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
["IMG", "INPUT", "BUTTON", "A"].includes(
|
||||
(event.target as Element).tagName
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose?.();
|
||||
},
|
||||
}) satisfies HTMLAttributes<HTMLDivElement>,
|
||||
[onClose, scale]
|
||||
);
|
||||
|
||||
return (
|
||||
<ZoomPanPinchContext.Provider value={{ isImagePanning: isPanning }}>
|
||||
<TransformWrapper
|
||||
ref={mergeRefs([ref, wrapperRef])}
|
||||
disabled={disabled}
|
||||
doubleClick={{ disabled: true }}
|
||||
minScale={1}
|
||||
maxScale={8}
|
||||
panning={{
|
||||
disabled: panningDisabled,
|
||||
}}
|
||||
{...panningHandlers}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: isPanning ? "grabbing" : scale > 1 ? "grab" : "zoom-out",
|
||||
}}
|
||||
contentStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "56px",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{children}
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
</ZoomPanPinchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
function usePanning() {
|
||||
const [isPanning, setPanning] = useState(false);
|
||||
const dragged = useRef(false);
|
||||
|
||||
const onPanningStart: ComponentProps<
|
||||
typeof TransformWrapper
|
||||
>["onPanningStart"] = (ref) => {
|
||||
const zoomedIn = ref.state.scale > 1;
|
||||
if (zoomedIn) {
|
||||
setPanning(ref.instance.isPanning);
|
||||
}
|
||||
};
|
||||
|
||||
const onPanning: ComponentProps<
|
||||
typeof TransformWrapper
|
||||
>["onPanning"] = () => {
|
||||
dragged.current = true;
|
||||
};
|
||||
|
||||
const onPanningStop: ComponentProps<
|
||||
typeof TransformWrapper
|
||||
>["onPanningStop"] = (ref, event) => {
|
||||
setPanning(ref.instance.isPanning);
|
||||
if (dragged.current) {
|
||||
dragged.current = false;
|
||||
} else if (event.target instanceof HTMLImageElement) {
|
||||
const zoomedOut = Math.abs(ref.state.scale - 1) < 0.001;
|
||||
if (zoomedOut) {
|
||||
ref.zoomIn();
|
||||
} else {
|
||||
ref.resetTransform();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isPanning,
|
||||
onPanningStart,
|
||||
onPanning,
|
||||
onPanningStop,
|
||||
};
|
||||
}
|
||||
|
||||
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
|
||||
const isIdle = useIdle(3 * Second.ms);
|
||||
const { t } = useTranslation();
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
|
||||
const [imageElements] = useState(
|
||||
view?.dom.querySelectorAll(".component-image img")
|
||||
);
|
||||
const animation = useRef<Animation | null>(null);
|
||||
const finalImage = useRef<{
|
||||
center: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
const editor = useEditor();
|
||||
|
||||
const imageNodes = useMemo(
|
||||
() =>
|
||||
view
|
||||
? findChildren(
|
||||
view.state.doc,
|
||||
(child) => child.type === view.state.schema.nodes.image,
|
||||
true
|
||||
)
|
||||
: [],
|
||||
[view]
|
||||
);
|
||||
const currentImageIndex = findIndex(
|
||||
imageNodes,
|
||||
(node) => node.pos === activePos
|
||||
images,
|
||||
(img) => img.pos === activeImage.pos
|
||||
);
|
||||
const currentImageNode =
|
||||
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
|
||||
|
||||
// Debugging status changes
|
||||
// useEffect(() => {
|
||||
@@ -108,15 +244,21 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
// );
|
||||
// }, [status]);
|
||||
|
||||
useEffect(() => () => view.focus(), []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (status.lightbox === LightboxStatus.CLOSED) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[status.lightbox]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
!!activePos &&
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.READY_TO_OPEN,
|
||||
image: status.image,
|
||||
});
|
||||
}, [!!activePos]);
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.READY_TO_OPEN,
|
||||
image: status.image,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === ImageStatus.LOADED) {
|
||||
@@ -139,6 +281,18 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
}, [status.image, status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENED &&
|
||||
status.image === ImageStatus.LOADED
|
||||
) {
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.OPENED,
|
||||
image: ImageStatus.MIN_ZOOM,
|
||||
});
|
||||
}
|
||||
}, [status.lightbox, status.image]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
|
||||
setupFadeOut();
|
||||
@@ -156,6 +310,15 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
}, [status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === ImageStatus.MIN_ZOOM) {
|
||||
// It was observed that focus went to `body` as the zoom out button was disabled
|
||||
// upon clicking it. This stopped navigating to next/previous image using arrow keys.
|
||||
// So focusing the content div here to restore the functionality.
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [status.image]);
|
||||
|
||||
const rememberImagePosition = () => {
|
||||
if (imgRef.current) {
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
@@ -179,11 +342,10 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
const setupZoomIn = () => {
|
||||
if (imgRef.current) {
|
||||
// in editor
|
||||
const editorImageEl = imageElements[currentImageIndex];
|
||||
const editorImageEl = activeImage.getElement();
|
||||
if (!editorImageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
|
||||
const {
|
||||
top: editorImgTop,
|
||||
@@ -270,7 +432,13 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
};
|
||||
|
||||
const setupZoomOut = () => {
|
||||
if (imgRef.current) {
|
||||
if (
|
||||
imgRef.current &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
) {
|
||||
// in lightbox
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
@@ -289,7 +457,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
};
|
||||
|
||||
// in editor
|
||||
const editorImageEl = imageElements[currentImageIndex];
|
||||
const editorImageEl = activeImage.getElement();
|
||||
let to;
|
||||
if (editorImageEl?.isConnected) {
|
||||
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
|
||||
@@ -364,33 +532,31 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!activePos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prev = () => {
|
||||
if (status.lightbox === LightboxStatus.OPENED) {
|
||||
if (!activePos) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENED &&
|
||||
(status.image === ImageStatus.MIN_ZOOM ||
|
||||
status.image === ImageStatus.ERROR)
|
||||
) {
|
||||
const prevIndex = currentImageIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
return;
|
||||
}
|
||||
onUpdate(imageNodes[prevIndex].pos);
|
||||
onUpdate(images[prevIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (status.lightbox === LightboxStatus.OPENED) {
|
||||
if (!activePos) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENED &&
|
||||
(status.image === ImageStatus.MIN_ZOOM ||
|
||||
status.image === ImageStatus.ERROR)
|
||||
) {
|
||||
const nextIndex = currentImageIndex + 1;
|
||||
if (nextIndex >= imageNodes.length) {
|
||||
if (nextIndex >= images.length) {
|
||||
return;
|
||||
}
|
||||
onUpdate(imageNodes[nextIndex].pos);
|
||||
onUpdate(images[nextIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -406,12 +572,63 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
|
||||
void downloadImageNode(currentImageNode);
|
||||
const svgDataURLToBlob = (dataURL: string) => {
|
||||
// Match the SVG data URL format
|
||||
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedSVGData = match[1];
|
||||
const decodedSVGData = decodeURIComponent(encodedSVGData);
|
||||
|
||||
// Convert string to Uint8Array
|
||||
const uint8 = new Uint8Array(decodedSVGData.length);
|
||||
for (let i = 0; i < decodedSVGData.length; ++i) {
|
||||
uint8[i] = decodedSVGData.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Create and return the Blob
|
||||
return new Blob([uint8], { type: "image/svg+xml" });
|
||||
};
|
||||
|
||||
const downloadImage = async (src: string, saveAs: string) => {
|
||||
let imageBlob;
|
||||
if (isInternalUrl(src)) {
|
||||
const image = await fetch(src);
|
||||
imageBlob = await image.blob();
|
||||
} else {
|
||||
// Assuming it's a mermaid svg
|
||||
imageBlob = svgDataURLToBlob(src);
|
||||
}
|
||||
|
||||
if (!imageBlob) {
|
||||
toast.error(t("Unable to download image"));
|
||||
return;
|
||||
}
|
||||
|
||||
const imageURL = URL.createObjectURL(imageBlob);
|
||||
const name = saveAs || "image";
|
||||
const extension = imageBlob.type.split(/\/|\+/g)[1];
|
||||
|
||||
// create a temporary link node and click it with our image data
|
||||
const link = document.createElement("a");
|
||||
link.href = imageURL;
|
||||
link.download = `${name}.${extension}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(imageURL);
|
||||
};
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (activeImage && status.lightbox === LightboxStatus.OPENED) {
|
||||
void downloadImage(activeImage.src, activeImage.alt);
|
||||
}
|
||||
}, [activeImage, status.lightbox]);
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
ev.preventDefault();
|
||||
switch (ev.key) {
|
||||
@@ -459,14 +676,19 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentImageNode) {
|
||||
return null;
|
||||
}
|
||||
const handleEditDiagram = () => {
|
||||
const { state, dispatch } = editor.view;
|
||||
|
||||
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
|
||||
// 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={!!activePos}>
|
||||
<Dialog.Root open={true}>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay
|
||||
ref={overlayRef}
|
||||
@@ -474,7 +696,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
onAnimationStart={handleFadeStart}
|
||||
onAnimationEnd={handleFadeEnd}
|
||||
/>
|
||||
<StyledContent onKeyDown={handleKeyDown}>
|
||||
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
|
||||
<VisuallyHidden.Root>
|
||||
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
@@ -482,10 +704,52 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</Dialog.Description>
|
||||
</VisuallyHidden.Root>
|
||||
<Actions animation={animation.current}>
|
||||
<Tooltip content={t("Zoom in")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
status.image === ImageStatus.MAX_ZOOM ||
|
||||
status.image === ImageStatus.ERROR
|
||||
}
|
||||
onClick={() => {
|
||||
if (zoomPanPinchRef.current) {
|
||||
zoomPanPinchRef.current.zoomIn();
|
||||
}
|
||||
}}
|
||||
aria-label={t("Zoom in")}
|
||||
size={32}
|
||||
icon={<ZoomInIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Zoom out")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (zoomPanPinchRef.current) {
|
||||
zoomPanPinchRef.current.zoomOut();
|
||||
}
|
||||
}}
|
||||
aria-label={t("Zoom out")}
|
||||
size={32}
|
||||
icon={<ZoomOutIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
<Separator />
|
||||
<Tooltip content={t("Copy link")} placement="bottom">
|
||||
<CopyToClipboard text={imgRef.current?.src ?? ""}>
|
||||
<Button
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={status.image === ImageStatus.ERROR}
|
||||
aria-label={t("Copy link")}
|
||||
size={32}
|
||||
icon={<LinkIcon />}
|
||||
@@ -495,9 +759,10 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Download")} placement="bottom">
|
||||
<Button
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
onClick={download}
|
||||
disabled={status.image === ImageStatus.ERROR}
|
||||
onClick={handleDownload}
|
||||
aria-label={t("Download")}
|
||||
size={32}
|
||||
icon={<DownloadIcon />}
|
||||
@@ -505,10 +770,25 @@ function Lightbox({ onUpdate, activePos }: 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">
|
||||
<Button
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
onClick={close}
|
||||
aria-label={t("Close")}
|
||||
@@ -520,49 +800,87 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</Tooltip>
|
||||
</Dialog.Close>
|
||||
</Actions>
|
||||
{currentImageIndex > 0 && (
|
||||
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
|
||||
<BackIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
<Image
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={currentImageNode.attrs.alt ?? ""}
|
||||
onLoading={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADING,
|
||||
})
|
||||
{currentImageIndex > 0 &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
) && (
|
||||
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
|
||||
<BackIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
<ZoomablePannablePinchable
|
||||
panningDisabled={
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
}
|
||||
onLoad={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADED,
|
||||
})
|
||||
}
|
||||
onError={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ERROR,
|
||||
})
|
||||
}
|
||||
onSwipeRight={prev}
|
||||
onSwipeLeft={next}
|
||||
onSwipeUp={close}
|
||||
onSwipeDown={close}
|
||||
status={status}
|
||||
animation={animation.current}
|
||||
/>
|
||||
{currentImageIndex < imageNodes.length - 1 && (
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
disabled={status.image === ImageStatus.ERROR}
|
||||
ref={zoomPanPinchRef}
|
||||
onClose={close}
|
||||
>
|
||||
<Image
|
||||
ref={imgRef}
|
||||
src={activeImage.src}
|
||||
alt={activeImage.alt}
|
||||
onLoading={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADING,
|
||||
})
|
||||
}
|
||||
onLoad={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADED,
|
||||
})
|
||||
}
|
||||
onError={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ERROR,
|
||||
})
|
||||
}
|
||||
onSwipeRight={prev}
|
||||
onSwipeLeft={next}
|
||||
onSwipeUp={close}
|
||||
onSwipeDown={close}
|
||||
status={status}
|
||||
animation={animation.current}
|
||||
onMinZoom={() => {
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.MIN_ZOOM,
|
||||
});
|
||||
}}
|
||||
onZoom={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ZOOMED,
|
||||
})
|
||||
}
|
||||
onMaxZoom={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.MAX_ZOOM,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ZoomablePannablePinchable>
|
||||
{currentImageIndex < images.length - 1 &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
) && (
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -581,6 +899,9 @@ type ImageProps = {
|
||||
onSwipeDown: () => void;
|
||||
status: Status;
|
||||
animation: Animation | null;
|
||||
onMinZoom: () => void;
|
||||
onZoom: () => void;
|
||||
onMaxZoom: () => void;
|
||||
};
|
||||
|
||||
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
@@ -596,6 +917,9 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onSwipeDown,
|
||||
status,
|
||||
animation,
|
||||
onMinZoom,
|
||||
onZoom,
|
||||
onMaxZoom,
|
||||
}: ImageProps,
|
||||
ref
|
||||
) {
|
||||
@@ -608,6 +932,25 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onSwipeDown,
|
||||
});
|
||||
|
||||
const { isImagePanning } = useContext(ZoomPanPinchContext);
|
||||
|
||||
useTransformEffect(({ state, instance }) => {
|
||||
const minScale = instance.props.minScale ?? 1;
|
||||
const maxScale = instance.props.maxScale ?? 8;
|
||||
const { scale } = state;
|
||||
if (scale === minScale && status.image === ImageStatus.ZOOMED) {
|
||||
onMinZoom();
|
||||
} else if (scale === maxScale && status.image === ImageStatus.ZOOMED) {
|
||||
onMaxZoom();
|
||||
} else if (
|
||||
scale > minScale &&
|
||||
scale < maxScale &&
|
||||
status.image !== ImageStatus.ZOOMED
|
||||
) {
|
||||
onZoom();
|
||||
}
|
||||
});
|
||||
|
||||
const [hidden, setHidden] = useState(
|
||||
status.image === null || status.image === ImageStatus.LOADING
|
||||
);
|
||||
@@ -642,9 +985,15 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onError={onError}
|
||||
onLoad={onLoad}
|
||||
$hidden={hidden}
|
||||
$zoomedIn={
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
}
|
||||
$zoomedOut={status.image === ImageStatus.MIN_ZOOM}
|
||||
$panning={isImagePanning}
|
||||
/>
|
||||
<Caption>
|
||||
{status.image === ImageStatus.LOADED &&
|
||||
{status.image === ImageStatus.MIN_ZOOM &&
|
||||
status.lightbox === LightboxStatus.OPENED ? (
|
||||
<Fade>{alt}</Fade>
|
||||
) : null}
|
||||
@@ -700,12 +1049,25 @@ const StyledOverlay = styled(Dialog.Overlay)<{
|
||||
|
||||
const StyledImg = styled.img<{
|
||||
$hidden: boolean;
|
||||
$zoomedIn: boolean;
|
||||
$zoomedOut: boolean;
|
||||
$panning: boolean;
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
|
||||
pointer-events: auto !important;
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
cursor: ${(props) =>
|
||||
props.$panning
|
||||
? "grabbing"
|
||||
: props.$zoomedOut
|
||||
? "zoom-in"
|
||||
: props.$zoomedIn
|
||||
? "zoom-out"
|
||||
: "default"};
|
||||
|
||||
${(props) =>
|
||||
props.animation?.zoomIn
|
||||
? css`
|
||||
@@ -717,7 +1079,12 @@ const StyledImg = styled.img<{
|
||||
animation: ${props.animation.zoomOut.apply()}
|
||||
${props.animation.zoomOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
: props.animation?.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
@@ -728,7 +1095,10 @@ const StyledContent = styled(Dialog.Content)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
padding: 56px;
|
||||
`;
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
background: transparent;
|
||||
`;
|
||||
|
||||
const Actions = styled.div<{
|
||||
@@ -741,6 +1111,10 @@ const Actions = styled.div<{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: ${depths.modal};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 6px;
|
||||
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
@@ -768,6 +1142,7 @@ const Nav = styled.div<{
|
||||
position: absolute;
|
||||
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
|
||||
transition: opacity 500ms ease-in-out;
|
||||
z-index: ${depths.modal};
|
||||
${(props) => props.$hidden && "opacity: 0;"}
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
@@ -787,7 +1162,7 @@ const Nav = styled.div<{
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledError = styled(Error)<{
|
||||
const StyledError = styled(ImageError)<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
${(props) =>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
|
||||
export const MeasuredContainer = <T extends React.ElementType>({
|
||||
as: As,
|
||||
as: As = "div",
|
||||
name,
|
||||
children,
|
||||
...rest
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import { ActionVariant, ActionWithChildren } 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: ActionV2WithChildren;
|
||||
action?: ActionWithChildren;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** ARIA label for the menu */
|
||||
@@ -30,15 +30,13 @@ export const ContextMenu = observer(
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
const menuItems = useComputed(
|
||||
() =>
|
||||
((action?.children as ActionVariant[]) ?? []).map((childAction) =>
|
||||
actionToMenuItem(childAction, actionContext)
|
||||
),
|
||||
[action?.children, actionContext]
|
||||
);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -48,7 +46,7 @@ export const ContextMenu = observer(
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose]
|
||||
[open, onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
@@ -68,7 +66,7 @@ export const ContextMenu = observer(
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { actionV2ToMenuItem } from "~/actions";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import {
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
ActionVariant,
|
||||
ActionWithChildren,
|
||||
MenuItem,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
@@ -25,7 +25,7 @@ import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
action: ActionWithChildren;
|
||||
/** 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 ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
return (action.children as ActionVariant[]).map((childAction) =>
|
||||
actionToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
} from "~/components/primitives/Menu";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import { MenuItem } from "~/types";
|
||||
import { MouseSafeArea } from "~/components/MouseSafeArea";
|
||||
import { createRef } from "react";
|
||||
|
||||
export function toMenuItems(items: MenuItem[]) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
const parentRef = createRef<HTMLDivElement>();
|
||||
|
||||
if (!filteredItems.length) {
|
||||
return null;
|
||||
@@ -88,7 +91,10 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<SubMenuContent>{submenuItems}</SubMenuContent>
|
||||
<SubMenuContent ref={parentRef}>
|
||||
<MouseSafeArea parentRef={parentRef} />
|
||||
{submenuItems}
|
||||
</SubMenuContent>
|
||||
</SubMenu>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +166,7 @@ export function toMobileMenuItems(
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
{item.selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{item.selected ? <CheckmarkIcon /> : null}
|
||||
{item.selected ? <CheckmarkIcon size={18} /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</Components.MenuButton>
|
||||
|
||||
+22
-14
@@ -22,6 +22,8 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
title?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
@@ -30,6 +32,8 @@ const Modal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
title = "Untitled",
|
||||
style,
|
||||
width,
|
||||
height,
|
||||
onRequestClose,
|
||||
}: Props) => {
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
@@ -57,7 +61,7 @@ const Modal: React.FC<Props> = ({
|
||||
>
|
||||
{isMobile ? (
|
||||
<Mobile>
|
||||
<Content>
|
||||
<MobileContent>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && (
|
||||
<Text size="xlarge" weight="bold">
|
||||
@@ -66,7 +70,7 @@ const Modal: React.FC<Props> = ({
|
||||
)}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</Content>
|
||||
</MobileContent>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
@@ -76,7 +80,7 @@ const Modal: React.FC<Props> = ({
|
||||
</Back>
|
||||
</Mobile>
|
||||
) : (
|
||||
<Small>
|
||||
<Wrapper $width={width} $height={height}>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
@@ -84,9 +88,9 @@ const Modal: React.FC<Props> = ({
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent style={style} shadow>
|
||||
<DesktopContent style={style} topShadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
</DesktopContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
@@ -94,7 +98,7 @@ const Modal: React.FC<Props> = ({
|
||||
</NudeButton>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Small>
|
||||
</Wrapper>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
@@ -142,7 +146,7 @@ const Mobile = styled.div`
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const Content = styled(Scrollable)`
|
||||
const MobileContent = styled(Scrollable)`
|
||||
width: 100%;
|
||||
padding: 8vh 12px;
|
||||
|
||||
@@ -151,6 +155,10 @@ const Content = styled(Scrollable)`
|
||||
`};
|
||||
`;
|
||||
|
||||
const DesktopContent = styled(Scrollable)`
|
||||
padding: 8px 24px 24px;
|
||||
`;
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
@@ -205,16 +213,20 @@ const Header = styled(Flex)`
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
padding: 24px 24px 12px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Small = styled.div`
|
||||
const Wrapper = styled.div<{
|
||||
$width?: number | string;
|
||||
$height?: number | string;
|
||||
}>`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
margin: 25vh auto auto auto;
|
||||
width: 75vw;
|
||||
min-width: 350px;
|
||||
max-width: 450px;
|
||||
max-height: 65vh;
|
||||
max-width: ${(props) => props.$width || "450px"};
|
||||
max-height: ${(props) => props.$height || "70vh"};
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -237,8 +249,4 @@ const Small = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const SmallContent = styled(Scrollable)`
|
||||
padding: 8px 24px 24px;
|
||||
`;
|
||||
|
||||
export default observer(Modal);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Positions = {
|
||||
/** Sub-menu x */
|
||||
@@ -21,7 +24,7 @@ type Positions = {
|
||||
* allow moving cursor to lower parts of sub-menu without the sub-menu
|
||||
* disappearing.
|
||||
*/
|
||||
export default function MouseSafeArea(props: {
|
||||
export const MouseSafeArea = observer(function MouseSafeArea_(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const {
|
||||
@@ -30,15 +33,32 @@ export default function MouseSafeArea(props: {
|
||||
height: h = 0,
|
||||
width: w = 0,
|
||||
} = props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const { ui } = useStores();
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
const distance = Math.abs(mouseX - x);
|
||||
const prevDistance = usePrevious(distance) ?? distance;
|
||||
|
||||
// Hide the safe area if the mouse is moving _away_ from the menu
|
||||
React.useEffect(() => {
|
||||
if (distance > prevDistance) {
|
||||
setIsVisible(false);
|
||||
} else if (distance < prevDistance) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, [distance, prevDistance]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
|
||||
backgroundColor: ui.debugSafeArea ? "rgba(255,0,0,0.2)" : undefined,
|
||||
right: getRight(positions),
|
||||
left: getLeft(positions),
|
||||
height: h,
|
||||
@@ -47,24 +67,26 @@ export default function MouseSafeArea(props: {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const buffer = 10;
|
||||
|
||||
const getLeft = ({ x, mouseX }: Positions) =>
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX + buffer, buffer) + "px";
|
||||
|
||||
const getRight = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
|
||||
mouseX > x ? -Math.max(mouseX - (x + w) + buffer, buffer) + "px" : undefined;
|
||||
|
||||
const getWidth = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x
|
||||
? Math.max(mouseX - (x + w), 10) + "px"
|
||||
: Math.max(x - mouseX, 10) + "px";
|
||||
? Math.max(mouseX - (x + w - buffer), buffer) + "px"
|
||||
: Math.max(x - mouseX + buffer, buffer) + "px";
|
||||
|
||||
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
|
||||
mouseX > x
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
|
||||
}%, 100% ${(100 * (mouseY - y)) / h - buffer}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - buffer}%, 0% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%, 100% 100%)`;
|
||||
@@ -12,17 +12,11 @@ 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,13 +6,17 @@ import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, hover, truncateMultiline } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import { UnreadBadge } from "../UnreadBadge";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const CommentEditor = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/CommentEditor")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
notification: Notification;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Popover,
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Notifications from "./Notifications";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Notifications = lazyWithRetry(() => import("./Notifications"));
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -16,18 +18,18 @@ type Props = {
|
||||
const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { notifications } = useStores();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const scrollableRef = React.useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
void notifications.fetchPage({ archived: false });
|
||||
}, [notifications]);
|
||||
|
||||
const handleRequestClose = React.useCallback(() => {
|
||||
const handleRequestClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleAutoFocus = React.useCallback((event: Event) => {
|
||||
const handleAutoFocus = useCallback((event: Event) => {
|
||||
// Prevent focus from moving to the popover content
|
||||
event.preventDefault();
|
||||
|
||||
@@ -48,10 +50,12 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
onOpenAutoFocus={handleAutoFocus}
|
||||
shrink
|
||||
>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -10,6 +10,8 @@ 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;
|
||||
@@ -19,6 +21,7 @@ export interface FormData {
|
||||
avatarUrl: string;
|
||||
redirectUris: string[];
|
||||
published: boolean;
|
||||
clientType: "confidential" | "public";
|
||||
}
|
||||
|
||||
export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
@@ -46,6 +49,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
avatarUrl: oauthClient?.avatarUrl ?? "",
|
||||
redirectUris: oauthClient?.redirectUris ?? [],
|
||||
published: oauthClient?.published ?? false,
|
||||
clientType: oauthClient?.clientType ?? "confidential",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,20 +66,33 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
control={control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<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}
|
||||
/>
|
||||
</EventBoundary>
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="clientType"
|
||||
render={({ field }) => (
|
||||
<InputClientType
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
ref={field.ref}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { EyeIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import type Document from "~/models/Document";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = useMemo(
|
||||
() => ProsemirrorHelper.toMarkdown(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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingTime;
|
||||
@@ -48,6 +48,11 @@ 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) {
|
||||
@@ -58,7 +63,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
});
|
||||
|
||||
if (response.length) {
|
||||
setSearchResults(response);
|
||||
setSearchResults((state) => [...(state ?? []), ...response]);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 */
|
||||
@@ -77,9 +78,12 @@ 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(() => {
|
||||
@@ -111,162 +115,177 @@ export const AccessControlList = observer(
|
||||
);
|
||||
|
||||
return (
|
||||
<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}
|
||||
<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
|
||||
/>
|
||||
}
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{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,
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.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}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
{team.sharing && can.share && collection.sharing && visible && (
|
||||
<Sticky>
|
||||
{collection.members.length ? <Separator /> : null}
|
||||
<PublicAccess collection={collection} share={share} />
|
||||
<PublicAccess
|
||||
ref={publicAccessRef}
|
||||
collection={collection}
|
||||
share={share}
|
||||
/>
|
||||
</Sticky>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
@@ -31,21 +31,24 @@ type Props = {
|
||||
share: Share | null | undefined;
|
||||
};
|
||||
|
||||
function InnerPublicAccess({ collection, share }: Props) {
|
||||
function InnerPublicAccess(
|
||||
{ collection, share }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = useState("");
|
||||
const [urlId, setUrlId] = useState(share?.urlId);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [validationError, setValidationError] = React.useState("");
|
||||
const [urlId, setUrlId] = React.useState(share?.urlId);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const can = usePolicy(share);
|
||||
const collectionAbilities = usePolicy(collection);
|
||||
const canPublish = can.update && collectionAbilities.share;
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = useCallback(
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
@@ -58,7 +61,7 @@ function InnerPublicAccess({ collection, share }: Props) {
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = useCallback(
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
@@ -71,7 +74,20 @@ function InnerPublicAccess({ collection, share }: Props) {
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = useCallback(
|
||||
const handleShowTOCChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
@@ -84,7 +100,7 @@ function InnerPublicAccess({ collection, share }: Props) {
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleUrlChange = useMemo(
|
||||
const handleUrlChange = React.useMemo(
|
||||
() =>
|
||||
debounce(async (ev) => {
|
||||
if (!share) {
|
||||
@@ -115,7 +131,7 @@ function InnerPublicAccess({ collection, share }: Props) {
|
||||
[t, share]
|
||||
);
|
||||
|
||||
const handleCopied = useCallback(() => {
|
||||
const handleCopied = React.useCallback(() => {
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
}, [t]);
|
||||
|
||||
@@ -130,7 +146,7 @@ function InnerPublicAccess({ collection, share }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Wrapper ref={ref}>
|
||||
<ListItem
|
||||
title={t("Web")}
|
||||
subtitle={<>{t("Allow anyone with the link to access")}</>}
|
||||
@@ -204,6 +220,31 @@ function InnerPublicAccess({ collection, share }: Props) {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
@@ -264,4 +305,4 @@ const StyledInfoIcon = styled(InfoIcon)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const PublicAccess = observer(InnerPublicAccess);
|
||||
export const PublicAccess = observer(React.forwardRef(InnerPublicAccess));
|
||||
|
||||
@@ -67,9 +67,11 @@ 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: 65,
|
||||
maxViewportPercentage: 45,
|
||||
margin: 24,
|
||||
});
|
||||
|
||||
@@ -109,101 +111,106 @@ export const AccessControlList = observer(
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollableContainer
|
||||
ref={containerRef}
|
||||
hiddenScrollbars
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{showLoading ? (
|
||||
<Placeholder />
|
||||
) : (
|
||||
<>
|
||||
{collection && canCollection.readDocument ? (
|
||||
<>
|
||||
{collection.permission ? (
|
||||
<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={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<UserIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("All members")}
|
||||
subtitle={t("Everyone in the workspace")}
|
||||
image={<Avatar model={document.createdBy} />}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip>
|
||||
{collection?.permission ===
|
||||
CollectionPermission.ReadWrite
|
||||
? t("Can edit")
|
||||
: t("Can view")}
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
{t("Can edit")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
) : usersInCollection ? (
|
||||
<ListItem
|
||||
image={<CollectionSquircle collection={collection} />}
|
||||
title={collection.name}
|
||||
subtitle={t("Everyone in the collection")}
|
||||
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<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"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
{team.sharing && can.share && !collectionSharingDisabled && visible && (
|
||||
<Sticky>
|
||||
{document.members.length ? <Separator /> : null}
|
||||
<PublicAccess
|
||||
ref={publicAccessRef}
|
||||
document={document}
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
@@ -211,7 +218,7 @@ export const AccessControlList = observer(
|
||||
/>
|
||||
</Sticky>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -276,10 +283,14 @@ function useUsersInCollection(collection?: Collection) {
|
||||
: false;
|
||||
}
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Sticky = styled.div`
|
||||
background: ${s("menuBackground")};
|
||||
position: sticky;
|
||||
bottom: -12px;
|
||||
bottom: 0;
|
||||
`;
|
||||
|
||||
const ScrollableContainer = styled(Scrollable)`
|
||||
|
||||
@@ -37,7 +37,10 @@ type Props = {
|
||||
onRequestClose?: () => void;
|
||||
};
|
||||
|
||||
function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
function PublicAccess(
|
||||
{ document, share, sharedParent }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = React.useState("");
|
||||
@@ -77,6 +80,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
@@ -140,7 +156,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Wrapper ref={ref}>
|
||||
<ListItem
|
||||
title={t("Web")}
|
||||
subtitle={
|
||||
@@ -241,6 +257,31 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -315,4 +356,4 @@ const StyledLink = styled(Link)`
|
||||
text-decoration: underline;
|
||||
`;
|
||||
|
||||
export default observer(PublicAccess);
|
||||
export default observer(React.forwardRef(PublicAccess));
|
||||
|
||||
@@ -14,7 +14,7 @@ export const Wrapper = styled.div`
|
||||
|
||||
export const Separator = styled.div`
|
||||
border-top: 1px dashed ${s("divider")};
|
||||
margin: 8px 0;
|
||||
margin: 0 0 8px 0;
|
||||
`;
|
||||
|
||||
export const HeaderInput = styled(Flex)`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user