mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b9275dff0 | |||
| 39f0f78ff4 | |||
| b2a0a9cf21 | |||
| c0ee1aa3d7 | |||
| b9e34e4227 | |||
| b405e1e985 | |||
| e0e6b3f3db | |||
| b2b0bd8c8f | |||
| 1a8d75b81b | |||
| 6c3816e07c | |||
| d264848024 | |||
| 65a3d1ac47 | |||
| af98549ca7 | |||
| ce1d2a90c0 |
@@ -15,4 +15,3 @@ crowdin.yml
|
||||
build
|
||||
docker-compose.yml
|
||||
node_modules
|
||||
.yarn
|
||||
|
||||
+2
-11
@@ -61,11 +61,6 @@ DATABASE_CONNECTION_POOL_MAX=
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/redis-LGM4BFXYp4
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# To enable horizontal scaling of the collaboration service you must provide a Redis URL, it may
|
||||
# be the same as above, or a different server.
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/horizontal-scaling-hkfU5Stao7
|
||||
REDIS_COLLABORATION_URL=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––– FILE STORAGE –––––––––––
|
||||
@@ -203,7 +198,7 @@ RATE_LIMITER_DURATION_WINDOW=60
|
||||
# ––––––––––– INTEGRATIONS –––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# GitHub integration allows previewing issue and pull request links
|
||||
# The GitHub integration allows previewing issue and pull request links
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
@@ -212,7 +207,7 @@ GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# Linear integration allows previewing issue links as rich mentions
|
||||
# The Linear integration allows previewing issue links as rich mentions
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
@@ -223,10 +218,6 @@ SLACK_VERIFICATION_TOKEN=your_token
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
|
||||
# Figma integration allows previewing design files as rich mentions
|
||||
FIGMA_CLIENT_ID=
|
||||
FIGMA_CLIENT_SECRET=
|
||||
|
||||
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
|
||||
# and do not forget to whitelist your domain name in the app settings
|
||||
DROPBOX_APP_KEY=
|
||||
|
||||
@@ -12,7 +12,6 @@ GOOGLE_CLIENT_SECRET=123
|
||||
|
||||
SLACK_CLIENT_ID=123
|
||||
SLACK_CLIENT_SECRET=123
|
||||
SLACK_VERIFICATION_TOKEN=test-token-123
|
||||
|
||||
GITHUB_CLIENT_ID=123;
|
||||
GITHUB_CLIENT_SECRET=123;
|
||||
@@ -30,11 +29,3 @@ RATE_LIMITER_ENABLED=false
|
||||
|
||||
FILE_STORAGE=local
|
||||
FILE_STORAGE_LOCAL_ROOT_DIR=/tmp
|
||||
|
||||
URL=http://localhost:3000
|
||||
COLLABORATION_URL=
|
||||
REDIS_URL=redis://localhost:6379
|
||||
UTILS_SECRET=test-utils-secret
|
||||
|
||||
DEBUG=
|
||||
LOG_LEVEL=error
|
||||
|
||||
+19
-54
@@ -18,61 +18,44 @@ env:
|
||||
SMTP_USERNAME: localhost
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Use Node.js 22.x
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
- name: Cache node_modules
|
||||
id: cache-node-modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: yarn install --immutable
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
|
||||
lint:
|
||||
needs: setup
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
needs: setup
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
@@ -102,7 +85,7 @@ jobs:
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: [setup, changes]
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -110,21 +93,15 @@ jobs:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: [setup, changes]
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
@@ -148,40 +125,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: |
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | awk "NR % 4 == (${{ matrix.shard }} - 1)")
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [setup, types, changes]
|
||||
needs: [build, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- name: Set environment to production
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
|
||||
@@ -14,10 +14,3 @@ data/*
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
|
||||
# Yarn Berry
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
|
||||
+1
-4
@@ -10,10 +10,7 @@
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/setupMocks.js"
|
||||
],
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
|
||||
+1
-3
@@ -86,7 +86,6 @@
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
@@ -95,8 +94,7 @@
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"react/rules-of-hooks": "error"
|
||||
]
|
||||
},
|
||||
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 86400
|
||||
@@ -30,12 +30,10 @@ You're an expert in the following areas:
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Critical – Do not create new markdown (.md) files.
|
||||
- Use early returns for readability.
|
||||
- Emphasize type safety and static analysis.
|
||||
- Follow consistent Prettier formatting.
|
||||
- Do not replace smart quotes ("") or ('') with simple quotes ("").
|
||||
- Do not add translation strings manually; they will be extracted automatically from the codebase.
|
||||
|
||||
## Dependencies and Upgrading
|
||||
|
||||
@@ -64,8 +62,8 @@ yarn install
|
||||
2. Public static methods
|
||||
3. Public variables
|
||||
4. Public methods
|
||||
5. Protected variables & methods
|
||||
6. Private variables & methods
|
||||
6. Protected variables & methods
|
||||
8. Private variables & methods
|
||||
|
||||
### Exports
|
||||
|
||||
@@ -79,7 +77,7 @@ yarn install
|
||||
- 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.
|
||||
- Do not import React unless it is used directly.
|
||||
- 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.
|
||||
|
||||
@@ -143,21 +141,20 @@ yarn sequelize migration:create --name=add-field-to-table
|
||||
- Run tests with Jest:
|
||||
|
||||
```bash
|
||||
# Run a specific test file (preferred)
|
||||
yarn test path/to/test.spec.ts
|
||||
|
||||
# Run every test (avoid)
|
||||
# Run all tests
|
||||
yarn test
|
||||
|
||||
# Run test suites (avoid)
|
||||
yarn test:app # All frontend tests
|
||||
yarn test:server # All backend tests
|
||||
yarn test:shared # All shared code tests
|
||||
# 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.
|
||||
- Do not create new test directories
|
||||
- Mock external dependencies appropriately in **mocks** folder.
|
||||
- Mock external dependencies appropriately in __mocks__ folder.
|
||||
- Aim for high code coverage but focus on critical paths.
|
||||
|
||||
## Code Quality
|
||||
|
||||
+8
-8
@@ -18,15 +18,15 @@ ENV NODE_ENV=production
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline && \
|
||||
chown -R nodejs:nodejs $APP_PATH
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/server ./server
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/public ./public
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/package.json ./package.json
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
COPY --from=base $APP_PATH/public ./public
|
||||
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
# Install wget to healthcheck the server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
@@ -44,4 +44,4 @@ USER nodejs
|
||||
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build/server/index.js"]
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
+5
-4
@@ -3,21 +3,22 @@ FROM node:22.21.0 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN apt-get update && apt-get install -y cmake
|
||||
ENV NODE_OPTIONS="--max-old-space-size=24000"
|
||||
|
||||
RUN corepack enable
|
||||
RUN yarn install --immutable --network-timeout 1000000 && \
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
COPY . .
|
||||
ARG CDN_URL
|
||||
RUN yarn build
|
||||
|
||||
RUN yarn workspaces focus --production && \
|
||||
RUN rm -rf node_modules
|
||||
|
||||
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.4.0
|
||||
The Licensed Work is (c) 2026 General Outline, Inc.
|
||||
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
|
||||
Service.
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2030-01-27
|
||||
Change Date: 2029-11-16
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
up:
|
||||
docker compose up -d redis postgres
|
||||
yarn install-local-ssl
|
||||
yarn install --immutable
|
||||
yarn install --pure-lockfile
|
||||
yarn dev:watch
|
||||
|
||||
build:
|
||||
|
||||
@@ -21,23 +21,7 @@
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate",
|
||||
"pr-predeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"environments": {
|
||||
"review": {
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-redis:mini"
|
||||
},
|
||||
{
|
||||
"plan": "heroku-postgresql:essential-0"
|
||||
}
|
||||
]
|
||||
}
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": {
|
||||
@@ -59,12 +43,8 @@
|
||||
"required": true
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to. For review apps, this is auto-generated.",
|
||||
"required": false
|
||||
},
|
||||
"HEROKU_APP_NAME": {
|
||||
"description": "Automatically set by Heroku for review apps",
|
||||
"required": false
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
|
||||
"required": true
|
||||
},
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PlusIcon, TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import ApiKeyNew from "~/scenes/ApiKeyNew";
|
||||
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
|
||||
import { createAction } from "..";
|
||||
|
||||
@@ -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,12 +1,12 @@
|
||||
import {
|
||||
SortAlphabeticalReverseIcon,
|
||||
SortAlphabeticalIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
AlphabeticalSortIcon,
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
SortManualIcon,
|
||||
ManualSortIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
@@ -96,11 +96,11 @@ export const editCollection = createAction({
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export const editCollection = createAction({
|
||||
content: (
|
||||
<CollectionEdit
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
collectionId={collection.id}
|
||||
collectionId={activeCollectionId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -122,16 +122,21 @@ export const editCollectionPermissions = createAction({
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Share this collection"),
|
||||
style: { marginBottom: -12 },
|
||||
content: (
|
||||
<SharePopover
|
||||
collection={collection}
|
||||
@@ -148,16 +153,15 @@ export const importDocument = createAction({
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
perform: ({ getActiveModel, stores }) => {
|
||||
const { documents } = stores;
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
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;
|
||||
@@ -167,10 +171,15 @@ export const importDocument = createAction({
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
const document = await documents.import(file, null, collection.id, {
|
||||
publish: true,
|
||||
});
|
||||
history.push(document.path);
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
@@ -183,36 +192,37 @@ export const importDocument = createAction({
|
||||
export const sortCollection = createActionWithChildren({
|
||||
name: ({ t }) => t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.update),
|
||||
icon: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
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" ? (
|
||||
<SortAlphabeticalIcon />
|
||||
<AlphabeticalSortIcon />
|
||||
) : (
|
||||
<SortAlphabeticalReverseIcon />
|
||||
<AlphabeticalReverseSortIcon />
|
||||
)
|
||||
) : (
|
||||
<SortManualIcon />
|
||||
<ManualSortIcon />
|
||||
);
|
||||
},
|
||||
children: [
|
||||
createAction({
|
||||
name: ({ t }) => t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "asc"
|
||||
);
|
||||
},
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
@@ -224,15 +234,15 @@ export const sortCollection = createActionWithChildren({
|
||||
createAction({
|
||||
name: ({ t }) => t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "desc"
|
||||
);
|
||||
},
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
@@ -244,12 +254,12 @@ export const sortCollection = createActionWithChildren({
|
||||
createAction({
|
||||
name: ({ t }) => t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.sort.field !== "title";
|
||||
},
|
||||
perform: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "index",
|
||||
@@ -266,19 +276,22 @@ export const searchInCollection = createInternalLinkAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection?.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stores.policies.abilities(collection.id).readDocument;
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
},
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = searchPath({
|
||||
collectionId: collection?.id,
|
||||
collectionId: activeCollectionId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
@@ -295,22 +308,23 @@ export const starCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
!collection.isStarred && stores.policies.abilities(collection.id).star
|
||||
!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
await collection.star();
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
await collection?.star();
|
||||
setPersistedState(getHeaderExpandedKey("starred"), true);
|
||||
},
|
||||
});
|
||||
@@ -321,18 +335,22 @@ export const unstarCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
|
||||
!!collection?.isStarred &&
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
await collection?.unstar();
|
||||
},
|
||||
});
|
||||
@@ -342,25 +360,28 @@ export const subscribeCollection = createAction({
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection.isActive &&
|
||||
!collection.isSubscribed &&
|
||||
stores.policies.abilities(collection.id).subscribe
|
||||
!!collection?.isActive &&
|
||||
!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).subscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.subscribe();
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.subscribe();
|
||||
|
||||
toast.success(t("Subscribed to document notifications"));
|
||||
},
|
||||
});
|
||||
@@ -370,25 +391,28 @@ export const unsubscribeCollection = createAction({
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ getActiveModel, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection.isActive &&
|
||||
!!collection.isSubscribed &&
|
||||
stores.policies.abilities(collection.id).unsubscribe
|
||||
!!collection?.isActive &&
|
||||
!!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
|
||||
if (!activeCollectionId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.unsubscribe();
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.unsubscribe();
|
||||
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
});
|
||||
@@ -398,15 +422,23 @@ export const archiveCollection = createAction({
|
||||
analyticsName: "Archive collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).archive;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
const { dialogs, collections } = stores;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
dialogs.openModal({
|
||||
title: t("Archive collection"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
@@ -431,10 +463,17 @@ export const restoreCollection = createAction({
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).restore;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -450,10 +489,18 @@ export const deleteCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
|
||||
perform: ({ getActiveModel, t, stores }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, t, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -475,10 +522,18 @@ export const exportCollection = createAction({
|
||||
analyticsName: "Export collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ExportIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some((policy) => policy.abilities.export),
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (!currentTeamId || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!stores.policies.abilities(activeCollectionId).export;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -501,13 +556,13 @@ export const createDocument = createInternalLinkAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "new create document",
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
const [pathname, search] = newDocumentPath(collection?.id).split("?");
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
@@ -523,13 +578,13 @@ export const createTemplate = createInternalLinkAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
const [pathname, search] = newTemplatePath(collection?.id).split("?");
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import type Comment from "~/models/Comment";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import { createAction } from "..";
|
||||
|
||||
@@ -17,17 +17,7 @@ import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, debugPath } from "~/utils/routeHelpers";
|
||||
|
||||
export const goToDebug = createAction({
|
||||
name: "Go to debug screen",
|
||||
icon: <BeakerIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: () => {
|
||||
history.push(debugPath());
|
||||
},
|
||||
});
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
export const copyId = createActionWithChildren({
|
||||
name: ({ t }) => t("Copy ID"),
|
||||
@@ -232,7 +222,6 @@ export const developer = createActionWithChildren({
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
children: [
|
||||
goToDebug,
|
||||
copyId,
|
||||
toggleDebugLogging,
|
||||
toggleDebugSafeArea,
|
||||
|
||||
@@ -35,20 +35,19 @@ import {
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import {
|
||||
ExportContentType,
|
||||
TeamPreference,
|
||||
NavigationNode,
|
||||
} from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { Week } from "@shared/utils/time";
|
||||
import type UserMembership from "~/models/UserMembership";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import { DocumentDownload } from "~/components/DocumentDownload";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
@@ -63,6 +62,7 @@ import {
|
||||
DocumentSection,
|
||||
TrashSection,
|
||||
} from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
@@ -78,9 +78,9 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type { Action, ActionGroup, ActionSeparator } from "~/types";
|
||||
import { Action, ActionGroup, ActionSeparator } from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import env from "~/env";
|
||||
import DocumentArchive from "~/scenes/DocumentArchive";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
@@ -509,6 +509,7 @@ export const shareDocument = createAction({
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
style: { marginBottom: -12 },
|
||||
title: t("Share this document"),
|
||||
content: (
|
||||
<SharePopover
|
||||
@@ -521,61 +522,13 @@ export const shareDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export md markdown html",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Download document"),
|
||||
content: (
|
||||
<DocumentDownload
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Download as Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download({
|
||||
contentType: ExportContentType.Markdown,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("Download as HTML"),
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "xml html export",
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
@@ -584,38 +537,70 @@ export const downloadDocumentAsHTML = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download({
|
||||
contentType: ExportContentType.Html,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
await document?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
name: ({ t }) => t("Download as PDF"),
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "pdf export",
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!(
|
||||
activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED
|
||||
),
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toast.loading(`${t("Exporting")}…`);
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return document
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && toast.dismiss(id));
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download({
|
||||
contentType: ExportContentType.Pdf,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
await document?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createActionWithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
children: [
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
downloadDocumentAsMarkdown,
|
||||
],
|
||||
});
|
||||
|
||||
export const copyDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: ActiveDocumentSection,
|
||||
@@ -629,11 +614,10 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
const res = await client.post("/documents.export", {
|
||||
id: document.id,
|
||||
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
|
||||
});
|
||||
copy(res.data);
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toMarkdown(document));
|
||||
toast.success(t("Markdown copied to clipboard"));
|
||||
}
|
||||
},
|
||||
@@ -652,8 +636,9 @@ export const copyDocumentAsPlainText = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toPlainText(document));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
@@ -1043,7 +1028,7 @@ export const moveDocumentToCollection = createAction({
|
||||
title: t("Move {{ documentType }}", {
|
||||
documentType: document.noun,
|
||||
}),
|
||||
content: <DocumentMove document={document} />,
|
||||
content: <DocumentMove documents={[document]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1109,19 +1094,7 @@ export const archiveDocument = createAction({
|
||||
|
||||
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]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1245,12 +1218,7 @@ export const deleteDocument = createAction({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
content: (
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
content: <DocumentDelete documents={[document]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1461,9 +1429,6 @@ export const rootDocumentActions = [
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
downloadDocumentAsMarkdown,
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
import type Integration from "~/models/Integration";
|
||||
import Integration from "~/models/Integration";
|
||||
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
|
||||
import type { IntegrationType } from "@shared/types";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { settingsPath } from "@shared/utils/routeHelpers";
|
||||
import history from "~/utils/history";
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import stores from "~/stores";
|
||||
import type SearchQuery from "~/models/SearchQuery";
|
||||
import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import {
|
||||
createAction,
|
||||
@@ -224,13 +224,13 @@ export const openKeyboardShortcuts = createAction({
|
||||
export const downloadApp = createExternalLinkAction({
|
||||
name: ({ t }) =>
|
||||
t("Download {{ platform }} app", {
|
||||
platform: isMac ? "macOS" : "Windows",
|
||||
platform: isMac() ? "macOS" : "Windows",
|
||||
}),
|
||||
analyticsName: "Download app",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <BrowserIcon />,
|
||||
visible: () => !Desktop.isElectron() && isMac && isCloudHosted,
|
||||
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
|
||||
url: "https://desktop.getoutline.com",
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ArchiveIcon, CheckmarkIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { createAction } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
import type Notification from "~/models/Notification";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
name: ({ t }) => t("Mark notifications as read"),
|
||||
@@ -23,36 +22,6 @@ export const markNotificationsAsArchived = createAction({
|
||||
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
|
||||
});
|
||||
|
||||
export const notificationMarkRead = (notification: Notification) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as read"),
|
||||
analyticsName: "Mark notification read",
|
||||
section: NotificationSection,
|
||||
icon: <CheckmarkIcon />,
|
||||
perform: () => notification.toggleRead(),
|
||||
visible: () => !notification.viewedAt,
|
||||
});
|
||||
|
||||
export const notificationMarkUnread = (notification: Notification) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as unread"),
|
||||
analyticsName: "Mark notification unread",
|
||||
section: NotificationSection,
|
||||
icon: <CheckmarkIcon />,
|
||||
perform: () => notification.toggleRead(),
|
||||
visible: () => !!notification.viewedAt,
|
||||
});
|
||||
|
||||
export const notificationArchive = (notification: Notification) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Mark notification as archived",
|
||||
section: NotificationSection,
|
||||
icon: <ArchiveIcon />,
|
||||
perform: () => notification.archive(),
|
||||
visible: () => !notification.archivedAt,
|
||||
});
|
||||
|
||||
export const rootNotificationActions = [
|
||||
markNotificationsAsRead,
|
||||
markNotificationsAsArchived,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } from "outline-icons";
|
||||
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import stores from "~/stores";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { createAction } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentHistoryPath,
|
||||
matchDocumentHistory,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const restoreRevision = createAction({
|
||||
@@ -76,105 +73,37 @@ export const deleteRevision = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyLinkToRevision = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
section: RevisionSection,
|
||||
perform: async ({ activeDocumentId, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
export const copyLinkToRevision = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
section: RevisionSection,
|
||||
perform: async ({ activeDocumentId, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
const match = matchPath<{ revisionId: string }>(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
});
|
||||
const revisionId = match?.params.revisionId;
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = urlify(documentHistoryPath(document, revisionId));
|
||||
const url = `${window.location.origin}${documentHistoryPath(
|
||||
document,
|
||||
revisionId
|
||||
)}`;
|
||||
|
||||
copy(url, {
|
||||
format: "text/plain",
|
||||
onCopy: () => {
|
||||
toast.message(t("Link copied"));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevisionAsHTML = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download revision as HTML",
|
||||
section: RevisionSection,
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async () => {
|
||||
const revision = stores.revisions.get(revisionId);
|
||||
await revision?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevisionAsPDF = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download revision as PDF",
|
||||
section: RevisionSection,
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!(
|
||||
activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED
|
||||
),
|
||||
perform: ({ t }) => {
|
||||
const id = toast.loading(`${t("Exporting")}…`);
|
||||
const revision = stores.revisions.get(revisionId);
|
||||
return revision
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && toast.dismiss(id));
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevisionAsMarkdown = (revisionId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download revision as Markdown",
|
||||
section: RevisionSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async () => {
|
||||
const revision = stores.revisions.get(revisionId);
|
||||
await revision?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadRevision = (revisionId: string) =>
|
||||
createActionWithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download revision")),
|
||||
analyticsName: "Download revision",
|
||||
section: RevisionSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download,
|
||||
children: [
|
||||
downloadRevisionAsHTML(revisionId),
|
||||
downloadRevisionAsPDF(revisionId),
|
||||
downloadRevisionAsMarkdown(revisionId),
|
||||
],
|
||||
});
|
||||
copy(url, {
|
||||
format: "text/plain",
|
||||
onCopy: () => {
|
||||
toast.message(t("Link copied"));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootRevisionActions = [];
|
||||
|
||||
@@ -25,21 +25,6 @@ export const changeToLightTheme = createAction({
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const toggleTheme = createAction({
|
||||
name: ({ t }) => t("Toggle theme"),
|
||||
analyticsName: "Change theme",
|
||||
iconInContextMenu: false,
|
||||
icon: ({ stores }) =>
|
||||
stores.ui.resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
shortcut: ["Meta+Shift+l"],
|
||||
perform: ({ stores }) =>
|
||||
stores.ui.setTheme(
|
||||
stores.ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light
|
||||
),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
name: ({ t }) => t("System"),
|
||||
analyticsName: "Change to system theme",
|
||||
@@ -62,4 +47,4 @@ export const changeTheme = createActionWithChildren({
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
});
|
||||
|
||||
export const rootSettingsActions = [changeTheme, toggleTheme];
|
||||
export const rootSettingsActions = [changeTheme];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import type Share from "~/models/Share";
|
||||
import Share from "~/models/Share";
|
||||
import { createAction, createInternalLinkAction } from "..";
|
||||
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
|
||||
import { ShareSection } from "../sections";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowIcon, PlusIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import type RootStore from "~/stores/RootStore";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
createActionWithChildren,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import type { ActionContext, ExternalLinkAction } from "~/types";
|
||||
import { ActionContext, ExternalLinkAction } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import type { UserRole } from "@shared/types";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
|
||||
import stores from "~/stores";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import {
|
||||
UserChangeRoleDialog,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor } from "history";
|
||||
import { toast } from "sonner";
|
||||
import type { Optional } from "utility-types";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
import {
|
||||
ActionContext,
|
||||
Action,
|
||||
ActionGroup,
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from "~/types";
|
||||
import Analytics from "~/utils/Analytics";
|
||||
import history from "~/utils/history";
|
||||
import type { Action as KbarAction } from "kbar";
|
||||
import { Action as KbarAction } from "kbar";
|
||||
|
||||
export function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ActionContext } from "~/types";
|
||||
import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/* oxlint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import type { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction, resolve } from "~/actions";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import type { ActionVariant, ActionWithChildren } from "~/types";
|
||||
import { ActionVariant, ActionWithChildren } from "~/types";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
@@ -22,7 +21,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
* Button that can be used to trigger an action definition.
|
||||
*/
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function ActionButton_(
|
||||
function _ActionButton(
|
||||
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
/* global ga */
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
import type { PublicEnv } from "@shared/types";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { IntegrationService, PublicEnv } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments/Comments")
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import type Group from "~/models/Group";
|
||||
import Group from "~/models/Group";
|
||||
import { AvatarSize } from "../Avatar/Avatar";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { IAvatar } from "./Avatar";
|
||||
import Avatar, { AvatarSize, AvatarVariant } from "./Avatar";
|
||||
import Avatar, { IAvatar, AvatarSize, AvatarVariant } from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
import { GroupAvatar } from "./GroupAvatar";
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
padding: 1.5px 5.5px;
|
||||
margin: 0 2px;
|
||||
margin-left: 10px;
|
||||
padding: 1px 5px 2px;
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
@@ -17,11 +17,10 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
primary || yellow
|
||||
? "transparent"
|
||||
: transparentize(0.4, theme.textTertiary)};
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export default Badge;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import type { InternalLinkAction, MenuInternalLink } from "~/types";
|
||||
import { InternalLinkAction, MenuInternalLink } from "~/types";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
@@ -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);
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor } from "history";
|
||||
import { DisclosureIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { Props as ActionButtonProps } from "~/components/ActionButton";
|
||||
import ActionButton from "~/components/ActionButton";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
type RealProps = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -27,7 +26,7 @@ const Content = styled.div<ContentProps>`
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Document from "~/models/Document";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
|
||||
@@ -2,8 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { FormData } from "./CollectionForm";
|
||||
import { CollectionForm } from "./CollectionForm";
|
||||
import { CollectionForm, FormData } from "./CollectionForm";
|
||||
|
||||
type Props = {
|
||||
collectionId: string;
|
||||
|
||||
@@ -6,13 +6,13 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import type { CollectionPermission } from "@shared/types";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { CollectionPermission, TeamPreference } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
@@ -22,7 +22,6 @@ import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
|
||||
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
|
||||
|
||||
@@ -141,7 +140,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
Collections are used to group documents and choose permissions
|
||||
</Trans>
|
||||
</Text>
|
||||
<HStack>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("Name")}
|
||||
@@ -165,7 +164,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Following controls are available in create flow, but moved elsewhere for edit */}
|
||||
{!collection && (
|
||||
@@ -223,7 +222,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
@@ -236,7 +235,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import type { FormData } from "./CollectionForm";
|
||||
import { CollectionForm } from "./CollectionForm";
|
||||
import { CollectionForm, FormData } from "./CollectionForm";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
@@ -28,7 +28,7 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
name: collection.name,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection),
|
||||
to: collectionPath(collection.path),
|
||||
}),
|
||||
],
|
||||
[collection, t]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ActionImpl } from "kbar";
|
||||
import { ActionImpl } from "kbar";
|
||||
import { ArrowIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
@@ -7,7 +7,6 @@ import { normalizeKeyDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "~/components/Text";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
|
||||
type Props = {
|
||||
action: ActionImpl;
|
||||
@@ -36,7 +35,7 @@ function CommandBarItem(
|
||||
|
||||
return (
|
||||
<Item active={active} ref={ref}>
|
||||
<Content>
|
||||
<Content align="center" gap={8}>
|
||||
<Icon>
|
||||
{action.icon ? (
|
||||
// @ts-expect-error no icon on ActionImpl
|
||||
@@ -101,7 +100,7 @@ const Ancestor = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
const Content = styled(HStack)`
|
||||
const Content = styled(Flex)`
|
||||
${ellipsis()}
|
||||
flex-shrink: 1;
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type Comment from "~/models/Comment";
|
||||
import Comment from "~/models/Comment";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -31,7 +31,7 @@ export type RefHandle = {
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
*/
|
||||
const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
|
||||
@@ -3,8 +3,7 @@ import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
|
||||
@@ -13,8 +13,8 @@ import Squircle from "@shared/components/Squircle";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import type Document from "~/models/Document";
|
||||
import type Pin from "~/models/Pin";
|
||||
import Document from "~/models/Document";
|
||||
import Pin from "~/models/Pin";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import type { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import type Document from "~/models/Document";
|
||||
import type { Editor } from "~/editor";
|
||||
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import Document from "~/models/Document";
|
||||
import { Editor } from "~/editor";
|
||||
|
||||
class DocumentContext {
|
||||
/** The current document */
|
||||
|
||||
@@ -3,8 +3,8 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
@@ -24,7 +24,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
const { policies } = useStores();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
||||
const [copying, setCopying] = React.useState<boolean>(false);
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
@@ -52,7 +51,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
try {
|
||||
setCopying(true);
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
@@ -67,8 +65,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSubmit(result);
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t copy the document, try again?"));
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,8 +114,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</StyledText>
|
||||
<Button disabled={!selectedPath || copying} onClick={copy}>
|
||||
{copying ? `${t("Copying")}…` : t("Copy")}
|
||||
<Button disabled={!selectedPath} onClick={copy}>
|
||||
{t("Copy")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { ExportContentType, NotificationEventType } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const DocumentDownload = observer(({ document, onSubmit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const hasChildDocuments = !!document.childDocuments.length;
|
||||
|
||||
const [contentType, setContentType] = useState<ExportContentType>(
|
||||
ExportContentType.Markdown
|
||||
);
|
||||
const [includeChildDocuments, setIncludeChildDocuments] =
|
||||
useState<boolean>(hasChildDocuments);
|
||||
|
||||
const handleContentTypeChange = useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setContentType(ev.target.value as ExportContentType);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleIncludeChildDocumentsChange = useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludeChildDocuments(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const response = await document.download({
|
||||
contentType,
|
||||
includeChildDocuments,
|
||||
});
|
||||
|
||||
if (includeChildDocuments && response?.data?.fileOperation) {
|
||||
const fileOperationId = response.data.fileOperation.id;
|
||||
const toastId = `export-${fileOperationId}`;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
toast.success(t("Export started"), {
|
||||
id: toastId,
|
||||
description: t("A link to your file will be sent through email soon"),
|
||||
duration: 3000,
|
||||
});
|
||||
ui.exportToasts.delete(fileOperationId);
|
||||
}, 6000);
|
||||
|
||||
ui.registerExportToast(fileOperationId, toastId, timeoutId);
|
||||
|
||||
toast.loading(t("Export started"), {
|
||||
id: toastId,
|
||||
description: `${t("Preparing your download")}…`,
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
}, [t, ui, document, contentType, includeChildDocuments, onSubmit]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const radioItems = [
|
||||
{
|
||||
title: "Markdown",
|
||||
description: t(
|
||||
"A file containing the selected documents in Markdown format."
|
||||
),
|
||||
value: ExportContentType.Markdown,
|
||||
},
|
||||
{
|
||||
title: "HTML",
|
||||
description: t(
|
||||
"A file containing the selected documents in HTML format."
|
||||
),
|
||||
value: ExportContentType.Html,
|
||||
},
|
||||
];
|
||||
|
||||
if (env.PDF_EXPORT_ENABLED) {
|
||||
radioItems.push({
|
||||
title: "PDF",
|
||||
description: t(
|
||||
"A file containing the selected documents in PDF format."
|
||||
),
|
||||
value: ExportContentType.Pdf,
|
||||
});
|
||||
}
|
||||
|
||||
return radioItems;
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={includeChildDocuments ? t("Export") : t("Download")}
|
||||
>
|
||||
<Flex gap={12} column>
|
||||
{items.map((item) => (
|
||||
<Option key={item.value}>
|
||||
<StyledInput
|
||||
type="radio"
|
||||
name="format"
|
||||
value={item.value}
|
||||
checked={contentType === item.value}
|
||||
onChange={handleContentTypeChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.description ? (
|
||||
<Text size="small" type="secondary">
|
||||
{item.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
{hasChildDocuments && (
|
||||
<>
|
||||
<hr style={{ margin: "16px 0 " }} />
|
||||
<Option>
|
||||
<StyledInput
|
||||
type="checkbox"
|
||||
name="includeChildDocuments"
|
||||
checked={includeChildDocuments}
|
||||
onChange={handleIncludeChildDocumentsChange}
|
||||
/>
|
||||
<Flex column gap={4}>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include child documents")}
|
||||
</Text>
|
||||
<Text as="p" size="small" type="secondary">
|
||||
<Trans
|
||||
defaults="When selected, exporting the document <em>{{documentName}}</em> may take some time."
|
||||
values={{
|
||||
documentName: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>{" "}
|
||||
{user.subscribedToEventType(
|
||||
NotificationEventType.ExportCompleted
|
||||
) && t("You will receive an email when it's complete.")}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Option>
|
||||
</>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
});
|
||||
|
||||
const Option = styled.label`
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
position: relative;
|
||||
top: 1.5px;
|
||||
`;
|
||||
@@ -16,7 +16,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
|
||||
@@ -6,13 +6,12 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import type Document from "~/models/Document";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -55,7 +54,6 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const theme = useTheme();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
@@ -121,9 +119,6 @@ function DocumentListItem(
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
search: highlight
|
||||
? `?q=${encodeURIComponent(highlight)}`
|
||||
: undefined,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
@@ -132,58 +127,56 @@ function DocumentListItem(
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Flex gap={4} auto>
|
||||
<IconWrapper>
|
||||
{document.icon ? (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon
|
||||
outline={document.isDraft}
|
||||
color={theme.textSecondary}
|
||||
/>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
</IconWrapper>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && <StarButton document={document} />}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
</Content>
|
||||
</Flex>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
@@ -197,14 +190,6 @@ function DocumentListItem(
|
||||
);
|
||||
}
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
width: 24px;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
@@ -219,9 +204,12 @@ const Actions = styled(EventBoundary)`
|
||||
flex-grow: 0;
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
${NudeButton}:${hover},
|
||||
${NudeButton}[aria-expanded= "true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
${NudeButton} {
|
||||
&:
|
||||
${hover},
|
||||
&[aria-expanded= "true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -297,14 +285,18 @@ const Heading = styled.span<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.1em;
|
||||
margin-bottom: 0.25em;
|
||||
white-space: nowrap;
|
||||
color: ${s("text")};
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import type Document from "~/models/Document";
|
||||
import type Revision from "~/models/Revision";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import DocumentTasks from "~/components/DocumentTasks";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -182,7 +182,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
<span>
|
||||
{t("in")}
|
||||
<Strong>
|
||||
<DocumentBreadcrumb document={document} maxDepth={1} onlyText />
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</Strong>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import type Document from "~/models/Document";
|
||||
import Document from "~/models/Document";
|
||||
import CircularProgressBar from "~/components/CircularProgressBar";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { bounceIn } from "~/styles/animations";
|
||||
@@ -42,7 +42,7 @@ function DocumentTasks({ document }: Props) {
|
||||
const message = getMessage(t, total, completed);
|
||||
|
||||
return (
|
||||
<Flex align="center" style={{ padding: "0 1px" }} gap={2} shrink={false}>
|
||||
<Flex align="center" style={{ padding: "0 1px" }} gap={2}>
|
||||
{completed === total ? (
|
||||
<Done
|
||||
color={theme.accent}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { observer } from "mobx-react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import type Document from "~/models/Document";
|
||||
import type User from "~/models/User";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
@@ -142,12 +141,11 @@ function EditableTitle(
|
||||
return (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<EventBoundary as="form" onSubmit={handleSave}>
|
||||
<form onSubmit={handleSave}>
|
||||
<Input
|
||||
dir="auto"
|
||||
type="text"
|
||||
lang=""
|
||||
name="title"
|
||||
value={value}
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -157,7 +155,7 @@ function EditableTitle(
|
||||
autoFocus
|
||||
{...rest}
|
||||
/>
|
||||
</EventBoundary>
|
||||
</form>
|
||||
) : (
|
||||
<Text
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import type { Optional } from "utility-types";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
@@ -42,14 +41,7 @@ export type Props = Optional<
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const {
|
||||
id,
|
||||
onChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
onFileUploadStart,
|
||||
onFileUploadStop,
|
||||
} = props;
|
||||
const { id, onChange, onCreateCommentMark, onDeleteCommentMark } = props;
|
||||
const { comments } = useStores();
|
||||
const { shareId } = useShare();
|
||||
const dictionary = useDictionary();
|
||||
@@ -58,27 +50,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
// Upload progress tracking for delayed toast
|
||||
const progressMap = React.useMemo(() => new Map<string, number>(), []);
|
||||
const uploadState = React.useRef<{
|
||||
toastId?: string | number;
|
||||
timeoutId?: ReturnType<typeof setTimeout>;
|
||||
progress: Map<string, number>;
|
||||
}>({ progress: progressMap });
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (
|
||||
file: File | string,
|
||||
uploadOptions?: {
|
||||
id?: string;
|
||||
onProgress?: (fractionComplete: number) => void;
|
||||
}
|
||||
) => {
|
||||
async (file: File | string) => {
|
||||
const options = {
|
||||
id: uploadOptions?.id,
|
||||
documentId: id,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
onProgress: uploadOptions?.onProgress,
|
||||
};
|
||||
const result =
|
||||
file instanceof File
|
||||
@@ -91,49 +67,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
const { handleClickLink } = useEditorClickHandlers({ shareId });
|
||||
|
||||
// Show toast only after uploads have been running for 2 seconds
|
||||
const handleFileUploadStart = React.useCallback(() => {
|
||||
uploadState.current.timeoutId = setTimeout(() => {
|
||||
uploadState.current.toastId = toast.loading(
|
||||
dictionary.uploadingWithProgress(0)
|
||||
);
|
||||
}, 2000);
|
||||
onFileUploadStart?.();
|
||||
}, [onFileUploadStart, dictionary.uploadingWithProgress]);
|
||||
|
||||
const handleFileUploadProgress = React.useCallback(
|
||||
(fileId: string, fractionComplete: number) => {
|
||||
uploadState.current.progress.set(fileId, fractionComplete);
|
||||
|
||||
// Calculate average progress across all files
|
||||
const progressValues = Array.from(uploadState.current.progress.values());
|
||||
const avgProgress =
|
||||
progressValues.reduce((a, b) => a + b, 0) / progressValues.length;
|
||||
const percent = Math.round(avgProgress * 100);
|
||||
|
||||
// Update toast if visible
|
||||
if (uploadState.current.toastId) {
|
||||
toast.loading(dictionary.uploadingWithProgress(percent), {
|
||||
id: uploadState.current.toastId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[dictionary.uploadingWithProgress]
|
||||
);
|
||||
|
||||
const handleFileUploadStop = React.useCallback(() => {
|
||||
if (uploadState.current.timeoutId) {
|
||||
clearTimeout(uploadState.current.timeoutId);
|
||||
uploadState.current.timeoutId = undefined;
|
||||
}
|
||||
if (uploadState.current.toastId) {
|
||||
toast.dismiss(uploadState.current.toastId);
|
||||
uploadState.current.toastId = undefined;
|
||||
}
|
||||
uploadState.current.progress.clear();
|
||||
onFileUploadStop?.();
|
||||
}, [onFileUploadStop]);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
localRef?.current?.focusAtEnd();
|
||||
}, [localRef]);
|
||||
@@ -180,18 +113,16 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
return insertFiles(view, event, pos, files, {
|
||||
uploadFile: handleUploadFile,
|
||||
onFileUploadStart: handleFileUploadStart,
|
||||
onFileUploadStop: handleFileUploadStop,
|
||||
onFileUploadProgress: handleFileUploadProgress,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
dictionary,
|
||||
isAttachment,
|
||||
});
|
||||
},
|
||||
[
|
||||
localRef,
|
||||
handleFileUploadStart,
|
||||
handleFileUploadStop,
|
||||
handleFileUploadProgress,
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
handleUploadFile,
|
||||
]
|
||||
@@ -292,9 +223,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
onFileUploadStart={handleFileUploadStart}
|
||||
onFileUploadStop={handleFileUploadStop}
|
||||
onFileUploadProgress={handleFileUploadProgress}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { s } from "@shared/styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
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";
|
||||
@@ -15,7 +16,6 @@ import { compressImage } from "~/utils/compressImage";
|
||||
import { generateEmojiNameFromFilename } from "~/utils/emoji";
|
||||
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import { VStack } from "./primitives/VStack";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
@@ -108,16 +108,12 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// Skip compression for GIFs to preserve animation
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
const compressed = await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
@@ -151,14 +147,29 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Square images with transparent backgrounds work best. If your image is too large, we’ll try to resize it for you."
|
||||
"The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<LabelText as="label">{t("Upload an image")}</LabelText>
|
||||
<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()} />
|
||||
<VStack>
|
||||
<Flex column align="center" gap={8}>
|
||||
{file ? (
|
||||
<>
|
||||
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
|
||||
@@ -183,25 +194,9 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Flex>
|
||||
</DropZone>
|
||||
|
||||
<Input
|
||||
label={t("Choose a name")}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my_custom_emoji"
|
||||
autoFocus
|
||||
required
|
||||
error={
|
||||
!isValidName
|
||||
? t(
|
||||
"name can only contain lowercase letters, numbers, and underscores."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{name.trim() && isValidName && (
|
||||
<Text type="secondary" style={{ marginTop: "8px" }}>
|
||||
{t("This emoji will be available as")} <code>:{name}:</code>
|
||||
@@ -218,7 +213,6 @@ const DropZone = styled.div`
|
||||
text-align: center;
|
||||
cursor: var(--pointer);
|
||||
transition: border-color 0.2s;
|
||||
margin-bottom: 1em;
|
||||
|
||||
&:hover {
|
||||
border-color: ${s("inputBorderFocused")};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type { WithTranslation } from "react-i18next";
|
||||
import { withTranslation, Trans } from "react-i18next";
|
||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type Document from "~/models/Document";
|
||||
import type Event from "~/models/Event";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Time from "~/components/Time";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Text from "./Text";
|
||||
|
||||
@@ -4,13 +4,15 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { FileOperationFormat, NotificationEventType } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
@@ -25,7 +27,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
React.useState<boolean>(true);
|
||||
const [includePrivate, setIncludePrivate] = React.useState<boolean>(true);
|
||||
const user = useCurrentUser();
|
||||
const { collections, ui } = useStores();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
@@ -51,40 +53,23 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
let response;
|
||||
|
||||
if (collection) {
|
||||
response = await collection.export(format, includeAttachments);
|
||||
await collection.export(format, includeAttachments);
|
||||
toast.success(t("Export started"), {
|
||||
description: t(`Your file will be available in {{ location }} soon`, {
|
||||
location: `"${t("Settings")} > ${t("Export")}"`,
|
||||
}),
|
||||
action: {
|
||||
label: t("View"),
|
||||
onClick: () => {
|
||||
history.push(settingsPath("export"));
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
response = await collections.export({
|
||||
format,
|
||||
includeAttachments,
|
||||
includePrivate,
|
||||
});
|
||||
await collections.export({ format, includeAttachments, includePrivate });
|
||||
toast.success(t("Export started"));
|
||||
}
|
||||
|
||||
if (response?.data?.fileOperation) {
|
||||
const fileOperationId = response.data.fileOperation.id;
|
||||
const toastId = `export-${fileOperationId}`;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
toast.success(t("Export started"), {
|
||||
id: toastId,
|
||||
description: t("A link to your file will be sent through email soon"),
|
||||
duration: 3000,
|
||||
});
|
||||
ui.exportToasts.delete(fileOperationId);
|
||||
}, 6000);
|
||||
|
||||
ui.registerExportToast(fileOperationId, toastId, timeoutId);
|
||||
|
||||
toast.loading(t("Export started"), {
|
||||
id: toastId,
|
||||
description: `${t("Preparing your download")}…`,
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import type { PaginatedItem } from "./PaginatedList";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
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";
|
||||
@@ -26,10 +25,8 @@ type Props = {
|
||||
className?: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
showFilter?: boolean;
|
||||
showIcons?: boolean;
|
||||
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
|
||||
fetchQueryOptions?: Record<string, string>;
|
||||
disclosure?: boolean;
|
||||
};
|
||||
|
||||
const FilterOptions = ({
|
||||
@@ -38,10 +35,8 @@ const FilterOptions = ({
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
showIcons = true,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
disclosure = true,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -62,7 +57,7 @@ const FilterOptions = ({
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={
|
||||
option.icon && showIcons ? (
|
||||
option.icon ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined
|
||||
}
|
||||
@@ -74,7 +69,7 @@ const FilterOptions = ({
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
),
|
||||
[onSelect, showIcons, selectedKeys]
|
||||
[onSelect, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
@@ -185,8 +180,8 @@ const FilterOptions = ({
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
disclosure
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Document], "type">;
|
||||
|
||||
const HoverPreviewDocument = React.forwardRef(function HoverPreviewDocument_(
|
||||
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
||||
{ url, id, title, summary, lastActivityByViewer }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import type User from "~/models/User";
|
||||
import User from "~/models/User";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
@@ -17,7 +17,7 @@ import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
|
||||
|
||||
const HoverPreviewGroup = React.forwardRef(function HoverPreviewGroup_(
|
||||
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
|
||||
{ name, description, memberCount, users }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
@@ -3,8 +3,11 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import {
|
||||
IntegrationService,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
@@ -21,7 +24,7 @@ import {
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Issue], "type">;
|
||||
|
||||
const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
{ url, id, title, description, author, labels, state, createdAt }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewLink = React.forwardRef(function HoverPreviewLink_(
|
||||
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
{ url, thumbnailUrl, title, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as React from "react";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function HoverPreviewMention_(
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ avatarUrl, name, lastActive, color }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.PR], "type">;
|
||||
|
||||
const HoverPreviewPullRequest = React.forwardRef(
|
||||
function HoverPreviewPullRequest_(
|
||||
function _HoverPreviewPullRequest(
|
||||
{ url, title, id, description, author, state, createdAt }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
|
||||
+29
-9
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -12,23 +13,37 @@ type Props = {
|
||||
onSelect: (color: string) => void;
|
||||
};
|
||||
|
||||
const IconColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
const ColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
const [selectedColor, setSelectedColor] = React.useState(activeColor);
|
||||
const isBuiltInColor = colorPalette.includes(selectedColor);
|
||||
const color = isBuiltInColor ? undefined : selectedColor;
|
||||
|
||||
const debouncedOnSelect = React.useMemo(
|
||||
() =>
|
||||
debounce((color: string) => {
|
||||
onSelect(color);
|
||||
}, 250),
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
debouncedOnSelect.cancel();
|
||||
},
|
||||
[debouncedOnSelect]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedColor(activeColor);
|
||||
}, [activeColor]);
|
||||
|
||||
const handleSelect = (color: string) => {
|
||||
setSelectedColor(color);
|
||||
onSelect(color);
|
||||
debouncedOnSelect(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container justify="space-between" align="center" auto>
|
||||
<PresetColors activeColor={selectedColor} onClick={handleSelect} />
|
||||
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
|
||||
<Divider />
|
||||
<SwatchButton
|
||||
color={color}
|
||||
@@ -36,7 +51,7 @@ const IconColorPicker = ({ activeColor, onSelect }: Props) => {
|
||||
onChange={handleSelect}
|
||||
pickerInModal
|
||||
/>
|
||||
</Container>
|
||||
</BuiltinColors>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,14 +61,18 @@ const Divider = styled.div`
|
||||
background-color: ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const PresetColors = ({
|
||||
const BuiltinColors = ({
|
||||
activeColor,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
activeColor: string;
|
||||
onClick: (color: string) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
<Container className={className} justify="space-between" align="center" auto>
|
||||
{colorPalette.map((color) => (
|
||||
<ColorButton
|
||||
key={color}
|
||||
@@ -62,7 +81,8 @@ const PresetColors = ({
|
||||
onClick={() => onClick(color)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
|
||||
const Container = styled(Flex)`
|
||||
@@ -71,4 +91,4 @@ const Container = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
export default IconColorPicker;
|
||||
export default ColorPicker;
|
||||
@@ -1,13 +1,12 @@
|
||||
import Flex from "@shared/components/Flex";
|
||||
import styled from "styled-components";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
export const UserInputContainer = styled(HStack)`
|
||||
export const UserInputContainer = styled(Flex)`
|
||||
height: 48px;
|
||||
padding: 6px 12px 0px;
|
||||
`;
|
||||
|
||||
export const StyledInputSearch = styled(InputSearch)`
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
@@ -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,25 +1,14 @@
|
||||
import concat from "lodash/concat";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { EmojiSkinTone } from "@shared/types";
|
||||
import { EmojiCategory, IconType } from "@shared/types";
|
||||
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
|
||||
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
|
||||
import { DisplayCategory } from "../utils";
|
||||
import type { DataNode, EmojiNode } from "./GridTemplate";
|
||||
import GridTemplate from "./GridTemplate";
|
||||
import GridTemplate, { DataNode } from "./GridTemplate";
|
||||
import SkinTonePicker from "./SkinTonePicker";
|
||||
import { StyledInputSearch, UserInputContainer } from "./Components";
|
||||
import { useIconState } from "../useIconState";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import { MenuButton } from "./MenuButton";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const GRID_HEIGHT = 410;
|
||||
|
||||
@@ -41,15 +30,9 @@ const EmojiPanel = ({
|
||||
height = GRID_HEIGHT,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { emojis, dialogs } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
|
||||
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const customEmojis = useComputed(
|
||||
() => emojis.orderedData.map(toIcon),
|
||||
[emojis.orderedData]
|
||||
);
|
||||
|
||||
const {
|
||||
emojiSkinTone: skinTone,
|
||||
@@ -58,20 +41,11 @@ const EmojiPanel = ({
|
||||
getFrequentIcons,
|
||||
} = useIconState(IconType.Emoji);
|
||||
|
||||
const {
|
||||
incrementIconCount: incrementCustomIconCount,
|
||||
getFrequentIcons: getFrequentCustomIcons,
|
||||
} = useIconState(IconType.Custom);
|
||||
|
||||
const freqEmojis = React.useMemo(
|
||||
() => getFrequentIcons(),
|
||||
[getFrequentIcons]
|
||||
);
|
||||
|
||||
const [freqCustomEmojis, setFreqCustomEmojis] = React.useState<EmojiNode[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onQueryChange(event.target.value);
|
||||
@@ -86,68 +60,23 @@ const EmojiPanel = ({
|
||||
[setEmojiSkinTone]
|
||||
);
|
||||
|
||||
const handleUploadClick = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Upload emoji"),
|
||||
content: <EmojiCreateDialog onSubmit={dialogs.closeAllModals} />,
|
||||
});
|
||||
}, [dialogs, t]);
|
||||
|
||||
const handleEmojiSelection = React.useCallback(
|
||||
({ id, value }: { id: string; value: string }) => {
|
||||
onEmojiChange(value);
|
||||
|
||||
// Determine if this is a custom emoji by checking if it's in the custom emoji data
|
||||
const isCustomEmoji =
|
||||
customEmojis.some((emoji) => emoji.id === id) ||
|
||||
freqCustomEmojis.some((emoji) => emoji.id === id);
|
||||
|
||||
if (isCustomEmoji) {
|
||||
incrementCustomIconCount(id);
|
||||
} else {
|
||||
incrementIconCount(id);
|
||||
}
|
||||
incrementIconCount(id);
|
||||
},
|
||||
[
|
||||
onEmojiChange,
|
||||
incrementIconCount,
|
||||
incrementCustomIconCount,
|
||||
customEmojis,
|
||||
freqCustomEmojis,
|
||||
]
|
||||
[onEmojiChange, incrementIconCount]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Load frequent custom emojis
|
||||
getFrequentCustomIcons().forEach((id) => {
|
||||
emojis
|
||||
.fetch(id)
|
||||
.then((emoji) => {
|
||||
setFreqCustomEmojis((prev) => {
|
||||
if (prev.some((item) => item.id === id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, toIcon(emoji)];
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
});
|
||||
});
|
||||
}, [emojis, getFrequentCustomIcons]);
|
||||
|
||||
const isSearch = query !== "";
|
||||
const templateData: DataNode[] = isSearch
|
||||
? getSearchResults({
|
||||
query,
|
||||
skinTone,
|
||||
customEmojis,
|
||||
})
|
||||
: getAllEmojis({
|
||||
skinTone,
|
||||
freqEmojis,
|
||||
customEmojis,
|
||||
freqCustomEmojis,
|
||||
});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -160,7 +89,7 @@ const EmojiPanel = ({
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<UserInputContainer>
|
||||
<UserInputContainer align="center" gap={12}>
|
||||
<StyledInputSearch
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
@@ -168,14 +97,6 @@ const EmojiPanel = ({
|
||||
onChange={handleFilter}
|
||||
/>
|
||||
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
|
||||
{can.update && (
|
||||
<MenuButton
|
||||
onClick={handleUploadClick}
|
||||
aria-label={t("Upload emoji")}
|
||||
>
|
||||
<PlusIcon />
|
||||
</MenuButton>
|
||||
)}
|
||||
</UserInputContainer>
|
||||
<GridTemplate
|
||||
ref={scrollableRef}
|
||||
@@ -183,11 +104,6 @@ const EmojiPanel = ({
|
||||
height={height - 48}
|
||||
data={templateData}
|
||||
onIconSelect={handleEmojiSelection}
|
||||
empty={
|
||||
<IconButton onClick={handleUploadClick}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
@@ -196,32 +112,19 @@ const EmojiPanel = ({
|
||||
const getSearchResults = ({
|
||||
query,
|
||||
skinTone,
|
||||
customEmojis,
|
||||
}: {
|
||||
query: string;
|
||||
skinTone: EmojiSkinTone;
|
||||
customEmojis: EmojiNode[];
|
||||
}): DataNode[] => {
|
||||
const emojis = search({ query, skinTone });
|
||||
|
||||
// Search custom emojis by name
|
||||
const matchingCustomEmojis = customEmojis.filter((emoji) =>
|
||||
emoji.name?.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const allResults = [
|
||||
...matchingCustomEmojis,
|
||||
...emojis.map((emoji) => ({
|
||||
type: IconType.Emoji as const,
|
||||
id: emoji.id,
|
||||
value: emoji.value,
|
||||
})),
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
category: DisplayCategory.Search,
|
||||
icons: allResults,
|
||||
icons: emojis.map((emoji) => ({
|
||||
type: IconType.Emoji,
|
||||
id: emoji.id,
|
||||
value: emoji.value,
|
||||
})),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -229,32 +132,21 @@ const getSearchResults = ({
|
||||
const getAllEmojis = ({
|
||||
skinTone,
|
||||
freqEmojis,
|
||||
customEmojis,
|
||||
freqCustomEmojis,
|
||||
}: {
|
||||
skinTone: EmojiSkinTone;
|
||||
freqEmojis: string[];
|
||||
customEmojis: EmojiNode[];
|
||||
freqCustomEmojis: EmojiNode[];
|
||||
}): DataNode[] => {
|
||||
const emojisWithCategory = getEmojisWithCategory({ skinTone });
|
||||
|
||||
const getFrequentIcons = (): DataNode => {
|
||||
const emojis = getEmojis({ ids: freqEmojis, skinTone });
|
||||
|
||||
// Combine frequent standard and custom emojis
|
||||
const allFrequent = [
|
||||
...emojis.map((emoji) => ({
|
||||
type: IconType.Emoji as const,
|
||||
return {
|
||||
category: DisplayCategory.Frequent,
|
||||
icons: emojis.map((emoji) => ({
|
||||
type: IconType.Emoji,
|
||||
id: emoji.id,
|
||||
value: emoji.value,
|
||||
})),
|
||||
...freqCustomEmojis,
|
||||
];
|
||||
|
||||
return {
|
||||
category: DisplayCategory.Frequent,
|
||||
icons: allFrequent,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -270,7 +162,7 @@ const getAllEmojis = ({
|
||||
};
|
||||
};
|
||||
|
||||
const allData = concat(
|
||||
return concat(
|
||||
getFrequentIcons(),
|
||||
getCategoryData(EmojiCategory.People),
|
||||
getCategoryData(EmojiCategory.Nature),
|
||||
@@ -281,22 +173,6 @@ const getAllEmojis = ({
|
||||
getCategoryData(EmojiCategory.Symbols),
|
||||
getCategoryData(EmojiCategory.Flags)
|
||||
);
|
||||
|
||||
if (customEmojis.length) {
|
||||
allData.push({
|
||||
category: "Custom",
|
||||
icons: customEmojis,
|
||||
});
|
||||
}
|
||||
|
||||
return allData;
|
||||
};
|
||||
|
||||
const toIcon = (emoji: Emoji): EmojiNode => ({
|
||||
type: IconType.Custom,
|
||||
id: emoji.id,
|
||||
value: emoji.id,
|
||||
name: emoji.name,
|
||||
});
|
||||
|
||||
export default EmojiPanel;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import type { ListChildComponentProps } from "react-window";
|
||||
import { FixedSizeList } from "react-window";
|
||||
import { FixedSizeList, ListChildComponentProps } from "react-window";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -37,20 +37,14 @@ export type DataNode = {
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** Width of the grid container */
|
||||
width: number;
|
||||
/** Height of the grid container */
|
||||
height: number;
|
||||
/** Data to be displayed in the grid */
|
||||
data: DataNode[];
|
||||
/** Content to display when search results are empty */
|
||||
empty?: React.ReactNode;
|
||||
/** Callback when an icon is selected */
|
||||
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
|
||||
};
|
||||
|
||||
const GridTemplate = (
|
||||
{ width, height, data, empty, onIconSelect }: Props,
|
||||
{ width, height, data, onIconSelect }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
// 24px padding for the Grid Container
|
||||
@@ -58,6 +52,10 @@ const GridTemplate = (
|
||||
|
||||
const gridItems = compact(
|
||||
data.flatMap((node) => {
|
||||
if (node.icons.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const category = (
|
||||
<CategoryName
|
||||
key={node.category}
|
||||
@@ -69,13 +67,6 @@ const GridTemplate = (
|
||||
</CategoryName>
|
||||
);
|
||||
|
||||
if (node.icons.length === 0) {
|
||||
if (node.category !== "Search") {
|
||||
return [];
|
||||
}
|
||||
return [[category], [empty]];
|
||||
}
|
||||
|
||||
const items = node.icons.map((item) => {
|
||||
if (item.type === IconType.SVG) {
|
||||
return (
|
||||
|
||||
@@ -6,9 +6,8 @@ import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import { DisplayCategory } from "../utils";
|
||||
import IconColorPicker from "./IconColorPicker";
|
||||
import type { DataNode } from "./GridTemplate";
|
||||
import GridTemplate from "./GridTemplate";
|
||||
import ColorPicker from "./ColorPicker";
|
||||
import GridTemplate, { DataNode } from "./GridTemplate";
|
||||
import { useIconState } from "../useIconState";
|
||||
|
||||
const IconNames = Object.keys(IconLibrary.mapping);
|
||||
@@ -122,7 +121,7 @@ const IconPanel = ({
|
||||
onChange={handleFilter}
|
||||
/>
|
||||
</InputSearchContainer>
|
||||
<IconColorPicker
|
||||
<ColorPicker
|
||||
width={panelWidth}
|
||||
activeColor={color}
|
||||
onSelect={onColorChange}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { hover, s } from "@shared/styles";
|
||||
import styled from "styled-components";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
export const MenuButton = styled(NudeButton)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: ${s("textSecondary")};
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
padding: 4px;
|
||||
|
||||
&: ${hover} {
|
||||
border: 1px solid ${s("inputBorderFocused")};
|
||||
}
|
||||
`;
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { MenuButton } from "./MenuButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
skinTone,
|
||||
@@ -56,9 +57,9 @@ const SkinTonePicker = ({
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<MenuButton aria-label={t("Choose default skin tone")}>
|
||||
<StyledMenuButton aria-label={t("Choose default skin tone")}>
|
||||
{handEmojiVariants[skinTone]?.value}
|
||||
</MenuButton>
|
||||
</StyledMenuButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
@@ -78,4 +79,15 @@ const Emojis = styled(Flex)`
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
const StyledMenuButton = styled(NudeButton)`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
padding: 4px;
|
||||
|
||||
&: ${hover} {
|
||||
border: 1px solid ${s("inputBorderFocused")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default SkinTonePicker;
|
||||
|
||||
@@ -21,11 +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];
|
||||
@@ -173,7 +175,7 @@ const IconPicker = ({
|
||||
if (open) {
|
||||
void emojis.fetchAll();
|
||||
}
|
||||
}, [open, emojis]);
|
||||
}, [open]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -252,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>
|
||||
@@ -278,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,35 +13,17 @@ export default function CircleIcon({
|
||||
retainColor,
|
||||
...rest
|
||||
}: Props) {
|
||||
const isGradient = color === "rainbow";
|
||||
const fillValue = isGradient ? "url(#circleIconGradient)" : color;
|
||||
|
||||
return (
|
||||
<svg
|
||||
fill={fillValue}
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
style={retainColor ? { fill: fillValue } : undefined}
|
||||
style={retainColor ? { fill: color } : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{isGradient && (
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="circleIconGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="#ff5858" />
|
||||
<stop offset="50%" stopColor="#fbcc34" />
|
||||
<stop offset="100%" stopColor="#00c6ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
export const ConnectedIcon = styled.div<{ color?: string }>`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: ${(props) => props.color ?? props.theme.accent};
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
`;
|
||||
@@ -1,9 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import CircleIcon from "./CircleIcon";
|
||||
|
||||
export const DottedCircleIcon = styled(CircleIcon)`
|
||||
circle {
|
||||
stroke: ${(props) => props.theme.textSecondary};
|
||||
stroke-dasharray: 2, 2;
|
||||
}
|
||||
`;
|
||||
@@ -125,10 +125,11 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export interface Props extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"prefix"
|
||||
> {
|
||||
export interface Props
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"prefix"
|
||||
> {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
|
||||
labelHidden?: boolean;
|
||||
label?: string;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import type { Props as InputProps } from "./Input";
|
||||
import Input from "./Input";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
import Relative from "./Sidebar/components/Relative";
|
||||
import { SwatchButton } from "./SwatchButton";
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import type { Permission } from "~/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
|
||||
type Props = Pick<
|
||||
React.ComponentProps<typeof InputSelect>,
|
||||
|
||||
@@ -2,8 +2,7 @@ import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import type { Props as InputProps } from "~/components/Input";
|
||||
import Input from "~/components/Input";
|
||||
import Input, { Props as InputProps } from "~/components/Input";
|
||||
|
||||
type Props = InputProps & {
|
||||
placeholder?: string;
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import type { DefaultTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
+21
-21
@@ -1,11 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import type { Keyframes } from "styled-components";
|
||||
import styled, { css, keyframes } from "styled-components";
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||
import styled, { css, Keyframes, keyframes } from "styled-components";
|
||||
import {
|
||||
ComponentProps,
|
||||
createContext,
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
@@ -41,12 +42,12 @@ import { Separator } from "./Actions";
|
||||
import useSwipe from "~/hooks/useSwipe";
|
||||
import { toast } from "sonner";
|
||||
import { findIndex } from "lodash";
|
||||
import type { LightboxImage } from "@shared/editor/lib/Lightbox";
|
||||
import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch";
|
||||
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";
|
||||
@@ -54,7 +55,6 @@ import { useEditor } from "~/editor/components/EditorContext";
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import { ImageSource } from "@shared/editor/lib/FileHelper";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { HStack } from "./primitives/HStack";
|
||||
|
||||
export enum LightboxStatus {
|
||||
READY_TO_OPEN,
|
||||
@@ -97,8 +97,6 @@ type Props = {
|
||||
onUpdate: (activeImage: LightboxImage | null) => void;
|
||||
/** Callback triggered when Lightbox closes */
|
||||
onClose: () => void;
|
||||
/** Whether the editor is read only */
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
const ZoomPanPinchContext = createContext({ isImagePanning: false });
|
||||
@@ -218,7 +216,7 @@ function usePanning() {
|
||||
};
|
||||
}
|
||||
|
||||
function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
|
||||
const isIdle = useIdle(3 * Second.ms);
|
||||
const { t } = useTranslation();
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
@@ -506,16 +504,18 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
const toTx = to.center.x - final.center.x;
|
||||
const toTy = to.center.y - final.center.y;
|
||||
|
||||
const fromS = from.width / final.width;
|
||||
const toS = to.width / final.width;
|
||||
const fromSx = from.width / final.width;
|
||||
const fromSy = from.height / final.height;
|
||||
const toSx = to.width / final.width;
|
||||
const toSy = to.height / final.height;
|
||||
return keyframes`
|
||||
from {
|
||||
translate: ${fromTx}px ${fromTy}px;
|
||||
scale: ${fromS};
|
||||
scale: ${fromSx} ${fromSy};
|
||||
}
|
||||
to {
|
||||
translate: ${toTx}px ${toTy}px;
|
||||
scale: ${toS};
|
||||
scale: ${toSx} ${toSy};
|
||||
}
|
||||
`;
|
||||
};
|
||||
@@ -573,10 +573,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
};
|
||||
|
||||
const svgDataURLToBlob = (dataURL: string) => {
|
||||
// Match the SVG data URL format (with or without charset)
|
||||
const match = dataURL.match(
|
||||
/^data:image\/svg\+xml(?:;charset=utf-8)?,(.*)$/i
|
||||
);
|
||||
// Match the SVG data URL format
|
||||
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
@@ -773,8 +771,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
{activeImage.source === ImageSource.DiagramsNet &&
|
||||
!Desktop.isElectron() &&
|
||||
!readOnly && (
|
||||
!Desktop.isElectron() && (
|
||||
<Tooltip content={t("Edit diagram")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
@@ -907,7 +904,7 @@ type ImageProps = {
|
||||
onMaxZoom: () => void;
|
||||
};
|
||||
|
||||
const Image = forwardRef<HTMLImageElement, ImageProps>(function Image_(
|
||||
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
{
|
||||
src,
|
||||
alt,
|
||||
@@ -1104,13 +1101,16 @@ const ActionButton = styled(Button)`
|
||||
background: transparent;
|
||||
`;
|
||||
|
||||
const Actions = styled(HStack)<{
|
||||
const Actions = styled.div<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 16px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: ${depths.modal};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
useFocusEffect,
|
||||
useRovingTabIndex,
|
||||
} from "@getoutline/react-roving-tabindex";
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user