Merge remote-tracking branch 'origin/main' into tommoor/new-permissions

# Conflicts:
#	app/components/Sharing/Collection/AccessControlList.tsx
#	app/scenes/Document/components/Header.tsx
#	shared/i18n/locales/en_US/translation.json
This commit is contained in:
Tom Moor
2026-05-08 23:21:29 -04:00
853 changed files with 37286 additions and 16380 deletions
+12 -1
View File
@@ -24,7 +24,18 @@
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
}
],
"tsconfig-paths-module-resolver"
[
"module-resolver",
{
"root": ["./"],
"alias": {
"@server": "./server",
"@shared": "./shared",
"~": "./app",
"plugins": "./plugins"
}
}
]
],
"env": {
"production": {
+6
View File
@@ -218,6 +218,12 @@ RATE_LIMITER_ENABLED=true
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Multiplier applied to the hardcoded per-endpoint API rate limits. Use values
# greater than 1 to make the limits more lenient (e.g. 2 doubles the allowed
# requests), or less than 1 to make them stricter. Effective limits are rounded
# to the nearest integer with a minimum of 1. Defaults to 1.
RATE_LIMITER_MULTIPLIER=1
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– INTEGRATIONS –––––––––––
+11
View File
@@ -17,6 +17,8 @@ SLACK_VERIFICATION_TOKEN=test-token-123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
GITHUB_APP_ID=123
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----"
GITLAB_CLIENT_ID=123
GITLAB_CLIENT_SECRET=123
@@ -29,6 +31,15 @@ OIDC_USERINFO_URI=http://localhost/userinfo
IFRAMELY_API_KEY=123
NOTION_CLIENT_ID=123
NOTION_CLIENT_SECRET=123
LINEAR_CLIENT_ID=123
LINEAR_CLIENT_SECRET=123
FIGMA_CLIENT_ID=123
FIGMA_CLIENT_SECRET=123
RATE_LIMITER_ENABLED=false
FILE_STORAGE=local
+16
View File
@@ -0,0 +1,16 @@
name: Install
description: Set up Node.js, Corepack, and install dependencies with yarn cache
runs:
using: composite
steps:
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Use Node.js 24.x
uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "yarn"
- name: Install dependencies
shell: bash
run: yarn install --immutable
@@ -31,6 +31,12 @@ jobs:
if (prAge < TWO_WEEKS) continue;
const hasSkipLabel = pr.labels.some(label =>
label.name === 'pinned'
);
if (hasSkipLabel) continue;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
+36 -96
View File
@@ -18,69 +18,13 @@ env:
SMTP_USERNAME: localhost
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 22.x
uses: actions/setup-node@v5
with:
node-version: 22.x
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
lint:
needs: setup
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 lint --quiet
types:
needs: setup
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 tsc
changes:
runs-on: ubuntu-latest
outputs:
config: ${{ steps.filter.outputs.config }}
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
deps: ${{ steps.filter.outputs.deps }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v2
@@ -90,6 +34,7 @@ jobs:
config:
- '.github/**'
- 'vite.config.ts'
- 'vitest.config.ts'
server:
- 'server/**'
- 'shared/**'
@@ -100,9 +45,36 @@ jobs:
- 'shared/**'
- 'package.json'
- 'yarn.lock'
deps:
- 'package.json'
- 'yarn.lock'
- '.yarnrc.yml'
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- run: yarn lint --quiet
types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- run: yarn tsc
audit:
needs: changes
if: ${{ needs.changes.outputs.deps == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- run: yarn npm audit --severity high --recursive --environment production
test:
needs: [setup, changes]
needs: changes
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -110,21 +82,11 @@ 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') }}
- uses: ./.github/actions/install
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [setup, changes]
needs: changes
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
services:
@@ -148,40 +110,18 @@ 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') }}
- uses: ./.github/actions/install
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | awk "NR % 4 == (${{ matrix.shard }} - 1)")
yarn test --maxWorkers=2 $TESTFILES
run: yarn test:server --maxWorkers=2 --shard=${{ matrix.shard }}/4
bundle-size:
needs: [setup, types, changes]
needs: changes
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == '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') }}
- uses: ./.github/actions/install
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
+43
View File
@@ -0,0 +1,43 @@
name: Docker Build Check
on:
push:
paths:
- "Dockerfile"
- "Dockerfile.base"
pull_request:
paths:
- "Dockerfile"
- "Dockerfile.base"
env:
BASE_IMAGE_NAME: outline-base
jobs:
build:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build base image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
tags: ${{ env.BASE_IMAGE_NAME }}:latest
push: false
- name: Build main image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
+17 -16
View File
@@ -1,4 +1,4 @@
name: Docker
name: Publish build
on:
push:
@@ -17,11 +17,11 @@ jobs:
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -30,14 +30,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.base
@@ -51,7 +51,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.IMAGE_NAME }}
@@ -61,7 +61,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@@ -96,11 +96,11 @@ jobs:
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -109,14 +109,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.base
@@ -130,7 +130,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.IMAGE_NAME }}
@@ -140,7 +140,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@@ -173,6 +173,7 @@ jobs:
needs:
- build-amd
- build-arm
environment: dockerhub
steps:
- name: Download digests
uses: actions/download-artifact@v4
@@ -182,17 +183,17 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_NAME }}
tags: |
+94
View File
@@ -0,0 +1,94 @@
name: Update Node.js LTS
on:
schedule:
# Run every Monday at 9:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-node:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Check for Node.js LTS update
id: check
run: |
# Get current Node version from Dockerfile
CURRENT_VERSION=$(grep -oP 'FROM node:\K[0-9]+\.[0-9]+\.[0-9]+' Dockerfile.base)
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
echo "Current Node.js version: $CURRENT_VERSION"
# Fetch the latest LTS release (any major version) from nodejs.org
LATEST_VERSION=$(curl -s https://nodejs.org/dist/index.json | \
jq -r '[.[] | select(.lts != false)][0].version' | \
sed 's/^v//')
if ! [[ "$LATEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Failed to fetch a valid LTS version (got '$LATEST_VERSION')"
exit 1
fi
echo "latest=$LATEST_VERSION" >> "$GITHUB_OUTPUT"
echo "Latest Node.js LTS version: $LATEST_VERSION"
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
echo "updated=false" >> "$GITHUB_OUTPUT"
echo "Already up to date."
else
echo "updated=true" >> "$GITHUB_OUTPUT"
echo "Update available: $CURRENT_VERSION -> $LATEST_VERSION"
fi
- name: Update Node.js version references
if: steps.check.outputs.updated == 'true'
env:
CURRENT: ${{ steps.check.outputs.current }}
LATEST: ${{ steps.check.outputs.latest }}
run: |
CURRENT_MAJOR=$(echo "$CURRENT" | cut -d. -f1)
LATEST_MAJOR=$(echo "$LATEST" | cut -d. -f1)
# Update Dockerfiles
sed -i "s/node:${CURRENT}-slim/node:${LATEST}-slim/g" Dockerfile
sed -i "s/node:${CURRENT} /node:${LATEST} /g" Dockerfile.base
# Update references that depend on major version
if [ "$CURRENT_MAJOR" != "$LATEST_MAJOR" ]; then
# .nvmrc
echo "$LATEST_MAJOR" > .nvmrc
# CI workflow: step name, node-version, and cache keys
sed -i "s/Use Node.js ${CURRENT_MAJOR}.x/Use Node.js ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
sed -i "s/node-version: ${CURRENT_MAJOR}.x/node-version: ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
# Update cache keys: replace node-modules-[optional old version] with new version
sed -i -E "s/node-modules-([0-9]+\.x-)?/node-modules-${LATEST_MAJOR}.x-/g" .github/workflows/ci.yml
# package.json engines field: append new major version
sed -i "s/\"node\": \"\(.*\)\"/\"node\": \"\1 || ${LATEST_MAJOR}\"/" package.json
fi
echo "Updated Node.js from $CURRENT to $LATEST"
- name: Create pull request
if: steps.check.outputs.updated == 'true'
uses: peter-evans/create-pull-request@v7
with:
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
body: |
Automated update of Node.js in Docker images.
- **Previous version:** ${{ steps.check.outputs.current }}
- **New version:** ${{ steps.check.outputs.latest }}
[Release notes](https://nodejs.org/en/blog/release/v${{ steps.check.outputs.latest }})
branch: automated/update-node-lts
delete-branch: true
labels: dependencies
-63
View File
@@ -1,63 +0,0 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
"projects": [
{
"displayName": "server",
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/setupMocks.js"
],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
},
{
"displayName": "app",
"roots": ["<rootDir>/app"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
+1 -1
View File
@@ -1 +1 @@
22
24
+8 -2
View File
@@ -31,7 +31,7 @@
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-explicit-any": "warn",
"no-explicit-any": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
@@ -73,9 +73,13 @@
"eqeqeq": "error",
"curly": "error",
"no-console": "error",
"no-unused-expressions": "error",
"arrow-body-style": ["error", "as-needed"],
"no-useless-escape": "off",
"react/react-in-jsx-scope": "off",
"typescript/await-thenable": "error",
"typescript/no-duplicate-type-constituents": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/require-array-sort-compare": "error",
"react/self-closing-comp": [
"error",
{
@@ -87,6 +91,8 @@
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"typescript/consistent-type-imports": "error",
"typescript/restrict-template-expressions": "error",
"typescript/no-floating-promises": "error",
"no-unused-vars": [
"error",
{
+9 -1
View File
@@ -1,6 +1,14 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
enableScripts: false
npmMinimalAgeGate: 4320
npmPreapprovedPackages:
- outline-icons
# Build-time advisories that don't affect runtime request handling.
# Re-evaluate when bumping the relevant dev/build dep.
npmAuditIgnoreAdvisories:
- "1113517" # GHSA-mw96-cpmx-2vgc rollup <2.80.0 path traversal (workbox-build, build-time)
- "1113686" # GHSA-5c6j-r48x-rmvq serialize-javascript RCE (@rollup/plugin-terser, build-time)
+15 -2
View File
@@ -9,7 +9,7 @@ There is a web client which is fully responsive and works on mobile devices.
- **`shared/`** - Shared TypeScript types, utilities, and editor components
- **`plugins/`** - Plugin system for extending functionality
- **`public/`** - Static assets served directly
- **Various config files** - TypeScript, Vite, Jest, Prettier, Oxlint configurations
- **Various config files** - TypeScript, Vite, Vitest, Prettier, Oxlint configurations
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
@@ -46,6 +46,18 @@ You're an expert in the following areas:
yarn install
```
- When adding a `resolutions` entry to address a security advisory in a transitive dependency, target only the specific vulnerable descriptors using the `name@npm:<range>` syntax rather than overriding the package globally. Inspect `yarn.lock` to find the exact ranges requested by upstream packages and add one entry per vulnerable range, e.g.:
```json
"resolutions": {
"qs@npm:^6.5.2": "^6.14.2",
"qs@npm:^6.11.0": "^6.14.2",
"qs@npm:^6.14.0": "^6.14.2"
}
```
This keeps overrides scoped to the affected dependents and avoids forcing unrelated consumers onto an incompatible version.
## TypeScript Usage
- Use strict mode.
@@ -140,7 +152,7 @@ yarn sequelize migration:create --name=add-field-to-table
## Testing
- Run tests with Jest:
- Run tests with Vitest:
```bash
# Run a specific test file (preferred)
@@ -188,6 +200,7 @@ yarn test:shared # All shared code tests
## Security
- Sanitize all user input.
- Always use `sanitizeUrl()` when setting `href` or `src` from user-controlled data in ProseMirror `toDOM` methods, regardless of whether it is imported via an alias or a relative path. Unlike React components, `toDOM` writes raw DOM and does not sanitize attribute values.
- Use CSRF protection.
- Use rateLimiter middleware for sensitive endpoints.
- Follow OWASP guidelines.
+1 -1
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:22.21.0-slim AS runner
FROM node:24.15.0-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:22.21.0 AS deps
FROM node:24.15.0 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.6.1
Licensed Work: Outline 1.7.1
The Licensed Work is (c) 2026 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-03-18
Change Date: 2030-05-04
Change License: Apache License, Version 2.0
+11 -11
View File
@@ -27,23 +27,23 @@ Please see the [documentation](https://docs.getoutline.com/s/hosting/) for runni
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
# Development
# Contributing
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
> **Note:** Please do not submit AI-generated pull requests. We receive a high volume of mass, low-quality PRs generated by AI tools like Claude, ChatGPT, and Copilot from contributors who are unfamiliar with the codebase. These PRs are almost never mergeable and waste maintainer time reviewing them. If youd like to contribute, please take the time to understand the codebase and write your changes thoughtfully.
## Contributing
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) wed also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
Outline is built and maintained by a small team your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
If youre looking for ways to get started, heres a list of ways to help us improve Outline:
- [Translation](docs/TRANSLATION.md) into other languages
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
- Performance improvements, both on server and frontend
- Developer happiness and documentation
- Bugs and other issues listed on GitHub
- Bugs, quality fixes, and other issues listed on GitHub
# Development
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
## Architecture
@@ -61,7 +61,7 @@ can be enabled for all categories by setting `DEBUG=*` or for specific categorie
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
To add new tests, write your tests with [Vitest](https://vitest.dev/) and add a file with `.test.ts` extension next to the tested code.
```shell
# To run all tests
@@ -72,7 +72,7 @@ make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly with jest:
frontend and backend tests directly with vitest:
```shell
# To run backend tests
+1
View File
@@ -1,5 +1,6 @@
{
"extends": ["../.oxlintrc.json"],
"ignorePatterns": ["**/*.d.ts"],
"plugins": ["oxc", "eslint", "typescript", "react"],
"overrides": [
{
+20 -1
View File
@@ -1,5 +1,8 @@
import { PlusIcon, TrashIcon } from "outline-icons";
import copy from "copy-to-clipboard";
import { CopyIcon, PlusIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import stores from "~/stores";
import env from "~/env";
import type ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
@@ -25,6 +28,22 @@ export const createApiKey = createAction({
},
});
export const copyApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy API key",
section: SettingsSection,
icon: <CopyIcon />,
visible: () => !!apiKey.value,
perform: ({ t }) => {
copy(apiKey.value, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
toast.success(t("API key copied"));
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t, isMenu }) =>
+16 -8
View File
@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import invariant from "invariant";
import uniqBy from "lodash/uniqBy";
import { capitalize, uniqBy } from "es-toolkit/compat";
import {
DownloadIcon,
DuplicateIcon,
@@ -39,11 +39,13 @@ import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { ExportContentType, TeamPreference } from "@shared/types";
import { isMobile } from "@shared/utils/browser";
import { getEventFiles } from "@shared/utils/files";
import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
@@ -79,7 +81,6 @@ import {
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type {
Action,
@@ -127,7 +128,7 @@ export const openDocument = createActionWithChildren({
color={item.color ?? undefined}
/>
) : (
<DocumentIcon />
<DocumentIcon outline={item.isDraft} />
),
section: DocumentSection,
to: item.url,
@@ -737,8 +738,6 @@ export const copyDocumentAsPlainText = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
copy(ProsemirrorHelper.toPlainText(document));
toast.success(t("Text copied to clipboard"));
}
@@ -970,7 +969,11 @@ export const openDocumentInDesktop = createAction({
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
isCloudHosted &&
(isMac || isWindows) &&
!!document &&
!document.isDeleted &&
!isMobile()
);
},
perform: ({ activeDocumentId, stores }) => {
@@ -988,9 +991,14 @@ export const presentDocument = createAction({
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["Meta+Alt+p"],
visible: ({ activeDocumentId }) => !!activeDocumentId,
shortcut: ["Control+Alt+KeyP"],
visible: ({ activeDocumentId }) => !!activeDocumentId && !isMobile(),
perform: ({ activeDocumentId, stores }) => {
if (stores.ui.presentationData) {
stores.ui.setPresentingDocument(null);
return;
}
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
+1 -1
View File
@@ -2,7 +2,7 @@ import { PlusIcon } from "outline-icons";
import { createAction } from "~/actions";
import { TeamSection } from "../sections";
import stores from "~/stores";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
export const createEmoji = createAction({
name: ({ t }) => `${t("New emoji")}`,
+4 -1
View File
@@ -241,7 +241,10 @@ export const logout = createAction({
section: NavigationSection,
icon: <LogoutIcon />,
perform: async () => {
await stores.auth.logout({ userInitiated: true });
await stores.auth.logout({
userInitiated: true,
clearCache: true,
});
},
});
+1 -2
View File
@@ -25,6 +25,7 @@ import {
settingsPath,
urlify,
} from "~/utils/routeHelpers";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
import { ActiveTemplateSection, TemplateSection } from "../sections";
import Template from "~/models/Template";
import { AvatarSize } from "~/components/Avatar";
@@ -200,8 +201,6 @@ export const copyTemplateAsPlainText = createAction({
perform: async ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
copy(ProsemirrorHelper.toPlainText(template));
toast.success(t("Text copied to clipboard"));
}
+9 -2
View File
@@ -17,8 +17,12 @@ import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
import type { Action as KbarAction } from "kbar";
export function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
export function resolve<T>(value: unknown, context: ActionContext): T {
return (
typeof value === "function"
? (value as (context: ActionContext) => T)(context)
: value
) as T;
}
export const ActionSeparator: TActionSeparator = {
@@ -132,6 +136,7 @@ export function actionToMenuItem(
tooltip: resolve<React.ReactChild>(action.tooltip, context),
selected: resolve<boolean>(action.selected, context),
dangerous: action.dangerous,
shortcut: action.shortcut,
onClick: () => performAction(action, context),
};
@@ -143,6 +148,7 @@ export function actionToMenuItem(
icon,
visible,
disabled,
shortcut: action.shortcut,
to,
};
}
@@ -154,6 +160,7 @@ export function actionToMenuItem(
icon,
visible,
disabled,
shortcut: action.shortcut,
href: action.target
? { url: action.url, target: action.target }
: action.url,
+2 -1
View File
@@ -1,4 +1,5 @@
/* oxlint-disable react/prop-types */
import { observer } from "mobx-react";
import * as React from "react";
import type { Props as TooltipProps } from "~/components/Tooltip";
import Tooltip from "~/components/Tooltip";
@@ -85,4 +86,4 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
}
);
export default ActionButton;
export default observer(ActionButton);
+1 -1
View File
@@ -1,6 +1,6 @@
/* oxlint-disable prefer-rest-params */
/* global ga */
import escape from "lodash/escape";
import { escape } from "es-toolkit/compat";
import * as React from "react";
import type { PublicEnv } from "@shared/types";
import { IntegrationService } from "@shared/types";
+4 -1
View File
@@ -31,7 +31,10 @@ const Authenticated = ({ children }: Props) => {
return <LoadingIndicator />;
}
void auth.logout({ savePath: true });
void auth.logout({
savePath: true,
clearCache: false,
});
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
+29 -6
View File
@@ -1,21 +1,25 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route } from "react-router-dom";
import { useLocation } from "react-router-dom";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import { RightSidebarProvider } from "~/components/RightSidebarContext";
import Sidebar from "~/components/Sidebar";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import history from "~/utils/history";
import { isModKey } from "@shared/utils/keyboard";
import lazyWithRetry from "~/utils/lazyWithRetry";
import {
searchPath,
newDocumentPath,
settingsPath,
homePath,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
@@ -33,11 +37,18 @@ type Props = {
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
useKeyDown(".", (event) => {
if (isModKey(event)) {
ui.toggleCollapsedSidebar();
}
});
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
@@ -60,7 +71,15 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
history.replace(postLoginPath);
try {
history.replace(postLoginPath);
} catch (err) {
Logger.warn("Failed to navigate to post login path, falling back", {
path: postLoginPath,
error: err,
});
history.replace(homePath());
}
}
}, [spendPostLoginPath]);
@@ -68,12 +87,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const isSettings = location.pathname.startsWith(settingsPath());
const sidebar = (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
<React.Suspense fallback={null}>
{isSettings && <SettingsSidebar />}
</React.Suspense>
<div style={isSettings ? { display: "none" } : undefined}>
<Sidebar />
</div>
</Fade>
);
+3 -6
View File
@@ -113,19 +113,16 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
${ellipsis()}
${undraggableOnDesktop()}
display: flex;
flex-shrink: 1;
min-width: 0;
cursor: var(--pointer);
color: ${s("text")};
font-size: 15px;
height: 24px;
line-height: 24px;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
svg {
flex-shrink: 0;
}
margin-inline-start: ${(props) => (props.$withIcon ? "4px" : "0")};
max-width: 460px;
&:hover {
text-decoration: underline;
+3 -3
View File
@@ -125,7 +125,7 @@ const Label = styled.span<{ hasIcon?: boolean }>`
white-space: nowrap;
text-overflow: ellipsis;
${(props) => props.hasIcon && "padding-left: 4px;"};
${(props) => props.hasIcon && "padding-inline-start: 4px;"};
`;
export const Inner = styled.span<{
@@ -135,13 +135,13 @@ export const Inner = styled.span<{
}>`
display: flex;
padding: 0 8px;
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
padding-inline-end: ${(props) => (props.disclosure ? 2 : 8)}px;
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
min-height: 32px;
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && props.hasText && "padding-inline-start: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
+1 -3
View File
@@ -22,9 +22,7 @@ const Circle = ({
if (percentage) {
// because the circle is so small, anything greater than 85% appears like 100%
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
strokePercentage = percentage
? ((100 - percentage) * circumference) / 100
: 0;
strokePercentage = ((100 - percentage) * circumference) / 100;
}
return (
+9 -5
View File
@@ -1,7 +1,4 @@
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { filter, isEqual, orderBy, uniq } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -146,7 +143,14 @@ function Collaborators(props: Props) {
/>
);
},
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
[
presentIds,
editingIds,
observingUserId,
currentUserId,
handleAvatarClick,
t,
]
);
if (!document.insightsEnabled) {
+1 -1
View File
@@ -64,7 +64,7 @@ const StyledTrigger = styled(RadixCollapsible.Trigger)`
padding: 0 0 8px 0;
cursor: var(--pointer);
color: ${s("textTertiary")};
font-size: 14pxte
font-size: 14px;
&:hover {
color: ${s("textSecondary")};
+11 -4
View File
@@ -1,4 +1,4 @@
import uniq from "lodash/uniq";
import { uniq } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useMemo, useEffect, useCallback, Suspense } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -25,10 +25,11 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
import { HStack } from "../primitives/HStack";
import { useDialogContext } from "~/components/DialogContext";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
export interface FormData {
export type FormData = {
name: string;
icon: string;
color: string | null;
@@ -36,7 +37,7 @@ export interface FormData {
permission: CollectionPermission | undefined;
commenting?: boolean | null;
templateManagement: CollectionPermission;
}
};
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
@@ -67,6 +68,7 @@ export const CollectionForm = observer(function CollectionForm_({
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const dialog = useDialogContext();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
@@ -278,7 +280,12 @@ export const CollectionForm = observer(function CollectionForm_({
{collection ? (
options
) : (
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
<Collapsible
label={t("Advanced options")}
onOpenChange={() => dialog.setAnimating(true)}
>
{options}
</Collapsible>
)}
<HStack justify="flex-end">
+9 -6
View File
@@ -3,7 +3,7 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import { normalizeKeyDisplay, shortcutSeparator } from "@shared/utils/keyboard";
import Highlight from "~/components/Highlight";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
@@ -30,8 +30,8 @@ function CommandBarItem(
) {
const theme = useTheme();
const ancestors = React.useMemo(() => {
if (!currentRootActionId) {
return action.ancestors;
if (!currentRootActionId || !action.ancestors) {
return action.ancestors ?? [];
}
const index = action.ancestors.findIndex(
(ancestor) => ancestor.id === currentRootActionId
@@ -90,9 +90,12 @@ function CommandBarItem(
) : (
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
))}
{sc.split("+").flatMap((key, i, arr) => {
const el = <Key key={key}>{normalizeKeyDisplay(key)}</Key>;
return i < arr.length - 1 && shortcutSeparator
? [el, shortcutSeparator]
: [el];
})}
</React.Fragment>
))}
</Shortcut>
@@ -43,7 +43,8 @@ const Container = styled.div`
const Header = styled(Text).attrs({ as: "h3" })`
letter-spacing: 0.03em;
margin: 0;
padding: 16px 0 4px 20px;
padding-block: 16px 4px;
padding-inline: 20px 0;
height: 36px;
cursor: default;
`;
@@ -1,4 +1,5 @@
import { useKBar } from "kbar";
import { escapeRegExp } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
@@ -25,6 +26,41 @@ interface CacheEntry {
const cacheTTL = Minute.ms * 5;
const maxRecentDocs = 5;
/**
* Strip server-generated `<b>` highlight tags from context and re-apply them
* using the current search query. This prevents stale highlights when the
* displayed results are from a previous (in-flight) query.
*
* @param context the server-generated context string with `<b>` tags.
* @param query the current search query to highlight.
* @returns the context string with highlights matching the current query.
*/
function rehighlightContext(
context: string | undefined,
query: string
): string | undefined {
if (!context) {
return context;
}
const plain = context.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
const trimmed = query.trim();
if (!trimmed) {
return plain;
}
const terms = trimmed.split(/\s+/).filter(Boolean);
const patterns = [escapeRegExp(trimmed)];
if (terms.length > 1) {
patterns.push(...terms.map((t) => `\\b${escapeRegExp(t)}\\b`));
}
const regex = new RegExp(patterns.join("|"), "gi");
return plain.replace(regex, "<b>$&</b>");
}
/**
* Registers search result actions in the command bar scoped to a public share.
*/
@@ -40,6 +76,9 @@ function SharedSearchActions() {
searchQuery: state.searchQuery,
}));
const searchQueryRef = React.useRef(searchQuery);
searchQueryRef.current = searchQuery;
React.useEffect(() => {
if (!searchQuery || !shareId) {
setResults([]);
@@ -60,7 +99,9 @@ function SharedSearchActions() {
const currentQuery = searchQuery;
void documents.search({ query: searchQuery, shareId }).then((res) => {
searchCache.current.set(currentQuery, { timestamp: now, results: res });
setResults(res);
if (searchQueryRef.current === currentQuery) {
setResults(res);
}
});
}, [documents, searchQuery, shareId]);
@@ -92,18 +133,19 @@ function SharedSearchActions() {
createAction({
id: `shared-search-${result.document.id}`,
name: result.document.titleWithDefault,
description: result.context,
description: rehighlightContext(result.context, searchQuery),
keywords: searchQuery,
analyticsName: "Open shared search result",
section: SearchResultsSection,
icon: documentIcon(result.document),
perform: () => {
if (shareId) {
const currentQuery = searchQueryRef.current;
addRecentDoc(result.document);
history.push({
pathname: sharedModelPath(shareId, result.document.url),
search: searchQuery
? `?q=${encodeURIComponent(searchQuery)}`
search: currentQuery
? `?q=${encodeURIComponent(currentQuery)}`
: undefined,
});
}
@@ -26,7 +26,7 @@ const useRecentDocumentActions = (count = 6) => {
color={item.color ?? undefined}
/>
) : (
<DocumentIcon />
<DocumentIcon outline={item.isDraft} />
),
to: documentPath(item),
})
+10 -9
View File
@@ -87,22 +87,23 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
}));
const wrappedEvent =
(
callback:
| React.FocusEventHandler<HTMLSpanElement>
| React.FormEventHandler<HTMLSpanElement>
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
<E extends React.SyntheticEvent<HTMLSpanElement>>(
callback: ((event: E) => void) | undefined
) =>
(event: any) => {
(event: E) => {
if (readOnly) {
return;
}
const text = event.currentTarget.textContent || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event?.preventDefault();
if (
maxLength &&
event.nativeEvent instanceof KeyboardEvent &&
isPrintableKeyEvent(event.nativeEvent) &&
text.length >= maxLength
) {
event.preventDefault();
return;
}
@@ -120,7 +120,7 @@ const DefaultCollectionInputSelect = observer(
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
labelHidden
short
/>
);
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext, useMemo, useState } from "react";
export type DialogContext = {
animating: boolean;
setAnimating: (isAnimating: boolean) => void;
};
/**
* Context for the dialogs (Guide/Modal) being rendered.
* This helps control the dialog's behavior from within any nested component.
*/
const DialogContext = createContext<DialogContext>({
animating: false,
setAnimating: () => {},
});
export function DialogProvider({ children }: { children: React.ReactNode }) {
const [animating, setAnimating] = useState(false);
const ctx = useMemo<DialogContext>(
() => ({
animating,
setAnimating,
}),
[animating]
);
return (
<DialogContext.Provider value={ctx}>{children}</DialogContext.Provider>
);
}
export const useDialogContext = () => useContext(DialogContext);
+30 -27
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { DialogProvider } from "./DialogContext";
const Guide = lazyWithRetry(() => import("~/components/Guide"));
const Modal = lazyWithRetry(() => import("~/components/Modal"));
@@ -12,33 +13,35 @@ function Dialogs() {
const modals = [...modalStack];
return (
<Suspense fallback={null}>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{modals.map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{modal.content}
</Modal>
))}
</Suspense>
<DialogProvider>
<Suspense fallback={null}>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{modals.map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{modal.content}
</Modal>
))}
</Suspense>
</DialogProvider>
);
}
+9 -25
View File
@@ -101,11 +101,16 @@ function DocumentBreadcrumb(
<DocumentName
documentId={node.id}
collection={collection}
icon={node.icon}
color={node.color}
title={title}
/>
),
icon: node.icon ? (
<Icon
value={node.icon}
color={node.color}
initial={title.charAt(0).toUpperCase()}
/>
) : undefined,
section: ActiveDocumentSection,
to: {
pathname: node.url,
@@ -197,14 +202,10 @@ const CollectionName = observer(function CollectionName_({
const DocumentName = observer(function DocumentName_({
documentId,
collection,
icon,
color,
title,
}: {
documentId: string;
collection: Collection | undefined;
icon: string | undefined;
color: string | undefined;
title: string;
}) {
const { t } = useTranslation();
@@ -212,21 +213,8 @@ const DocumentName = observer(function DocumentName_({
const doc = documents.get(documentId);
const menuAction = useDocumentMenuAction({ documentId });
const content = icon ? (
<>
<StyledIcon
value={icon}
color={color}
initial={title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title
);
if (!doc) {
return <>{content}</>;
return <>{title}</>;
}
return (
@@ -236,16 +224,12 @@ const DocumentName = observer(function DocumentName_({
}}
>
<ContextMenu action={menuAction} ariaLabel={t("Document options")}>
<span>{content}</span>
<span>{title}</span>
</ContextMenu>
</ActionContextProvider>
);
});
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
+1 -1
View File
@@ -110,7 +110,7 @@ function DocumentCard(props: Props) {
dir={document.dir}
$isDragging={isDragging}
to={{
pathname: document.url,
pathname: document.path,
state: {
title: document.titleWithDefault,
},
@@ -1,11 +1,13 @@
import FuzzySearch from "fuzzy-search";
import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import flatten from "lodash/flatten";
import includes from "lodash/includes";
import map from "lodash/map";
import {
concat,
difference,
fill,
filter,
flatten,
includes,
map,
} from "es-toolkit/compat";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
@@ -22,7 +24,6 @@ import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
@@ -447,10 +448,7 @@ const FlexContainer = styled(Flex)`
justify-content: center;
`;
const ListSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
const ListSearch = styled(InputSearch).attrs({ round: true })`
margin-bottom: 4px;
padding-left: 24px;
padding-right: 24px;
@@ -9,17 +9,27 @@ import Disclosure from "~/components/Sidebar/components/Disclosure";
import Text from "~/components/Text";
type Props = {
/** Whether this node is the chosen destination (committed pick via click or Enter). */
selected: boolean;
/** Whether this node is currently highlighted by pointer hover or keyboard navigation. */
active: boolean;
/** Inline style passed in by the virtualized list for absolute positioning. */
style: React.CSSProperties;
/** Whether this node's children are currently revealed in the tree. */
expanded: boolean;
/** Icon rendered before the title (document icon, emoji, or star). */
icon?: React.ReactNode;
/** Display title for the node. */
title: string;
/** Zero-based nesting depth, used to indent the node. */
depth: number;
/** Whether this node has descendants and should render a disclosure chevron. */
hasChildren: boolean;
/** Fired when the disclosure chevron is clicked to expand or collapse the node. */
onDisclosureClick: (ev: React.MouseEvent) => void;
/** Fired on pointer movement over the node; used to update the active highlight. */
onPointerMove: (ev: React.MouseEvent) => void;
/** Fired when the node is clicked to toggle its selection. */
onClick: (ev: React.MouseEvent) => void;
};
@@ -41,7 +51,7 @@ function DocumentExplorerNode(
) {
const { t } = useTranslation();
const DISCLOSURE = 24;
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
const width = (depth + 2) * DISCLOSURE;
return (
<Node
@@ -79,6 +89,10 @@ const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin: 2px 0;
&&[aria-expanded="true"]:not(:hover) {
background: none;
}
`;
const Spacer = styled(Flex)<{ width: number }>`
+1 -2
View File
@@ -1,5 +1,4 @@
import compact from "lodash/compact";
import sortBy from "lodash/sortBy";
import { compact, sortBy } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
+10 -17
View File
@@ -1,8 +1,9 @@
import difference from "lodash/difference";
import { difference } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { mergeRefs } from "react-merge-refs";
import type { Optional } from "utility-types";
@@ -16,7 +17,6 @@ import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useCurrentUser from "~/hooks/useCurrentUser";
import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
@@ -28,12 +28,7 @@ const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
export type Props = Optional<
EditorProps,
| "placeholder"
| "defaultValue"
| "onClickLink"
| "embeds"
| "dictionary"
| "extensions"
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "extensions"
> & {
embedsDisabled?: boolean;
onSynced?: () => Promise<void>;
@@ -52,7 +47,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
} = props;
const { comments } = useStores();
const { shareId } = useShare();
const dictionary = useDictionary();
const { t } = useTranslation();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
@@ -95,11 +90,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const handleFileUploadStart = React.useCallback(() => {
uploadState.current.timeoutId = setTimeout(() => {
uploadState.current.toastId = toast.loading(
dictionary.uploadingWithProgress(0)
t("Uploading… {{ progress }}%", { progress: 0 })
);
}, 2000);
onFileUploadStart?.();
}, [onFileUploadStart, dictionary.uploadingWithProgress]);
}, [onFileUploadStart, t]);
const handleFileUploadProgress = React.useCallback(
(fileId: string, fractionComplete: number) => {
@@ -113,12 +108,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
// Update toast if visible
if (uploadState.current.toastId) {
toast.loading(dictionary.uploadingWithProgress(percent), {
toast.loading(t("Uploading… {{ progress }}%", { progress: percent }), {
id: uploadState.current.toastId,
});
}
},
[dictionary.uploadingWithProgress]
[t]
);
const handleFileUploadStop = React.useCallback(() => {
@@ -183,7 +178,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
onFileUploadStart: handleFileUploadStart,
onFileUploadStop: handleFileUploadStop,
onFileUploadProgress: handleFileUploadProgress,
dictionary,
isAttachment,
});
},
@@ -192,7 +186,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
handleFileUploadStart,
handleFileUploadStop,
handleFileUploadProgress,
dictionary,
handleUploadFile,
]
);
@@ -211,6 +204,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const commentMarks = localRef.current.getComments();
const commentIds = comments.orderedData.map((c) => c.id);
const commentMarkIds = commentMarks?.map((c) => c.id);
const focus = previousCommentIds.current !== undefined;
const newCommentIds = difference(
commentMarkIds,
previousCommentIds.current ?? [],
@@ -220,7 +214,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
newCommentIds.forEach((commentId) => {
const mark = commentMarks.find((c) => c.id === commentId);
if (mark) {
onCreateCommentMark(mark.id, mark.userId);
onCreateCommentMark(mark.id, mark.userId, { focus });
}
});
@@ -288,7 +282,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
-233
View File
@@ -1,233 +0,0 @@
import * as React from "react";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { getDataTransferFiles } from "@shared/utils/files";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input, { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { compressImage } from "~/utils/compressImage";
import { generateEmojiNameFromFilename } from "~/utils/emoji";
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
import { bytesToHumanReadable } from "@shared/utils/files";
import { VStack } from "./primitives/VStack";
type Props = {
onSubmit: () => void;
};
export function EmojiCreateDialog({ onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [name, setName] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const handleFileSelection = React.useCallback(
(file: File) => {
const isValidType = AttachmentValidation.emojiContentTypes.includes(
file.type
);
if (!isValidType) {
toast.error(
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
);
return;
}
// Validate file size
if (file.size > AttachmentValidation.emojiMaxFileSize) {
toast.error(
t("File size too large. Maximum size is {{ size }}.", {
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
})
);
return;
}
setFile(file);
// Auto-populate name field if it's empty
setName((currentName) => {
if (!currentName.trim()) {
const generatedName = generateEmojiNameFromFilename(file.name);
return generatedName || currentName;
}
return currentName;
});
},
[t]
);
const onDrop = React.useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
handleFileSelection(acceptedFiles[0]);
}
},
[handleFileSelection]
);
// Handle paste events
React.useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const files = getDataTransferFiles(event);
if (files.length > 0) {
event.preventDefault();
handleFileSelection(files[0]);
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [handleFileSelection]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDropAccepted: onDrop,
accept: AttachmentValidation.emojiContentTypes,
maxSize: AttachmentValidation.emojiMaxFileSize,
maxFiles: 1,
});
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("Please enter a name for the emoji"));
return;
}
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
// Skip compression for GIFs to preserve animation
const fileToUpload =
file.type === "image/gif"
? file
: await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(fileToUpload, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
await emojis.create({
name: name.trim(),
attachmentId: attachment.id,
});
toast.success(t("Emoji created successfully"));
onSubmit();
} finally {
setIsUploading(false);
}
};
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"Square images with transparent backgrounds work best. If your image is too large, well try to resize it for you."
)}
</Text>
<LabelText as="label">{t("Upload an image")}</LabelText>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<VStack>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="medium">
{isDragActive
? t("Drop the image here")
: t("Click, drop, or paste an image here")}
</Text>
<Text size="medium" type="secondary">
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
size: bytesToHumanReadable(
AttachmentValidation.emojiMaxFileSize
),
})}
</Text>
</>
)}
</VStack>
</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>
</Text>
)}
</ConfirmationDialog>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("inputBorder")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
margin-bottom: 1em;
&:hover {
border-color: ${s("inputBorderFocused")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;
+161
View File
@@ -0,0 +1,161 @@
import * as React from "react";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { getDataTransferFiles } from "@shared/utils/files";
import { bytesToHumanReadable } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Text from "~/components/Text";
import { VStack } from "~/components/primitives/VStack";
interface UseEmojiFileUploadOptions {
/** Optional callback fired after a valid file is selected. */
onFileSelected?: (file: File) => void;
}
/**
* Hook that manages emoji image file selection with validation, drag-and-drop,
* and paste support.
*/
export function useEmojiFileUpload(options?: UseEmojiFileUploadOptions) {
const { t } = useTranslation();
const [file, setFile] = React.useState<File | null>(null);
const handleFileSelection = React.useCallback(
(selected: File) => {
const isValidType = AttachmentValidation.emojiContentTypes.includes(
selected.type
);
if (!isValidType) {
toast.error(
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
);
return;
}
if (selected.size > AttachmentValidation.emojiMaxFileSize) {
toast.error(
t("File size too large. Maximum size is {{ size }}.", {
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
})
);
return;
}
setFile(selected);
options?.onFileSelected?.(selected);
},
[t, options]
);
const handleDrop = React.useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
handleFileSelection(acceptedFiles[0]);
}
},
[handleFileSelection]
);
React.useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const files = getDataTransferFiles(event);
if (files.length > 0) {
event.preventDefault();
handleFileSelection(files[0]);
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [handleFileSelection]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDropAccepted: handleDrop,
accept: AttachmentValidation.emojiContentTypes,
maxSize: AttachmentValidation.emojiMaxFileSize,
maxFiles: 1,
});
return { file, getRootProps, getInputProps, isDragActive };
}
interface EmojiImageDropZoneProps {
/** The currently selected file, if any. */
file: File | null;
/** Dropzone root props. */
getRootProps: ReturnType<typeof useDropzone>["getRootProps"];
/** Dropzone input props. */
getInputProps: ReturnType<typeof useDropzone>["getInputProps"];
/** Whether a drag is currently active. */
isDragActive: boolean;
}
/**
* Shared drop zone component for emoji image upload, showing either a file
* preview or placeholder text.
*/
export function EmojiImageDropZone({
file,
getRootProps,
getInputProps,
isDragActive,
}: EmojiImageDropZoneProps) {
const { t } = useTranslation();
return (
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<VStack>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="small">
{isDragActive
? t("Drop the image here")
: t("Click, drop, or paste an image here")}
</Text>
<Text size="small" type="secondary">
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
size: bytesToHumanReadable(
AttachmentValidation.emojiMaxFileSize
),
})}
</Text>
</>
)}
</VStack>
</DropZone>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("inputBorder")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
margin-bottom: 1em;
&:hover {
border-color: ${s("inputBorderFocused")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;
@@ -0,0 +1,132 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AttachmentPreset } from "@shared/types";
import { EmojiValidation } from "@shared/validations";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input, { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { compressImage } from "~/utils/compressImage";
import { generateEmojiNameFromFilename } from "~/utils/emoji";
import { useEmojiFileUpload, EmojiImageDropZone } from "./Components";
interface Props {
/** Callback invoked after successful creation. */
onSubmit: () => void;
}
/**
* Dialog for creating a new custom emoji with image upload and name input.
*/
export function EmojiCreateDialog({ onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [name, setName] = React.useState("");
const [isUploading, setIsUploading] = React.useState(false);
const handleFileSelected = React.useCallback((selected: File) => {
setName((currentName) => {
if (!currentName.trim()) {
const generatedName = generateEmojiNameFromFilename(selected.name);
return generatedName || currentName;
}
return currentName;
});
}, []);
const { file, getRootProps, getInputProps, isDragActive } =
useEmojiFileUpload({ onFileSelected: handleFileSelected });
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("Please enter a name for the emoji"));
return;
}
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
const fileToUpload =
file.type === "image/gif"
? file
: await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(fileToUpload, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
await emojis.create({
name: name.trim(),
attachmentId: attachment.id,
});
toast.success(t("Emoji created successfully"));
onSubmit();
} finally {
setIsUploading(false);
}
};
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you."
)}
</Text>
<LabelText as="label">{t("Upload an image")}</LabelText>
<EmojiImageDropZone
file={file}
getRootProps={getRootProps}
getInputProps={getInputProps}
isDragActive={isDragActive}
/>
<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>
</Text>
)}
</ConfirmationDialog>
);
}
@@ -0,0 +1,86 @@
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AttachmentPreset } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import type Emoji from "~/models/Emoji";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { compressImage } from "~/utils/compressImage";
import { useEmojiFileUpload, EmojiImageDropZone } from "./Components";
interface Props {
/** The emoji whose image is being replaced. */
emoji: Emoji;
/** Callback invoked after a successful replacement. */
onSubmit: () => void;
}
/**
* Dialog for replacing the image of an existing custom emoji.
*/
export function EmojiReplaceDialog({ emoji, onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [isUploading, setIsUploading] = React.useState(false);
const { file, getRootProps, getInputProps, isDragActive } =
useEmojiFileUpload();
const handleSubmit = async () => {
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
const fileToUpload =
file.type === "image/gif"
? file
: await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(fileToUpload, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
await emojis.update({
id: emoji.id,
attachmentId: attachment.id,
});
toast.success(t("Emoji replaced"));
onSubmit();
} finally {
setIsUploading(false);
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!file || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Save")}
>
<Text as="p" type="secondary">
<Trans
defaults="Upload a new image to replace the current one for <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically."
values={{ emojiName: `:${emoji.name}:` }}
components={{ em: <code /> }}
/>
</Text>
<EmojiImageDropZone
file={file}
getRootProps={getRootProps}
getInputProps={getInputProps}
isDragActive={isDragActive}
/>
</ConfirmationDialog>
);
}
+16 -5
View File
@@ -1,8 +1,8 @@
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 type { TFunction } from "i18next";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -18,20 +18,26 @@ import Storage from "@shared/utils/Storage";
import { deleteAllDatabases } from "~/utils/developer";
import Flex from "./Flex";
type Props = WithTranslation & {
interface OwnProps {
/** Whether to reload the page if a chunk fails to load. */
reloadOnChunkMissing?: boolean;
/** Whether to show a title heading. */
showTitle?: boolean;
/** The wrapping component to use. */
component?: React.ComponentType | string;
/** Children rendered when no error is present. */
children?: React.ReactNode;
}
type Props = OwnProps & {
t: TFunction;
};
const ERROR_TRACKING_KEY = "error-boundary-tracking";
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
@observer
class ErrorBoundary extends React.Component<Props> {
class ErrorBoundaryClass extends React.Component<Props> {
@observable
error: Error | null | undefined;
@@ -223,4 +229,9 @@ const Pre = styled.pre`
white-space: pre-wrap;
`;
export default withTranslation()(ErrorBoundary);
function ErrorBoundary(props: OwnProps) {
const { t } = useTranslation();
return <ErrorBoundaryClass t={t} {...props} />;
}
export default ErrorBoundary;
+1 -1
View File
@@ -18,7 +18,7 @@ type Props = {
};
/**
* Wraps children in a <Fade> if loading is true on mount.
* Wraps children in a <Fade> if animate is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = useState(animate);
+1 -1
View File
@@ -1,4 +1,4 @@
import deburr from "lodash/deburr";
import { deburr } from "es-toolkit/compat";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
+9 -3
View File
@@ -1,4 +1,4 @@
import throttle from "lodash/throttle";
import { throttle } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { MenuIcon } from "outline-icons";
import { transparentize } from "polished";
@@ -116,17 +116,23 @@ function Header(
const Breadcrumbs = styled("div")`
flex-grow: 1;
flex-basis: 0;
min-width: 0;
align-items: center;
padding-right: 8px;
padding-inline: 0 8px;
display: flex;
${breakpoint("tablet")`
min-width: auto;
`};
`;
const Actions = styled(Flex)`
flex-grow: 1;
flex-basis: 0;
min-width: auto;
padding-left: 8px;
padding-inline: 8px 0;
gap: 12px;
margin-inline-start: 8px;
${breakpoint("tablet")`
position: unset;
+6 -1
View File
@@ -1,11 +1,16 @@
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
const Heading = styled.h1<{ as?: string; centered?: boolean }>`
display: flex;
align-items: center;
user-select: none;
${(props) => (props.as ? "" : "margin-top: 6vh; font-weight: 600;")}
${(props) => (props.as ? "" : "margin-top: 3vh; font-weight: 600;")}
${(props) => (props.centered ? "text-align: center;" : "")}
${breakpoint("tablet")`
${(props: { as?: string }) => (props.as ? "" : "margin-top: 6vh;")}
`};
`;
export default Heading;
+1 -1
View File
@@ -1,4 +1,4 @@
import escapeRegExp from "lodash/escapeRegExp";
import { escapeRegExp } from "es-toolkit/compat";
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
+1 -1
View File
@@ -88,6 +88,7 @@ export const CardContent = styled.div`
// &:after — gradient mask for overflow text
export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
${sharedVars}
backdrop-filter: blur(10px);
background: ${s("menuBackground")};
padding: 16px;
@@ -112,7 +113,6 @@ export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
${(props) =>
props.fadeOut !== false
? `&:after {
${sharedVars}
content: "";
display: block;
position: absolute;
@@ -26,7 +26,7 @@ const HoverPreviewDocument = React.forwardRef(function HoverPreviewDocument_(
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Flex column gap={2}>
<Title>{title}</Title>
<Info>{lastActivityByViewer}</Info>
{lastActivityByViewer && <Info>{lastActivityByViewer}</Info>}
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
@@ -1,4 +1,4 @@
import concat from "lodash/concat";
import { concat } from "es-toolkit/compat";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -6,7 +6,7 @@ import type { EmojiSkinTone } from "@shared/types";
import { EmojiCategory, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
import { DisplayCategory } from "../utils";
import type { DataNode, EmojiNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
@@ -1,20 +1,25 @@
import chunk from "lodash/chunk";
import compact from "lodash/compact";
import { chunk, compact } from "es-toolkit/compat";
import * as React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { Emoji } from "~/components/Emoji";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import { TRANSLATED_CATEGORIES } from "../utils";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
import { CustomEmoji } from "@shared/components/CustomEmoji";
/**
* icon/emoji size is 24px; and we add 4px padding on all sides,
* Desktop: 24px icon/emoji + 4px padding on all sides = 32px button.
* Mobile: 32px icon/emoji + 4px padding on all sides = 40px button, so
* roughly 8 emojis fit across a typical phone screen.
*/
const BUTTON_SIZE = 32;
const BUTTON_SIZE_DESKTOP = 32;
const BUTTON_SIZE_MOBILE = 40;
const ICON_SIZE_DESKTOP = 24;
const ICON_SIZE_MOBILE = 32;
type OutlineNode = {
type: IconType.SVG;
@@ -53,8 +58,11 @@ const GridTemplate = (
{ width, height, data, empty, onIconSelect }: Props,
ref: React.Ref<HTMLDivElement>
) => {
const isMobile = useMobile();
const buttonSize = isMobile ? BUTTON_SIZE_MOBILE : BUTTON_SIZE_DESKTOP;
const iconSize = isMobile ? ICON_SIZE_MOBILE : ICON_SIZE_DESKTOP;
// 24px padding for the Grid Container
const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE);
const itemsPerRow = Math.max(1, Math.floor((width - 24) / buttonSize));
const gridItems = compact(
data.flatMap((node) => {
@@ -84,7 +92,11 @@ const GridTemplate = (
onClick={() => onIconSelect({ id: item.name, value: item.name })}
style={{ "--delay": `${item.delay}ms` } as React.CSSProperties}
>
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
<Icon
as={IconLibrary.getComponent(item.name)}
color={item.color}
size={iconSize}
>
{item.initial}
</Icon>
</IconButton>
@@ -96,7 +108,11 @@ const GridTemplate = (
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji width={24} height={24}>
<Emoji
width={iconSize}
height={iconSize}
size={isMobile ? iconSize : undefined}
>
{item.type === IconType.Custom ? (
<CustomEmoji value={item.value} title={item.name} />
) : (
@@ -119,7 +135,7 @@ const GridTemplate = (
height={height}
data={gridItems}
columns={itemsPerRow}
itemWidth={BUTTON_SIZE}
itemWidth={buttonSize}
/>
);
};
@@ -1,5 +1,5 @@
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { breakpoints, s, hover } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
@@ -10,4 +10,9 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
&: ${hover} {
background: ${s("listItemHoverBackground")};
}
@media (max-width: ${breakpoints.tablet - 1}px) {
width: 40px;
height: 40px;
}
`;
+3 -1
View File
@@ -79,7 +79,9 @@ const IconPicker = ({
const [activeTab, setActiveTab] = React.useState<TabName>(defaultTab);
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
// The Drawer's inner content has 6px padding on each side; subtract it
// so the panel doesn't overflow horizontally and itemsPerRow is correct.
const popoverWidth = isMobile ? windowWidth - 12 : POPOVER_WIDTH;
const handleTabChange = React.useCallback((value: string) => {
setActiveTab(value as TabName);
+100 -5
View File
@@ -6,11 +6,15 @@ import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import Fade from "~/components/Fade";
import { undraggableOnDesktop } from "~/styles";
export const NativeTextarea = styled.textarea<{
hasIcon?: boolean;
hasPrefix?: boolean;
$autoSize?: boolean;
$minHeight?: string;
$maxHeight?: string;
}>`
border: 0;
flex: 1;
@@ -20,6 +24,10 @@ export const NativeTextarea = styled.textarea<{
background: none;
color: ${s("text")};
${(props) => props.$autoSize && `field-sizing: content;`}
${(props) => props.$minHeight && `min-height: ${props.$minHeight};`}
${(props) => props.$maxHeight && `max-height: ${props.$maxHeight};`}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
@@ -87,7 +95,7 @@ export const Wrapper = styled.div<{
const IconWrapper = styled.span`
position: relative;
left: 4px;
inset-inline-start: 4px;
width: 24px;
height: 24px;
`;
@@ -96,7 +104,9 @@ export const Outline = styled(Flex)<{
margin?: string | number;
hasError?: boolean;
$focused?: boolean;
$round?: boolean;
}>`
position: relative;
flex: 1;
margin: ${(props) =>
props.margin !== undefined ? props.margin : "0 0 16px"};
@@ -109,7 +119,7 @@ export const Outline = styled(Flex)<{
: props.$focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: 4px;
border-radius: ${(props) => (props.$round ? "16px" : "4px")};
font-weight: normal;
align-items: center;
overflow: hidden;
@@ -119,6 +129,24 @@ export const Outline = styled(Flex)<{
user-select: none;
`;
const CharacterCount = styled.span<{ $warning?: boolean }>`
position: absolute;
top: 0;
inset-inline-end: 0;
font-size: 11px;
line-height: 1;
padding: 2px 4px;
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-end-radius: 0;
border-end-start-radius: 2px;
background: ${(props) =>
props.$warning ? props.theme.warning : props.theme.inputBorder};
color: ${(props) =>
props.$warning ? props.theme.white : props.theme.textTertiary};
pointer-events: none;
`;
export const LabelText = styled.div`
font-weight: 500;
padding-bottom: 4px;
@@ -141,6 +169,18 @@ export interface Props extends Omit<
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
icon?: React.ReactNode;
/** Show a character count near the maxLength limit. Always shown for textareas, opt-in for other types. */
showCharacterCount?: boolean;
/** An optional soft limit below maxLength. When the value exceeds this, the character count is shown in a warning color. */
warningLimit?: number;
/** For textareas, grow the height to fit content. Use with `maxHeight` to cap the growth. */
autoSize?: boolean;
/** Minimum height of the textarea as a CSS length value (e.g. "3lh", "80px"). */
minHeight?: string;
/** Maximum height of the textarea as a CSS length value (e.g. "20lh", "400px"). */
maxHeight?: string;
/** Whether to use a round border-radius (16px) instead of the default (4px). */
round?: boolean;
/** Like autoFocus, but also select any text in the input */
autoSelect?: boolean;
/** Callback is triggered with the CMD+Enter keyboard combo */
@@ -157,6 +197,21 @@ function Input(
) {
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
const [focused, setFocused] = React.useState(false);
const [charCount, setCharCount] = React.useState(() => {
if (typeof props.value === "string") {
return props.value.length;
}
if (typeof props.defaultValue === "string") {
return props.defaultValue.length;
}
return 0;
});
React.useEffect(() => {
if (typeof props.value === "string") {
setCharCount(props.value.length);
}
}, [props.value]);
const handleBlur = (ev: React.SyntheticEvent) => {
setFocused(false);
@@ -174,6 +229,15 @@ function Input(
}
};
const handleChange = (
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setCharCount(ev.target.value.length);
if (props.onChange) {
props.onChange(ev);
}
};
const handleKeyDown = (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
@@ -205,14 +269,31 @@ function Input(
short,
flex,
prefix,
round,
labelHidden,
maxLength,
showCharacterCount,
warningLimit,
autoSize,
minHeight,
maxHeight,
onFocus,
onBlur,
onChange,
onRequestSubmit,
children,
...rest
} = props;
const showCharCount =
(type === "textarea" || showCharacterCount) &&
maxLength !== undefined &&
(charCount >= maxLength * 0.9 ||
(warningLimit !== undefined && charCount >= warningLimit));
const overWarningLimit =
warningLimit !== undefined && charCount > warningLimit;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
@@ -224,7 +305,7 @@ function Input(
) : (
wrappedLabel
))}
<Outline $focused={focused} margin={margin}>
<Outline $focused={focused} $round={round} margin={margin}>
{prefix}
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
@@ -237,9 +318,14 @@ function Input(
onFocus={handleFocus}
hasIcon={!!icon}
hasPrefix={!!prefix}
$autoSize={autoSize}
$minHeight={minHeight}
$maxHeight={maxHeight}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
// set it after "rest" to override props from spread.
maxLength={maxLength}
onKeyDown={handleKeyDown}
onChange={handleChange}
/>
) : (
<NativeInput
@@ -253,10 +339,19 @@ function Input(
hasPrefix={!!prefix}
type={type}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
// set it after "rest" to override "onKeyDown" and "onChange" from prop.
maxLength={maxLength}
onKeyDown={handleKeyDown}
onChange={handleChange}
/>
)}
{showCharCount && (
<Fade>
<CharacterCount $warning={overWarningLimit}>
{charCount}/{maxLength}
</CharacterCount>
</Fade>
)}
{children}
</Outline>
</label>
+1 -1
View File
@@ -36,7 +36,7 @@ const PositionedSwatchButton = styled(SwatchButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
position: absolute;
bottom: 21px;
right: 6px;
inset-inline-end: 6px;
`;
export default InputColor;
@@ -37,7 +37,7 @@ export default function InputMemberPermissionSelect(
value={value || EmptySelectValue}
onChange={onChange}
label={t("Permissions")}
hideLabel
labelHidden
nude
{...rest}
/>
+28 -8
View File
@@ -4,11 +4,17 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { isModKey } from "@shared/utils/keyboard";
import { s } from "@shared/styles";
import {
isModKey,
metaDisplay,
shortcutSeparator,
} from "@shared/utils/keyboard";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
import Input from "./Input";
type Props = {
/** A string representing where the search started, for tracking. */
@@ -42,6 +48,7 @@ function InputSearchPage({
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
const isMobile = useMobile();
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
useKeyDown("f", (ev: KeyboardEvent) => {
@@ -97,16 +104,29 @@ function InputSearchPage({
onBlur={setUnfocused}
margin={0}
labelHidden
/>
>
{!isMobile && (
<Shortcut $visible={!isFocused && !value && !collectionId}>
{metaDisplay}
{shortcutSeparator}K
</Shortcut>
)}
</InputMaxWidth>
);
}
const InputMaxWidth = styled(Input)`
max-width: 30vw;
const InputMaxWidth = styled(Input).attrs({ round: true })`
max-width: min(calc(30vw + 20px), 100%);
`;
${Outline} {
border-radius: 16px;
}
const Shortcut = styled.span<{ $visible: boolean }>`
flex-shrink: 0;
font-size: 13px;
color: ${s("textTertiary")};
padding-inline: 0 10px;
pointer-events: none;
opacity: ${(props) => (props.$visible ? 1 : 0)};
transition: opacity 100ms ease-in-out;
`;
export default observer(InputSearchPage);
+57 -9
View File
@@ -21,6 +21,7 @@ import {
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectHeading,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
@@ -35,6 +36,13 @@ type Separator = {
type: "separator";
};
type Heading = {
/* Denotes a non-selectable heading rendered above a group of options. */
type: "heading";
/* Text shown as the heading label. */
label: string;
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
@@ -48,7 +56,7 @@ export type Item = {
icon?: React.ReactElement;
};
export type Option = Item | Separator;
export type Option = Item | Separator | Heading;
type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
/* Options to display in the select menu. */
@@ -60,13 +68,15 @@ type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
labelHidden?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean;
/** Display a tooltip with the descriptive help text about the select menu. */
help?: string;
/** Render function to override the selected value shown in the trigger. Receives the currently selected option, or undefined when none is selected. */
displayValue?: (selectedOption: Item | undefined) => React.ReactNode;
} & TriggerButtonProps;
export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
@@ -76,9 +86,10 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
value,
onChange,
label,
hideLabel,
labelHidden,
short,
help,
displayValue,
...triggerProps
} = props;
@@ -95,12 +106,34 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
(opt) => opt.type === "item" && !!opt.icon
);
const selectedOption = React.useMemo(
() =>
localValue
? (options.find(
(opt) => opt.type === "item" && opt.value === localValue
) as Item | undefined)
: undefined,
[localValue, options]
);
const resolvedDisplayValue = displayValue
? displayValue(selectedOption)
: undefined;
const renderOption = React.useCallback(
(option: Option, idx: number) => {
if (option.type === "separator") {
return <InputSelectSeparator key={`separator-${idx}`} />;
}
if (option.type === "heading") {
return (
<InputSelectHeading key={`heading-${option.label}`}>
{option.label}
</InputSelectHeading>
);
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
@@ -143,13 +176,14 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
resolvedDisplayValue={resolvedDisplayValue}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} help={help} />
<Label text={label} hidden={labelHidden ?? false} help={help} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
@@ -159,6 +193,7 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
<InputSelectTrigger
ref={ref}
placeholder={placeholder}
displayValue={resolvedDisplayValue}
{...triggerProps}
/>
<InputSelectContent
@@ -179,6 +214,7 @@ InputSelect.displayName = "InputSelect";
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
resolvedDisplayValue?: React.ReactNode;
};
const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
@@ -188,11 +224,13 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
value,
onChange,
label,
hideLabel,
labelHidden,
disabled,
short,
placeholder,
optionsHaveIcon,
displayValue: _displayValue,
resolvedDisplayValue,
...triggerProps
} = props;
@@ -222,6 +260,14 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
return <InputSelectSeparator key={`separator-${idx}`} />;
}
if (option.type === "heading") {
return (
<InputSelectHeading key={`heading-${option.label}`}>
{option.label}
</InputSelectHeading>
);
}
const isSelected = option === selectedOption;
return (
@@ -252,7 +298,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Label text={label} hidden={labelHidden ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
@@ -262,7 +308,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
{resolvedDisplayValue !== undefined ? (
resolvedDisplayValue
) : selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
@@ -365,8 +413,8 @@ const IconWrapper = styled.span`
align-items: center;
width: 24px;
height: 24px;
margin-left: -4px;
margin-right: 4px;
margin-inline-start: -4px;
margin-inline-end: 4px;
overflow: hidden;
flex-shrink: 0;
`;
+1 -1
View File
@@ -11,7 +11,7 @@ type Props = {
shrink?: boolean;
} & Pick<
React.ComponentProps<typeof InputSelect>,
"value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
"value" | "onChange" | "disabled" | "labelHidden" | "nude" | "help"
>;
export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
+1 -1
View File
@@ -1,5 +1,5 @@
import { m } from "framer-motion";
import find from "lodash/find";
import { find } from "es-toolkit/compat";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { languages, languageOptions } from "@shared/i18n";
+5 -4
View File
@@ -48,6 +48,7 @@ const Layout = React.forwardRef(function Layout_(
<Content
auto
justify="center"
role="main"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
@@ -55,7 +56,7 @@ const Layout = React.forwardRef(function Layout_(
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
marginInlineStart: `${ui.sidebarWidth}px`,
}
}
>
@@ -85,21 +86,21 @@ type ContentProps = {
const Content = styled(Flex)<ContentProps>`
margin: 0;
transition: ${(props) =>
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
props.$isResizing ? "none" : `margin-inline-start 100ms ease-out`};
@media print {
margin: 0 !important;
}
${breakpoint("mobile", "tablet")`
margin-left: 0 !important;
margin-inline-start: 0 !important;
`}
${breakpoint("tablet")`
${(props: ContentProps) =>
props.$hasSidebar &&
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
`margin-inline-start: ${props.theme.sidebarCollapsedWidth}px;`}
`};
`;
+1
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
// oxlint-disable no-explicit-any -- ComponentType<any> is the standard React pattern for generic component constraints
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
+1 -1
View File
@@ -40,7 +40,7 @@ import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
import { toast } from "sonner";
import { findIndex } from "lodash";
import { findIndex } from "es-toolkit/compat";
import type { LightboxImage } from "@shared/editor/lib/Lightbox";
import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch";
import {
+1 -1
View File
@@ -203,7 +203,7 @@ const Wrapper = styled.a<{
`;
const Image = styled(Flex)`
padding: 0 8px 0 0;
padding-inline-end: 8px;
max-height: 32px;
align-items: center;
user-select: none;
+1 -1
View File
@@ -1,4 +1,4 @@
import times from "lodash/times";
import { times } from "es-toolkit/compat";
import styled from "styled-components";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
+1 -1
View File
@@ -47,7 +47,7 @@ export const ContextMenu = observer(
onClose?.();
}
},
[open, onOpen, onClose]
[onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
+13 -10
View File
@@ -42,13 +42,14 @@ export function toMenuItems(items: MenuItem[]) {
case "button":
return (
<MenuButton
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
tooltip={item.tooltip}
selected={item.selected}
dangerous={item.dangerous}
shortcut={item.shortcut}
onClick={item.onClick}
/>
);
@@ -56,10 +57,11 @@ export function toMenuItems(items: MenuItem[]) {
case "route":
return (
<MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
shortcut={item.shortcut}
to={item.to}
/>
);
@@ -67,10 +69,11 @@ export function toMenuItems(items: MenuItem[]) {
case "link":
return (
<MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
shortcut={item.shortcut}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
typeof item.href === "string" ? undefined : item.href.target
@@ -92,7 +95,7 @@ export function toMenuItems(items: MenuItem[]) {
};
return (
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenu key={`${item.type}-${index}`}>
<SubMenuTrigger
label={item.title as string}
icon={icon}
@@ -118,7 +121,7 @@ export function toMenuItems(items: MenuItem[]) {
return (
<MenuGroup
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
label={item.title as string}
items={groupItems}
/>
@@ -168,7 +171,7 @@ export function toMobileMenuItems(
case "button":
return (
<Components.MenuButton
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
disabled={item.disabled}
$dangerous={item.dangerous}
onClick={(e) => {
@@ -189,7 +192,7 @@ export function toMobileMenuItems(
case "route":
return (
<Components.MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
to={item.to}
disabled={item.disabled}
onClick={closeMenu}
@@ -202,7 +205,7 @@ export function toMobileMenuItems(
case "link":
return (
<Components.MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
typeof item.href === "string" ? undefined : item.href.target
@@ -228,7 +231,7 @@ export function toMobileMenuItems(
return (
<Components.MenuButton
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
disabled={item.disabled}
onClick={() => {
openSubmenu(item.title as string);
@@ -253,7 +256,7 @@ export function toMobileMenuItems(
}
return (
<div key={`${item.type}-${item.title}-${index}`}>
<div key={`${item.type}-${index}`}>
<Components.MenuHeader>{item.title}</Components.MenuHeader>
{groupItems}
</div>
+27 -19
View File
@@ -15,8 +15,8 @@ import usePrevious from "~/hooks/usePrevious";
import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import ErrorBoundary from "./ErrorBoundary";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import Tooltip from "./Tooltip";
import { useDialogContext } from "~/components/DialogContext";
type Props = {
children?: React.ReactNode;
@@ -31,7 +31,7 @@ type Props = {
const Modal: React.FC<Props> = ({
children,
isOpen,
title = "Untitled",
title,
style,
width,
height,
@@ -40,42 +40,43 @@ const Modal: React.FC<Props> = ({
const wasOpen = usePrevious(isOpen);
const isMobile = useMobile();
const { t } = useTranslation();
const resolvedTitle = title ?? t("Untitled");
const dialog = useDialogContext();
const onClose = React.useCallback(() => {
dialog.setAnimating(false); // Reset
onRequestClose();
}, [dialog, onRequestClose]);
if (!isOpen && !wasOpen) {
return null;
}
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => !open && onRequestClose()}
>
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<StyledOverlay />
<Dialog.Title asChild>
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
</Dialog.Title>
<StyledContent
onEscapeKeyDown={onRequestClose}
onPointerDownOutside={onRequestClose}
onEscapeKeyDown={onClose}
onPointerDownOutside={onClose}
aria-describedby={undefined}
>
{isMobile ? (
<Mobile>
<MobileContent>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && (
<Dialog.Title asChild>
<Text size="xlarge" weight="bold">
{title}
{resolvedTitle}
</Text>
)}
</Dialog.Title>
<ErrorBoundary>{children}</ErrorBoundary>
</Centered>
</MobileContent>
<Close onClick={onRequestClose}>
<Close onClick={onClose}>
<CloseIcon size={32} />
</Close>
<Back onClick={onRequestClose}>
<Back onClick={onClose}>
<BackIcon size={32} />
<Text>{t("Back")} </Text>
</Back>
@@ -89,13 +90,20 @@ const Modal: React.FC<Props> = ({
column
reverse
>
<DesktopContent style={style} topShadow>
<DesktopContent
style={style}
topShadow
overflow={dialog.animating ? "hidden" : undefined}
onAnimationEnd={() => dialog.setAnimating(false)}
>
<ErrorBoundary component="div">{children}</ErrorBoundary>
</DesktopContent>
<Header>
{title && <Text size="large">{title}</Text>}
<Dialog.Title asChild>
<Text size="large">{resolvedTitle}</Text>
</Dialog.Title>
<Tooltip content={t("Close")} shortcut="Esc">
<NudeButton onClick={onRequestClose}>
<NudeButton onClick={onClose}>
<CloseIcon />
</NudeButton>
</Tooltip>
@@ -117,8 +117,8 @@ const StyledAvatar = styled(Avatar).attrs({
const Container = styled(Flex)<{ $unread: boolean }>`
position: relative;
padding: 8px 12px;
padding-right: 40px;
padding-block: 8px;
padding-inline: 12px 40px;
border-radius: 4px;
${StyledLink}[data-state=open] &,
@@ -6,6 +6,7 @@ import styled from "styled-components";
import { s, hover } from "@shared/styles";
import Notification, { type NotificationFilter } from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import Empty from "../Empty";
@@ -83,6 +84,7 @@ function Notifications(
) {
const { notifications } = useStores();
const { t } = useTranslation();
const isMobile = useMobile();
const [filter, setFilter] = React.useState<NotificationFilter>("all");
const filterOptions = React.useMemo<Option[]>(
@@ -110,8 +112,9 @@ function Notifications(
<Flex
style={{
width: "100%",
height:
"min(300px, calc(var(--radix-popover-content-available-height) - 44px))",
minHeight: isMobile ? "75vh" : "300px",
maxHeight:
"min(75vh, calc(var(--radix-popover-content-available-height, 75vh) - 44px))",
}}
column
>
@@ -122,7 +125,7 @@ function Notifications(
<HStack>
<StyledInputSelect
label={t("Filter")}
hideLabel
labelHidden
options={filterOptions}
value={filter}
onChange={(value) => setFilter(value as NotificationFilter)}
@@ -1,15 +1,20 @@
import { observer } from "mobx-react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "~/components/primitives/Drawer";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Notifications = lazyWithRetry(() => import("./Notifications"));
import Notifications from "./Notifications";
type Props = {
children?: React.ReactNode;
@@ -19,7 +24,9 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const [open, setOpen] = useState(false);
const isMobile = useMobile();
const scrollableRef = useRef<HTMLDivElement>(null);
const drawerContentRef = useRef<React.ElementRef<typeof DrawerContent>>(null);
useEffect(() => {
void notifications.fetchPage({ archived: false });
@@ -40,6 +47,40 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
}
}, []);
const enablePointerEvents = useCallback(() => {
if (drawerContentRef.current) {
drawerContentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = useCallback(() => {
if (drawerContentRef.current) {
drawerContentRef.current.style.pointerEvents = "none";
}
}, []);
const notificationsList = (
<Notifications onRequestClose={handleRequestClose} ref={scrollableRef} />
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent
ref={drawerContentRef}
aria-label={t("Notifications")}
aria-describedby={undefined}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden>{t("Notifications")}</DrawerTitle>
{notificationsList}
</DrawerContent>
</Drawer>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>{children}</PopoverTrigger>
@@ -51,12 +92,7 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
scrollable={false}
shrink
>
<Suspense fallback={null}>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</Suspense>
{notificationsList}
</PopoverContent>
</Popover>
);
@@ -13,7 +13,7 @@ import Switch from "../Switch";
import EventBoundary from "@shared/components/EventBoundary";
import { InputClientType } from "./InputClientType";
export interface FormData {
export type FormData = {
name: string;
developerName: string;
developerUrl: string;
@@ -22,7 +22,7 @@ export interface FormData {
redirectUris: string[];
published: boolean;
clientType: "confidential" | "public";
}
};
export const OAuthClientForm = observer(function OAuthClientForm_({
handleSubmit,
+1 -2
View File
@@ -1,12 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet-async";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { useTeamContext } from "./TeamContext";
type Props = {
title: React.ReactNode;
title: string;
favicon?: string;
};
+3 -1
View File
@@ -7,7 +7,9 @@ import PaginatedList from "~/components/PaginatedList";
type Props = {
documents: Document[];
fetch: (options: any) => Promise<Document[] | undefined>;
// oxlint-disable-next-line no-explicit-any
fetch: (options: Record<string, any>) => Promise<Document[] | undefined>;
// oxlint-disable-next-line no-explicit-any
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: JSX.Element;
+4 -2
View File
@@ -1,4 +1,4 @@
import isEqual from "lodash/isEqual";
import { isEqual } from "es-toolkit/compat";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
@@ -35,10 +35,12 @@ interface Props<
* @param options Pagination and other query options
*/
fetch?: (
// oxlint-disable-next-line no-explicit-any
options: Record<string, any> | undefined
) => Promise<unknown[] | undefined> | undefined;
/** Additional options to pass to the fetch function */
// oxlint-disable-next-line no-explicit-any
options?: Record<string, any>;
/** Optional header content to display above the list */
@@ -78,7 +80,7 @@ interface Props<
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
renderHeading?: (name: React.ReactElement | string) => React.ReactNode;
/**
* Function to determine if an item is a duplicate of the previous item.
+4 -2
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import styled from "styled-components";
import Logger from "~/utils/Logger";
import { Hook, usePluginValue } from "~/utils/PluginManager";
import { Hook, PluginManager, usePluginValue } from "~/utils/PluginManager";
type Props = {
/** The ID of the plugin to render an Icon for. */
@@ -26,7 +26,9 @@ function PluginIcon({ id, color, size = 24 }: Props) {
);
}
Logger.warn("No Icon registered for plugin", { id });
if (PluginManager.isLoaded) {
Logger.warn("No Icon registered for plugin", { id });
}
return null;
}
+9 -7
View File
@@ -49,13 +49,15 @@ const useTooltipContent = ({
);
// If the emoji is a custom emoji ID, we need to get its short name for display
if (isUUID(emoji)) {
emojis.fetch(emoji).then((ce) => {
if (ce) {
setTransformedEmoji(ce.shortName);
}
});
}
React.useEffect(() => {
if (isUUID(emoji)) {
void emojis.fetch(emoji).then((ce) => {
if (ce) {
setTransformedEmoji(ce.shortName);
}
});
}
}, [emoji, emojis]);
if (!reactedUsers.length) {
return;
+1 -1
View File
@@ -1,4 +1,4 @@
import compact from "lodash/compact";
import { compact } from "es-toolkit/compat";
import { observer } from "mobx-react";
import * as React from "react";
import type Comment from "~/models/Comment";
@@ -1,4 +1,4 @@
import compact from "lodash/compact";
import { compact } from "es-toolkit/compat";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
+1 -1
View File
@@ -34,7 +34,7 @@ const Scene: React.FC<Props> = ({
wide,
}: Props) => (
<FillWidth>
<PageTitle title={textTitle || title} />
<PageTitle title={textTitle ?? (typeof title === "string" ? title : "")} />
<Header
hasSidebar
title={
+19 -24
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import styled, { css } from "styled-components";
import { hideScrollbars } from "@shared/styles";
import useWindowSize from "~/hooks/useWindowSize";
type Props = React.HTMLAttributes<HTMLDivElement> & {
/** Whether to show shadows at top and bottom when scrolled */
@@ -45,41 +44,37 @@ function Scrollable(
const fallbackRef = React.useRef<HTMLDivElement>();
const [topShadowVisible, setTopShadow] = React.useState(false);
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
const { height } = useWindowSize();
const updateShadows = React.useCallback(() => {
const c = (ref || fallbackRef).current;
if (!c) {
return;
}
const scrollTop = c.scrollTop;
const tsv = !!((shadow || topShadow || fadeTo) && scrollTop > 0);
if (tsv !== topShadowVisible) {
setTopShadow(tsv);
}
setTopShadow(!!((shadow || topShadow || fadeTo) && scrollTop > 0));
const wrapperHeight = c.scrollHeight - c.clientHeight;
const bsv = !!(
(shadow || bottomShadow || fadeTo) &&
wrapperHeight - scrollTop !== 0
setBottomShadow(
!!((shadow || bottomShadow || fadeTo) && wrapperHeight - scrollTop > 1)
);
if (bsv !== bottomShadowVisible) {
setBottomShadow(bsv);
}
}, [
shadow,
topShadow,
bottomShadow,
fadeTo,
ref,
topShadowVisible,
bottomShadowVisible,
]);
}, [shadow, topShadow, bottomShadow, fadeTo, ref]);
React.useEffect(() => {
const c = (ref || fallbackRef).current;
if (!c) {
return;
}
updateShadows();
}, [height, updateShadows]);
const observer = new ResizeObserver(updateShadows);
observer.observe(c);
for (const child of Array.from(c.children)) {
observer.observe(child);
}
return () => observer.disconnect();
}, [ref, updateShadows]);
return (
<Wrapper
@@ -167,7 +167,7 @@ export const AccessControlList = observer(
}
}}
label={t("Access")}
hideLabel
labelHidden
disabled={!can.update}
short
nude
@@ -192,7 +192,7 @@ export const AccessControlList = observer(
});
}}
label={t("Permission")}
hideLabel
labelHidden
disabled={!can.update}
nude
/>
@@ -1,7 +1,7 @@
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import copy from "copy-to-clipboard";
import { debounce, isEmpty } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons";
import { CopyIcon, GlobeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -20,7 +20,9 @@ import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import env from "~/env";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { ListItem } from "../components/ListItem";
import ShareSettingsPopover from "../components/ShareSettingsPopover";
import { DomainPrefix, ShareLinkInput, StyledInfoIcon } from "../components";
type Props = {
@@ -35,68 +37,46 @@ function InnerPublicAccess(
ref: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const { shares } = useStores();
const theme = useTheme();
const [validationError, setValidationError] = React.useState("");
const [urlId, setUrlId] = React.useState(share?.urlId);
const inputRef = React.useRef<HTMLInputElement>(null);
const can = usePolicy(share);
const collectionAbilities = usePolicy(collection);
const canPublish = can.update && collectionAbilities.share;
const canPublish = share ? can.update : collectionAbilities.share;
const [creating, setCreating] = React.useState(false);
React.useEffect(() => {
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
allowIndexing: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowLastModifiedChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showLastUpdated: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
published: checked,
});
if (checked && !share) {
setCreating(true);
const newShare = await shares.create({
type: "collection",
collectionId: collection.id,
published: true,
});
copy(newShare.url);
toast.success(t("Public link copied to clipboard"));
} else if (share) {
await share.save({ published: checked });
if (checked) {
copy(share.url);
toast.success(t("Public link copied to clipboard"));
}
}
} catch (err) {
toast.error(err.message);
} finally {
setCreating(false);
}
},
[share]
[t, share, shares, collection]
);
const handleUrlChange = React.useMemo(
@@ -159,7 +139,7 @@ function InnerPublicAccess(
aria-label={t("Publish to internet")}
checked={share?.published ?? false}
onChange={handlePublishedChange}
disabled={!canPublish}
disabled={!canPublish || creating}
width={26}
height={14}
/>
@@ -169,96 +149,24 @@ function InnerPublicAccess(
<ResizingHeightContainer>
{!!share?.published && (
<>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Search engine indexing")}&nbsp;
<Tooltip
content={t(
"Disable this setting to discourage search engines from indexing the page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show last modified")}&nbsp;
<Tooltip
content={t(
"Display the last modified timestamp on the shared page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show last modified")}
checked={share?.showLastUpdated ?? false}
onChange={handleShowLastModifiedChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlChange}
error={validationError}
defaultValue={urlId}
prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}>
{env.URL.replace(/https?:\/\//, "") + "/s/"}
</DomainPrefix>
}
>
{copyButton}
</ShareLinkInput>
<Flex align="center" gap={2}>
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlChange}
error={validationError}
defaultValue={urlId}
prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}>
{env.URL.replace(/https?:\/\//, "") + "/s/"}
</DomainPrefix>
}
>
{copyButton}
</ShareLinkInput>
<ShareSettingsPopover share={share} />
</Flex>
<Flex align="flex-start" gap={4}>
<StyledInfoIcon color={theme.textTertiary} />
<Text type="tertiary" size="xsmall">
@@ -66,6 +66,7 @@ function SharePopover({
const share = shares.getByCollectionId(collection.id);
const prevPendingIds = usePrevious(pendingIds);
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
@@ -89,6 +90,15 @@ function SharePopover({
}
);
// Move focus into the popover to account for lazy-loading
React.useLayoutEffect(() => {
if (!hasRendered) {
return;
}
(searchInputRef.current ?? wrapperRef.current)?.focus();
}, [hasRendered]);
// Hide the picker when the popover is closed
React.useEffect(() => {
if (visible) {
@@ -351,7 +361,7 @@ function SharePopover({
);
return (
<Wrapper>
<Wrapper ref={wrapperRef} tabIndex={-1}>
{can.update && (
<SearchInput
ref={searchInputRef}
@@ -154,7 +154,7 @@ export const AccessControlList = observer(
value={document.isPrivate ? "private" : "inherited"}
onChange={handleAccessChange}
label={t("Access")}
hideLabel
labelHidden
short
disabled={!can.manageUsers || parentIsPrivate}
nude
@@ -1,4 +1,4 @@
import orderBy from "lodash/orderBy";
import { orderBy } from "es-toolkit/compat";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
+46 -143
View File
@@ -1,7 +1,7 @@
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import copy from "copy-to-clipboard";
import { debounce, isEmpty } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons";
import { CopyIcon, GlobeIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -14,6 +14,7 @@ import type Share from "~/models/Share";
import Switch from "~/components/Switch";
import env from "~/env";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { AvatarSize } from "../../Avatar";
import CopyToClipboard from "../../CopyToClipboard";
import NudeButton from "../../NudeButton";
@@ -21,6 +22,7 @@ import { ResizingHeightContainer } from "../../ResizingHeightContainer";
import Text from "../../Text";
import Tooltip from "../../Tooltip";
import { ListItem } from "../components/ListItem";
import ShareSettingsPopover from "../components/ShareSettingsPopover";
import {
DomainPrefix,
ShareLinkInput,
@@ -45,68 +47,46 @@ function PublicAccess(
ref: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const { shares } = useStores();
const theme = useTheme();
const [validationError, setValidationError] = React.useState("");
const [urlId, setUrlId] = React.useState(share?.urlId);
const inputRef = React.useRef<HTMLInputElement>(null);
const can = usePolicy(share);
const documentAbilities = usePolicy(document);
const canPublish = can.update && documentAbilities.share;
const canPublish = share ? can.update : documentAbilities.share;
const [creating, setCreating] = React.useState(false);
React.useEffect(() => {
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
allowIndexing: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowLastModifiedChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showLastUpdated: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
published: checked,
});
if (checked && !share) {
setCreating(true);
const newShare = await shares.create({
type: "document",
documentId: document.id,
published: true,
});
copy(newShare.url);
toast.success(t("Public link copied to clipboard"));
} else if (share) {
await share.save({ published: checked });
if (checked) {
copy(share.url);
toast.success(t("Public link copied to clipboard"));
}
}
} catch (err) {
toast.error(err.message);
} finally {
setCreating(false);
}
},
[share]
[t, share, shares, document]
);
const handleUrlChange = React.useMemo(
@@ -202,7 +182,7 @@ function PublicAccess(
aria-label={t("Publish to internet")}
checked={share?.published ?? false}
onChange={handlePublishedChange}
disabled={!canPublish}
disabled={!canPublish || creating}
width={26}
height={14}
/>
@@ -211,106 +191,29 @@ function PublicAccess(
/>
<ResizingHeightContainer>
{share?.published && !sharedParent?.published && (
<>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Search engine indexing")}&nbsp;
<Tooltip
content={t(
"Disable this setting to discourage search engines from indexing the page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show last modified")}&nbsp;
<Tooltip
content={t(
"Display the last modified timestamp on the shared page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show last modified")}
checked={share?.showLastUpdated ?? false}
onChange={handleShowLastModifiedChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
</>
)}
{sharedParent?.published && !document.isDraft ? (
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
{copyButton}
</ShareLinkInput>
) : share?.published ? (
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlChange}
error={validationError}
defaultValue={urlId}
prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}>
{env.URL.replace(/https?:\/\//, "") + "/s/"}
</DomainPrefix>
}
>
{copyButton}
</ShareLinkInput>
<Flex align="center" gap={2}>
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlChange}
error={validationError}
defaultValue={urlId}
prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}>
{env.URL.replace(/https?:\/\//, "") + "/s/"}
</DomainPrefix>
}
>
{copyButton}
</ShareLinkInput>
<ShareSettingsPopover share={share} />
</Flex>
) : null}
{share?.published && !share.includeChildDocuments ? (
@@ -68,6 +68,7 @@ function SharePopover({
const prevPendingIds = usePrevious(pendingIds);
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
@@ -91,6 +92,15 @@ function SharePopover({
}
);
// Move focus into the popover to account for lazy-loading
React.useLayoutEffect(() => {
if (!hasRendered) {
return;
}
(searchInputRef.current ?? wrapperRef.current)?.focus();
}, [hasRendered]);
React.useEffect(() => {
if (visible) {
if (externalLoading === undefined) {
@@ -358,7 +368,7 @@ function SharePopover({
);
return (
<Wrapper>
<Wrapper ref={wrapperRef} tabIndex={-1}>
{can.manageUsers && (
<SearchInput
ref={searchInputRef}

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