Compare commits

..

3 Commits

Author SHA1 Message Date
Salihu 2c0f375901 requested changes 2026-03-21 16:07:08 +01:00
Salihu 4894e92d6b add gradient 2026-03-21 16:07:08 +01:00
Salihu 5e6eebd0ec collapsible code blocks 2026-03-21 16:07:03 +01:00
1115 changed files with 28838 additions and 66155 deletions
+1 -12
View File
@@ -24,18 +24,7 @@
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
}
],
[
"module-resolver",
{
"root": ["./"],
"alias": {
"@server": "./server",
"@shared": "./shared",
"~": "./app",
"plugins": "./plugins"
}
}
]
"tsconfig-paths-module-resolver"
],
"env": {
"production": {
-27
View File
@@ -1,21 +1,5 @@
NODE_ENV=production
# –––––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE-BASED SECRETS ––––––––
# –––––––––––––––––––––––––––––––––––––––––
#
# Any environment variable can be loaded from a file by appending _FILE to the
# variable name and setting the value to the path of the file. This is useful
# for Docker secrets and other file-based secret management systems.
#
# For example, instead of:
# SECRET_KEY=your_secret_key
# You can use:
# SECRET_KEY_FILE=/run/secrets/outline_secret_key
#
# The file contents will be trimmed of leading/trailing whitespace. If both the
# variable and the _FILE variant are set, the direct variable takes precedence.
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
@@ -140,11 +124,6 @@ FORCE_HTTPS=true
# and "X-Client-IP".
# PROXY_IP_HEADER=
# Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
# X-Forwarded-Proto) set by an upstream proxy. Set to false if not
# running behind a proxy in production.
# PROXY_HEADERS_TRUSTED=true
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
@@ -223,12 +202,6 @@ 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,8 +17,6 @@ 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
@@ -31,15 +29,6 @@ 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
@@ -1,16 +0,0 @@
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,12 +31,6 @@ 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,
@@ -43,7 +43,7 @@ jobs:
uses: actions/checkout@v5
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@3d5873ac3e7bf1a38b24d9778d8dc639d5706d8b # main
uses: calibreapp/image-actions@main
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
@@ -54,7 +54,7 @@ jobs:
if: |
github.event_name != 'pull_request' &&
steps.calibre.outputs.markdown != ''
uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 # v3
uses: peter-evans/create-pull-request@v3
with:
title: "chore: Auto Compress Images"
branch-suffix: timestamp
+98 -38
View File
@@ -18,23 +18,78 @@ 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@4512585405083f25c027a35db413c2b3b9006d50 # v2
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
config:
- '.github/**'
- 'vite.config.ts'
- 'vitest.config.ts'
server:
- 'server/**'
- 'shared/**'
@@ -45,36 +100,9 @@ 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: changes
needs: [setup, changes]
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -82,11 +110,21 @@ jobs:
test-group: [app, shared]
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- 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 test:${{ matrix.test-group }}
test-server:
needs: changes
needs: [setup, changes]
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
services:
@@ -110,23 +148,45 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- 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 sequelize db:migrate
- name: Run server tests
run: yarn test:server --maxWorkers=2 --shard=${{ matrix.shard }}/4
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | awk "NR % 4 == (${{ matrix.shard }} - 1)")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: changes
needs: [setup, types, 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
- uses: ./.github/actions/install
- 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') }}
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@38328454d6a23942175eba485fca4fbb807b1f03 # v2
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
+30 -24
View File
@@ -1,10 +1,9 @@
name: Publish build
name: Docker
on:
push:
tags:
- "v*"
workflow_dispatch:
env:
IMAGE_NAME: outlinewiki/outline
@@ -12,17 +11,17 @@ env:
jobs:
build-arm:
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -31,14 +30,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
@@ -46,11 +45,13 @@ jobs:
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -60,7 +61,7 @@ jobs:
- name: Build and push
id: build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -68,6 +69,8 @@ jobs:
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
@@ -87,17 +90,17 @@ jobs:
retention-days: 1
build-amd:
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -106,14 +109,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
@@ -121,11 +124,13 @@ jobs:
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -135,7 +140,7 @@ jobs:
- name: Build and push
id: build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -143,6 +148,8 @@ jobs:
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
@@ -162,11 +169,10 @@ jobs:
retention-days: 1
merge:
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
environment: dockerhub
steps:
- name: Download digests
uses: actions/download-artifact@v4
@@ -176,17 +182,17 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
-94
View File
@@ -1,94 +0,0 @@
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@22a9089034f40e5a961c8808d113e2c98fb63676 # 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
-1
View File
@@ -14,7 +14,6 @@ data/*
*.pem
*.key
*.cert
.history
# Yarn Berry
.yarn/*
+63
View File
@@ -0,0 +1,63 @@
{
"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 @@
24
22
+2 -26
View File
@@ -31,7 +31,7 @@
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-explicit-any": "error",
"no-explicit-any": "warn",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
@@ -73,30 +73,9 @@
"eqeqeq": "error",
"curly": "error",
"no-console": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "prosemirror-tables",
"importNames": [
"addRowBefore",
"addRowAfter",
"addColumnBefore",
"addColumnAfter"
],
"message": "Use the wrappers from shared/editor/commands/table instead, which respect the target index and place the cursor in the inserted cell."
}
]
}
],
"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",
{
@@ -108,9 +87,6 @@
"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",
"typescript/no-useless-default-assignment": "error",
"no-unused-vars": [
"error",
{
+1 -9
View File
@@ -1,14 +1,6 @@
nodeLinker: node-modules
enableScripts: false
npmMinimalAgeGate: 4320
npmMinimalAgeGate: 86400
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)
+2 -15
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, Vitest, Prettier, Oxlint configurations
- **Various config files** - TypeScript, Vite, Jest, Prettier, Oxlint configurations
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
@@ -46,18 +46,6 @@ 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.
@@ -152,7 +140,7 @@ yarn sequelize migration:create --name=add-field-to-table
## Testing
- Run tests with Vitest:
- Run tests with Jest:
```bash
# Run a specific test file (preferred)
@@ -200,7 +188,6 @@ 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:24.16.0-slim AS runner
FROM node:22.21.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:24.16.0 AS deps
FROM node:22.21.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.8.1
Licensed Work: Outline 1.6.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-06-06
Change Date: 2030-03-18
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).
# Contributing
# Development
> **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.
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.
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.
## Contributing
If youre looking for ways to get started, heres a list of ways to help us improve Outline:
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:
- [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, 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.
- Bugs and other issues listed on GitHub
## 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 [Vitest](https://vitest.dev/) and add a file with `.test.ts` extension next to the tested code.
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.
```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 vitest:
frontend and backend tests directly with jest:
```shell
# To run backend tests
-1
View File
@@ -1,6 +1,5 @@
{
"extends": ["../.oxlintrc.json"],
"ignorePatterns": ["**/*.d.ts"],
"plugins": ["oxc", "eslint", "typescript", "react"],
"overrides": [
{
+1 -20
View File
@@ -1,8 +1,5 @@
import copy from "copy-to-clipboard";
import { CopyIcon, PlusIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import { PlusIcon, TrashIcon } from "outline-icons";
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";
@@ -28,22 +25,6 @@ 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 }) =>
+26 -109
View File
@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import invariant from "invariant";
import { capitalize, uniqBy } from "es-toolkit/compat";
import uniqBy from "lodash/uniqBy";
import {
DownloadIcon,
DuplicateIcon,
@@ -39,13 +39,11 @@ 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";
@@ -81,7 +79,7 @@ import {
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type {
Action,
@@ -109,21 +107,19 @@ export const openDocument = createActionWithChildren({
shortcut: ["o", "d"],
keywords: "go to",
icon: <DocumentIcon />,
children: ({ stores, t }) => {
children: ({ stores }) => {
const nodes = stores.collections.navigationNodes.reduce(
(acc, node) => [...acc, ...node.children],
[] as NavigationNode[]
);
const documents = stores.documents.orderedData;
return uniqBy([...documents, ...nodes], "id").map((item) => {
const document = stores.documents.get(item.id);
return createInternalLinkAction({
return uniqBy([...documents, ...nodes], "id").map((item) =>
createInternalLinkAction({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
description: document ? documentBreadcrumbText(document, t) : undefined,
icon: item.icon ? (
<Icon
value={item.icon}
@@ -131,12 +127,12 @@ export const openDocument = createActionWithChildren({
color={item.color ?? undefined}
/>
) : (
<DocumentIcon outline={item.isDraft} />
<DocumentIcon />
),
section: DocumentSection,
to: item.url,
});
});
})
);
},
});
@@ -240,26 +236,6 @@ function findDocumentSiblingIndex(
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
}
/**
* Determines whether the user can create a sibling of the given document.
* A sibling shares the document's parent, so this mirrors the backend's
* create authorization: create permission on the parent document, or on the
* collection when the document is at the root.
*
* @param stores - the root stores.
* @param document - the document to create a sibling of.
* @returns true if the user can create a sibling.
*/
function canCreateSiblingDocument(
stores: ActionContext["stores"],
document: { collectionId?: string | null; parentDocumentId?: string }
): boolean {
return document.parentDocumentId
? stores.policies.abilities(document.parentDocumentId).createChildDocument
: !!document.collectionId &&
stores.policies.abilities(document.collectionId).createDocument;
}
export const createNestedDocument = createInternalLinkAction({
name: ({ t }) => t("Nested document"),
analyticsName: "New document",
@@ -292,14 +268,10 @@ const createDocumentBefore = createInternalLinkAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document?.collectionId) {
return false;
}
const collection = stores.collections.get(document.collectionId);
if (collection?.sort.field === "title") {
return false;
}
return canCreateSiblingDocument(stores, document);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
@@ -334,14 +306,10 @@ const createDocumentAfter = createInternalLinkAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document?.collectionId) {
return false;
}
const collection = stores.collections.get(document.collectionId);
if (collection?.sort.field === "title") {
return false;
}
return canCreateSiblingDocument(stores, document);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
@@ -366,18 +334,6 @@ const createDocumentAfter = createInternalLinkAction({
},
});
function isAlphabeticallySorted(
stores: ActionContext["stores"],
activeDocumentId: string
): boolean {
const document = stores.documents.get(activeDocumentId);
if (!document?.collectionId) {
return false;
}
const collection = stores.collections.get(document.collectionId);
return collection?.sort.field === "title";
}
export const createNewDocument = createActionWithChildren({
name: ({ t }) => t("New document"),
analyticsName: "New document",
@@ -385,48 +341,17 @@ export const createNewDocument = createActionWithChildren({
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId || !currentTeamId) {
if (!activeDocumentId) {
return false;
}
if (!stores.policies.abilities(currentTeamId).createDocument) {
return false;
}
return !isAlphabeticallySorted(stores, activeDocumentId);
return (
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
export const createNewDocumentInAlphabeticalCollection =
createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId || !currentTeamId) {
return false;
}
if (!stores.policies.abilities(currentTeamId).createDocument) {
return false;
}
if (!stores.policies.abilities(activeDocumentId).createChildDocument) {
return false;
}
return isAlphabeticallySorted(stores, activeDocumentId);
},
to: ({ activeDocumentId, sidebarContext }) => {
const [pathname, search] =
newNestedDocumentPath(activeDocumentId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -812,6 +737,8 @@ 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"));
}
@@ -1043,11 +970,7 @@ export const openDocumentInDesktop = createAction({
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted &&
(isMac || isWindows) &&
!!document &&
!document.isDeleted &&
!isMobile()
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
);
},
perform: ({ activeDocumentId, stores }) => {
@@ -1065,14 +988,9 @@ export const presentDocument = createAction({
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["Control+Alt+KeyP"],
visible: ({ activeDocumentId }) => !!activeDocumentId && !isMobile(),
shortcut: ["Meta+Alt+p"],
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ activeDocumentId, stores }) => {
if (stores.ui.presentationData) {
stores.ui.setPresentingDocument(null);
return;
}
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
@@ -1602,7 +1520,6 @@ export const rootDocumentActions = [
createDocument,
createDraftDocument,
createNewDocument,
createNewDocumentInAlphabeticalCollection,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
+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/EmojiDialog/EmojiCreateDialog";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
export const createEmoji = createAction({
name: ({ t }) => `${t("New emoji")}`,
+1 -4
View File
@@ -241,10 +241,7 @@ export const logout = createAction({
section: NavigationSection,
icon: <LogoutIcon />,
perform: async () => {
await stores.auth.logout({
userInitiated: true,
clearCache: true,
});
await stores.auth.logout({ userInitiated: true });
},
});
+2 -6
View File
@@ -3,7 +3,6 @@ import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import { ExportContentType } from "@shared/types";
import Revision from "~/models/Revision";
import stores from "~/stores";
import { createAction, createActionWithChildren } from "~/actions";
import { RevisionSection } from "~/actions/sections";
@@ -22,7 +21,7 @@ export const restoreRevision = createAction({
section: RevisionSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId, getActiveModel }) => {
perform: async ({ event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
@@ -31,10 +30,7 @@ export const restoreRevision = createAction({
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = getActiveModel(Revision)?.id ?? match?.params.revisionId;
if (!revisionId) {
return;
}
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
+2 -1
View File
@@ -25,7 +25,6 @@ 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";
@@ -201,6 +200,8 @@ 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"));
}
+2 -12
View File
@@ -17,12 +17,8 @@ import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
import type { Action as KbarAction } from "kbar";
export function resolve<T>(value: unknown, context: ActionContext): T {
return (
typeof value === "function"
? (value as (context: ActionContext) => T)(context)
: value
) as T;
export function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
}
export const ActionSeparator: TActionSeparator = {
@@ -136,7 +132,6 @@ 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),
};
@@ -148,7 +143,6 @@ export function actionToMenuItem(
icon,
visible,
disabled,
shortcut: action.shortcut,
to,
};
}
@@ -160,7 +154,6 @@ export function actionToMenuItem(
icon,
visible,
disabled,
shortcut: action.shortcut,
href: action.target
? { url: action.url, target: action.target }
: action.url,
@@ -217,7 +210,6 @@ export function actionToKBar(
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const subtitle = resolve<string>(action.description, context);
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
@@ -237,7 +229,6 @@ export function actionToKBar(
section,
keywords: action.keywords,
shortcut: action.shortcut,
subtitle,
icon,
priority,
perform: () => performAction(action, context),
@@ -263,7 +254,6 @@ export function actionToKBar(
keywords: action.keywords,
shortcut: action.shortcut,
icon,
subtitle,
priority,
},
...children.map((child) => ({
+1 -8
View File
@@ -13,15 +13,8 @@ ActiveCollectionSection.priority = 0.8;
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DateSection = ({ t }: ActionContext) => t("Date");
DateSection.priority = 1;
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SearchResultsSection = ({ t }: ActionContext) =>
t("Search results");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
@@ -65,7 +58,7 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recently viewed");
t("Recent searches");
RecentSearchesSection.priority = -0.1;
+1 -2
View File
@@ -1,5 +1,4 @@
/* 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";
@@ -86,4 +85,4 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
}
);
export default observer(ActionButton);
export default ActionButton;
+1 -1
View File
@@ -1,6 +1,6 @@
/* oxlint-disable prefer-rest-params */
/* global ga */
import { escape } from "es-toolkit/compat";
import escape from "lodash/escape";
import * as React from "react";
import type { PublicEnv } from "@shared/types";
import { IntegrationService } from "@shared/types";
+14
View File
@@ -0,0 +1,14 @@
export default function Arrow() {
return (
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
</svg>
);
}
+8 -25
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -16,7 +16,6 @@ const Authenticated = ({ children }: Props) => {
const { i18n } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const language = user?.language;
const hasLoggedOut = useRef(false);
// Watching for language changes here as this is the earliest point we might have the user
// available and means we can start loading translations faster
@@ -24,36 +23,20 @@ const Authenticated = ({ children }: Props) => {
void changeLanguage(language, i18n);
}, [i18n, language]);
const shouldLogout = !auth.authenticated && !auth.isFetching;
// Passive logout when we land here without an authenticated session note we
// intentionally do not revoke the server-side token, as that would clobber
// the session in any other tab that may have already re-authenticated.
useEffect(() => {
if (shouldLogout && !hasLoggedOut.current) {
hasLoggedOut.current = true;
void auth.logout({
savePath: true,
clearCache: false,
revokeToken: false,
});
}
}, [shouldLogout, auth]);
useEffect(() => {
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
}
}, [auth.logoutRedirectUri]);
if (auth.authenticated) {
return children;
}
if (auth.isFetching || auth.logoutRedirectUri) {
if (auth.isFetching) {
return <LoadingIndicator />;
}
void auth.logout({ savePath: true });
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
return null;
}
return <Redirect to="/" />;
};
+14 -41
View File
@@ -1,27 +1,21 @@
import { observer } from "mobx-react";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { useLocation } from "react-router-dom";
import { EditorAwareHTML5Backend } from "~/components/EditorAwareHTML5Backend";
import { Switch, Route } 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";
@@ -39,18 +33,11 @@ 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();
@@ -73,15 +60,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (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());
}
history.replace(postLoginPath);
}
}, [spendPostLoginPath]);
@@ -89,16 +68,12 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const isSettings = location.pathname.startsWith(settingsPath());
const sidebar = (
<Fade>
<React.Suspense fallback={null}>
{isSettings && <SettingsSidebar />}
</React.Suspense>
<div style={isSettings ? { display: "none" } : undefined}>
<Sidebar />
</div>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
);
@@ -106,16 +81,14 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<DocumentContextProvider>
<RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<DndProvider backend={EditorAwareHTML5Backend}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</DndProvider>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</PortalContext.Provider>
</RightSidebarProvider>
</DocumentContextProvider>
+7 -14
View File
@@ -55,15 +55,6 @@ function Breadcrumb(
});
}
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.currentTarget.querySelector('[data-state="open"]')) {
event.preventDefault();
}
},
[]
);
const toBreadcrumb = React.useCallback(
(action: TopLevelAction, index: number) => {
if (action.type === "menu") {
@@ -77,7 +68,6 @@ function Breadcrumb(
{item.icon}
<Item
to={item.to}
onClick={handleClick}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
@@ -86,7 +76,7 @@ function Breadcrumb(
</>
);
},
[actionContext, handleClick, highlightFirstItem]
[actionContext, highlightFirstItem]
);
return (
@@ -113,16 +103,19 @@ 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-inline-start: ${(props) => (props.$withIcon ? "4px" : "0")};
max-width: 460px;
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
svg {
flex-shrink: 0;
}
&: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-inline-start: 4px;"};
${(props) => props.hasIcon && "padding-left: 4px;"};
`;
export const Inner = styled.span<{
@@ -135,13 +135,13 @@ export const Inner = styled.span<{
}>`
display: flex;
padding: 0 8px;
padding-inline-end: ${(props) => (props.disclosure ? 2 : 8)}px;
padding-right: ${(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-inline-start: 4px;"};
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
+3 -1
View File
@@ -22,7 +22,9 @@ 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 = ((100 - percentage) * circumference) / 100;
strokePercentage = percentage
? ((100 - percentage) * circumference) / 100
: 0;
}
return (
+7 -11
View File
@@ -1,4 +1,7 @@
import { filter, isEqual, orderBy, uniq } from "es-toolkit/compat";
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -79,10 +82,10 @@ function Collaborators(props: Props) {
// Memoize ids to avoid unnecessary effect executions
const missingUserIds = useMemo(
() =>
uniq([...collaboratorIdsSet, ...presentIds])
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
.filter((userId) => !users.get(userId))
.sort(),
[collaboratorIdsSet, presentIds, users]
[document.collaboratorIds, presentIds, users]
);
useEffect(() => {
@@ -143,14 +146,7 @@ function Collaborators(props: Props) {
/>
);
},
[
presentIds,
editingIds,
observingUserId,
currentUserId,
handleAvatarClick,
t,
]
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
);
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: 14px;
font-size: 14pxte
&:hover {
color: ${s("textSecondary")};
+4 -11
View File
@@ -1,4 +1,4 @@
import { uniq } from "es-toolkit/compat";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useMemo, useEffect, useCallback, Suspense } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -25,11 +25,10 @@ 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 type FormData = {
export interface FormData {
name: string;
icon: string;
color: string | null;
@@ -37,7 +36,7 @@ export type FormData = {
permission: CollectionPermission | undefined;
commenting?: boolean | null;
templateManagement: CollectionPermission;
};
}
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
@@ -68,7 +67,6 @@ export const CollectionForm = observer(function CollectionForm_({
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const dialog = useDialogContext();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
@@ -280,12 +278,7 @@ export const CollectionForm = observer(function CollectionForm_({
{collection ? (
options
) : (
<Collapsible
label={t("Advanced options")}
onOpenChange={() => dialog.setAnimating(true)}
>
{options}
</Collapsible>
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
)}
<HStack justify="flex-end">
+6 -28
View File
@@ -3,8 +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, shortcutSeparator } from "@shared/utils/keyboard";
import Highlight from "~/components/Highlight";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -16,22 +15,14 @@ type Props = {
currentRootActionId: string | null | undefined;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function CommandBarItem(
{ action, active, currentRootActionId }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const theme = useTheme();
const ancestors = React.useMemo(() => {
if (!currentRootActionId || !action.ancestors) {
return action.ancestors ?? [];
if (!currentRootActionId) {
return action.ancestors;
}
const index = action.ancestors.findIndex(
(ancestor) => ancestor.id === currentRootActionId
@@ -65,16 +56,6 @@ function CommandBarItem(
))}
{action.name}
{action.children?.length ? "…" : ""}
{action.subtitle && (
<Text type="secondary" ellipsis>
&nbsp;&nbsp;
<Highlight
text={action.subtitle}
highlight={SEARCH_RESULT_REGEX}
processResult={replaceResultMarks}
/>
</Text>
)}
</Content>
{action.shortcut?.length ? (
<Shortcut>
@@ -90,12 +71,9 @@ function CommandBarItem(
) : (
""
)}
{sc.split("+").flatMap((key, i, arr) => {
const el = <Key key={key}>{normalizeKeyDisplay(key)}</Key>;
return i < arr.length - 1 && shortcutSeparator
? [el, shortcutSeparator]
: [el];
})}
{sc.split("+").map((key) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
))}
</React.Fragment>
))}
</Shortcut>
@@ -43,8 +43,7 @@ const Container = styled.div`
const Header = styled(Text).attrs({ as: "h3" })`
letter-spacing: 0.03em;
margin: 0;
padding-block: 16px 4px;
padding-inline: 20px 0;
padding: 16px 0 4px 20px;
height: 36px;
cursor: default;
`;
@@ -1,94 +0,0 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import CommandBarResults from "./CommandBarResults";
import SharedSearchActions from "./SharedSearchActions";
/**
* A simplified command bar for public shares that only provides search.
*/
function SharedCommandBar() {
const { t } = useTranslation();
return (
<>
<SharedSearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchInput defaultPlaceholder={`${t("Search")}`} />
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
</>
);
}
type Props = {
children?: React.ReactNode;
};
const KBarPortal: React.FC = ({ children }: Props) => {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
};
const Positioner = styled(KBarPositioner)`
z-index: ${depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
position: relative;
padding: 16px 12px;
margin: 0 8px;
width: calc(100% - 16px);
outline: none;
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
opacity: 1;
}
`;
const Animator = styled(KBarAnimator)`
max-width: 600px;
max-height: 75vh;
width: 90vw;
background: ${s("menuBackground")};
color: ${s("text")};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
transition: max-width 0.2s ease-in-out;
${breakpoint("desktopLarge")`
max-width: 740px;
`};
@media print {
display: none;
}
`;
export default observer(SharedCommandBar);
@@ -1,187 +0,0 @@
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";
import Icon from "@shared/components/Icon";
import useShare from "@shared/hooks/useShare";
import { Minute } from "@shared/utils/time";
import { createAction } from "~/actions";
import {
RecentSearchesSection,
SearchResultsSection,
} from "~/actions/sections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
import type Document from "~/models/Document";
import history from "~/utils/history";
import { sharedModelPath } from "~/utils/routeHelpers";
import type { SearchResult } from "~/types";
interface CacheEntry {
timestamp: number;
results: SearchResult[];
}
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.
*/
function SharedSearchActions() {
const { documents } = useStores();
const { shareId } = useShare();
const searchCache = React.useRef<Map<string, CacheEntry>>(new Map());
const [results, setResults] = React.useState<SearchResult[]>([]);
const recentDocsRef = React.useRef<Document[]>([]);
const [recentDocs, setRecentDocs] = React.useState<Document[]>([]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
const searchQueryRef = React.useRef(searchQuery);
searchQueryRef.current = searchQuery;
React.useEffect(() => {
if (!searchQuery || !shareId) {
setResults([]);
return;
}
const now = Date.now();
const cachedEntry = searchCache.current.get(searchQuery);
const isExpired = cachedEntry
? now - cachedEntry.timestamp > cacheTTL
: true;
if (cachedEntry && !isExpired) {
setResults(cachedEntry.results);
return;
}
const currentQuery = searchQuery;
void documents.search({ query: searchQuery, shareId }).then((res) => {
searchCache.current.set(currentQuery, { timestamp: now, results: res });
if (searchQueryRef.current === currentQuery) {
setResults(res);
}
});
}, [documents, searchQuery, shareId]);
const addRecentDoc = React.useCallback((doc: Document) => {
const prev = recentDocsRef.current;
const filtered = prev.filter((d) => d.id !== doc.id);
const next = [doc, ...filtered].slice(0, maxRecentDocs);
recentDocsRef.current = next;
setRecentDocs(next);
}, []);
const documentIcon = React.useCallback(
(doc: Document) =>
doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
[]
);
const actions = React.useMemo(
() =>
results.map((result) =>
createAction({
id: `shared-search-${result.document.id}`,
name: result.document.titleWithDefault,
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: currentQuery
? `?q=${encodeURIComponent(currentQuery)}`
: undefined,
});
}
},
})
),
[results, shareId, searchQuery, addRecentDoc, documentIcon]
);
const recentDocActions = React.useMemo(
() =>
recentDocs.map((doc) =>
createAction({
id: `shared-recent-doc-${doc.id}`,
name: doc.titleWithDefault,
analyticsName: "Open recent shared document",
section: RecentSearchesSection,
icon: documentIcon(doc),
perform: () => {
if (shareId) {
history.push(sharedModelPath(shareId, doc.url));
}
},
})
),
[recentDocs, shareId, documentIcon]
);
useCommandBarActions(searchQuery ? actions : recentDocActions, [
searchQuery
? actions.map((a) => a.id).join("")
: recentDocActions.map((a) => a.id).join(""),
searchQuery,
]);
return null;
}
export default observer(SharedSearchActions);
@@ -1,16 +1,13 @@
import { DocumentIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { createInternalLinkAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
const { documents, ui } = useStores();
const { t } = useTranslation();
return useMemo(
() =>
@@ -22,7 +19,6 @@ const useRecentDocumentActions = (count = 6) => {
name: item.titleWithDefault,
analyticsName: "Recently viewed document",
section: RecentSection,
description: documentBreadcrumbText(item, t),
icon: item.icon ? (
<Icon
value={item.icon}
@@ -30,12 +26,12 @@ const useRecentDocumentActions = (count = 6) => {
color={item.color ?? undefined}
/>
) : (
<DocumentIcon outline={item.isDraft} />
<DocumentIcon />
),
to: documentPath(item),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed, t]
[count, ui.activeDocumentId, documents.recentlyViewed]
);
};
+9 -10
View File
@@ -87,23 +87,22 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
}));
const wrappedEvent =
<E extends React.SyntheticEvent<HTMLSpanElement>>(
callback: ((event: E) => void) | undefined
(
callback:
| React.FocusEventHandler<HTMLSpanElement>
| React.FormEventHandler<HTMLSpanElement>
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) =>
(event: E) => {
(event: any) => {
if (readOnly) {
return;
}
const text = event.currentTarget.textContent || "";
if (
maxLength &&
event.nativeEvent instanceof KeyboardEvent &&
isPrintableKeyEvent(event.nativeEvent) &&
text.length >= maxLength
) {
event.preventDefault();
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event?.preventDefault();
return;
}
@@ -120,7 +120,7 @@ const DefaultCollectionInputSelect = observer(
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
labelHidden
hideLabel
short
/>
);
+1 -1
View File
@@ -15,7 +15,7 @@ export default function DesktopEventHandler() {
const hasDisabledUpdateMessage = useRef(false);
useEffect(() => {
Desktop.bridge?.redirect((path: string, replace: boolean) => {
Desktop.bridge?.redirect((path: string, replace = false) => {
if (replace) {
history.replace(path);
} else {
-32
View File
@@ -1,32 +0,0 @@
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);
+27 -30
View File
@@ -2,7 +2,6 @@ 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"));
@@ -13,35 +12,33 @@ function Dialogs() {
const modals = [...modalStack];
return (
<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>
<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>
);
}
+11
View File
@@ -0,0 +1,11 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Divider = styled.hr`
border: 0;
border-bottom: 1px solid ${s("divider")};
margin: 0;
padding: 0;
`;
export default Divider;
+26 -136
View File
@@ -1,18 +1,13 @@
import type { TFunction } from "i18next";
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type Collection from "~/models/Collection";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -20,56 +15,6 @@ import { archivePath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
/**
* Returns the breadcrumb parts leading up to a document, separating the
* (possibly deleted) collection label from ancestor document titles. The
* document itself is not included.
*
* @param document - the document to compute the breadcrumb for.
* @param t - translation function for fallback titles.
* @returns the collection label and ancestor titles.
*/
export function documentBreadcrumbParts(
document: Document,
t: TFunction
): { collection: string | undefined; ancestors: string[] } {
let collectionLabel: string | undefined;
if (document.isCollectionDeleted) {
collectionLabel = t("Deleted Collection");
} else if (document.collection?.name) {
collectionLabel = document.collection.name;
}
return {
collection: collectionLabel,
ancestors: document.pathTo
.slice(0, -1)
.map((node) => node.title || t("Untitled")),
};
}
/**
* Returns the breadcrumb path leading up to a document as a plain text
* string. Includes the collection name (or "Deleted Collection" fallback)
* and any ancestor document titles, slash-separated.
*
* @param document - the document to compute the breadcrumb for.
* @param t - translation function for fallback titles.
* @returns the breadcrumb as a slash-separated string, or undefined if the
* document has no resolvable parent context.
*/
export function documentBreadcrumbText(
document: Document,
t: TFunction
): string | undefined {
const parts = documentBreadcrumbParts(document, t);
const segments = [
...(parts.collection ? [parts.collection] : []),
...parts.ancestors,
];
return segments.length ? segments.join(" / ") : undefined;
}
type Props = {
children?: React.ReactNode;
document: Document;
@@ -123,9 +68,7 @@ function DocumentBreadcrumb(
to: archivePath(),
}),
createInternalLinkAction({
name: collection ? (
<CollectionName collection={collection} />
) : undefined,
name: collection?.name,
section: ActiveDocumentSection,
icon: collection ? (
<CollectionIcon collection={collection} expanded />
@@ -147,20 +90,18 @@ function DocumentBreadcrumb(
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkAction({
name: (
<DocumentName
documentId={node.id}
collection={collection}
title={title}
/>
name: node.icon ? (
<>
<StyledIcon
value={node.icon}
color={node.color}
initial={node.title.charAt(0).toUpperCase()}
/>{" "}
{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,25 +138,22 @@ function DocumentBreadcrumb(
return <></>;
}
const { collection: collectionLabel, ancestors: ancestorLabels } =
documentBreadcrumbParts(document, t);
const slicedAncestors = reverse
? ancestorLabels.slice(depth && -depth)
: ancestorLabels.slice(0, depth);
const slicedPath = reverse
? path.slice(depth && -depth)
: path.slice(0, depth);
const showCollection =
!!collectionLabel &&
(!reverse || depth === undefined || slicedAncestors.length < depth);
collection &&
(!reverse || depth === undefined || slicedPath.length < depth);
return (
<>
{showCollection && collectionLabel}
{slicedAncestors.map((label, index) => (
<React.Fragment key={index}>
{showCollection && collection.name}
{slicedPath.map((node: NavigationNode, index: number) => (
<React.Fragment key={node.id}>
{showCollection && <SmallSlash />}
{label}
{!showCollection && index !== slicedAncestors.length - 1 && (
{node.title || t("Untitled")}
{!showCollection && index !== slicedPath.length - 1 && (
<SmallSlash />
)}
</React.Fragment>
@@ -231,57 +169,9 @@ function DocumentBreadcrumb(
);
}
/** Renders a collection name wrapped in a context menu. */
const CollectionName = observer(function CollectionName_({
collection,
}: {
collection: Collection;
}) {
const { t } = useTranslation();
const menuAction = useCollectionMenuAction({
collectionId: collection.id,
});
return (
<ActionContextProvider value={{ activeModels: [collection] }}>
<ContextMenu action={menuAction} ariaLabel={t("Collection options")}>
<span>{collection.name}</span>
</ContextMenu>
</ActionContextProvider>
);
});
/** Renders a document name wrapped in a context menu. */
const DocumentName = observer(function DocumentName_({
documentId,
collection,
title,
}: {
documentId: string;
collection: Collection | undefined;
title: string;
}) {
const { t } = useTranslation();
const { documents } = useStores();
const doc = documents.get(documentId);
const menuAction = useDocumentMenuAction({ documentId });
if (!doc) {
return <>{title}</>;
}
return (
<ActionContextProvider
value={{
activeModels: [doc, ...(collection ? [collection] : [])],
}}
>
<ContextMenu action={menuAction} ariaLabel={t("Document options")}>
<span>{title}</span>
</ContextMenu>
</ActionContextProvider>
);
});
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
+1 -1
View File
@@ -110,7 +110,7 @@ function DocumentCard(props: Props) {
dir={document.dir}
$isDragging={isDragging}
to={{
pathname: document.path,
pathname: document.url,
state: {
title: document.titleWithDefault,
},
@@ -40,8 +40,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
return nodes;
}, [policies, collectionTrees]);
const copy = async (path = selectedPath) => {
if (!path) {
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
return;
}
@@ -52,8 +52,10 @@ function DocumentCopy({ document, onSubmit }: Props) {
publish,
recursive,
title: document.title,
collectionId: path.collectionId,
...(path.type === "document" ? { parentDocumentId: path.id } : {}),
collectionId: selectedPath.collectionId,
...(selectedPath.type === "document"
? { parentDocumentId: selectedPath.id }
: {}),
});
toast.success(t("Document copied"));
@@ -109,7 +111,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
t("Select a location to copy")
)}
</Text>
<Button disabled={!selectedPath || copying} onClick={() => copy()}>
<Button disabled={!selectedPath || copying} onClick={copy}>
{copying ? `${t("Copying")}` : t("Copy")}
</Button>
</Footer>
@@ -1,13 +1,11 @@
import FuzzySearch from "fuzzy-search";
import {
concat,
difference,
fill,
filter,
flatten,
includes,
map,
} from "es-toolkit/compat";
import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import flatten from "lodash/flatten";
import includes from "lodash/includes";
import map from "lodash/map";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
@@ -24,6 +22,7 @@ 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";
@@ -31,7 +30,7 @@ import useStores from "~/hooks/useStores";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
onSubmit: (item: NavigationNode | null) => void;
onSubmit: () => void;
/** A side-effect of item selection */
onSelect: (item: NavigationNode | null) => void;
/** Items to be shown in explorer */
@@ -255,13 +254,6 @@ function DocumentExplorer({
}
};
const submitNode = (node: number) => {
const selectedNode = nodes[node];
selectNode(selectedNode);
onSubmit(selectedNode);
};
const ListItem = observer(
({
index,
@@ -318,8 +310,7 @@ function DocumentExplorer({
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => selectNode(nodes[index])}
onDoubleClick={() => submitNode(index)}
onClick={() => toggleSelect(index)}
icon={renderedIcon}
title={title}
path={path}
@@ -333,8 +324,7 @@ function DocumentExplorer({
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => selectNode(nodes[index])}
onDoubleClick={() => submitNode(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
@@ -396,7 +386,7 @@ function DocumentExplorer({
}
case "Enter": {
if (isModKey(ev)) {
onSubmit(selectedNode);
onSubmit();
} else {
toggleSelect(activeNode);
}
@@ -457,7 +447,10 @@ const FlexContainer = styled(Flex)`
justify-content: center;
`;
const ListSearch = styled(InputSearch).attrs({ round: true })`
const ListSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
margin-bottom: 4px;
padding-left: 24px;
padding-right: 24px;
@@ -9,30 +9,18 @@ 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 select it. */
onClick: (ev: React.MouseEvent) => void;
/** Fired when the node is double-clicked to submit the current selection. */
onDoubleClick: (ev: React.MouseEvent) => void;
};
function DocumentExplorerNode(
@@ -48,13 +36,12 @@ function DocumentExplorerNode(
onDisclosureClick,
onPointerMove,
onClick,
onDoubleClick,
}: Props,
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const DISCLOSURE = 24;
const width = (depth + 2) * DISCLOSURE;
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
return (
<Node
@@ -62,7 +49,6 @@ function DocumentExplorerNode(
selected={selected}
active={active}
onClick={onClick}
onDoubleClick={onDoubleClick}
style={style}
onPointerMove={onPointerMove}
role="option"
@@ -93,10 +79,6 @@ const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin: 2px 0;
&&[aria-expanded="true"]:not(:hover) {
background: none;
}
`;
const Spacer = styled(Flex)<{ width: number }>`
@@ -17,7 +17,6 @@ type Props = {
onPointerMove: (ev: React.MouseEvent) => void;
onClick: (ev: React.MouseEvent) => void;
onDoubleClick: (ev: React.MouseEvent) => void;
};
function DocumentExplorerSearchResult({
@@ -29,7 +28,6 @@ function DocumentExplorerSearchResult({
path,
onPointerMove,
onClick,
onDoubleClick,
}: Props) {
const { t } = useTranslation();
@@ -38,7 +36,6 @@ function DocumentExplorerSearchResult({
selected={selected}
active={active}
onClick={onClick}
onDoubleClick={onDoubleClick}
style={style}
onPointerMove={onPointerMove}
role="option"
@@ -3,7 +3,6 @@ import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import { descendants, flattenTree } from "@shared/utils/tree";
import type Document from "~/models/Document";
import Button from "~/components/Button";
import Text from "~/components/Text";
@@ -24,23 +23,13 @@ function DocumentMove({ document }: Props) {
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(() => {
// Collect the IDs of the document itself and all of its descendants so they
// can be excluded from the move targets (moving to self or a descendant
// would create a cycle; moving to the exact same location is a no-op).
const allNodes = collectionTrees.flatMap(flattenTree);
const sourceNode = allNodes.find((node) => node.id === document.id);
const excludedIds = new Set<string>([document.id]);
if (sourceNode) {
descendants(sourceNode).forEach((n) => excludedIds.add(n.id));
}
// Recursively filter out the document itself and its descendants.
// The document's current parent is intentionally kept so that siblings
// remain visible as valid move targets.
// Recursively filter out the document itself and its existing parent doc, if any.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter((c) => !excludedIds.has(c.id))
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
.map(filterSourceDocument),
});
@@ -54,19 +43,19 @@ function DocumentMove({ document }: Props) {
);
return nodes;
}, [policies, collectionTrees, document.id]);
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
const move = async (path = selectedPath) => {
if (!path) {
const move = async () => {
if (!selectedPath) {
toast.message(t("Select a location to move"));
return;
}
try {
setMoving(true);
const { type, id: parentDocumentId } = path;
const { type, id: parentDocumentId } = selectedPath;
const collectionId = path.collectionId as string;
const collectionId = selectedPath.collectionId as string;
if (type === "document") {
await document.move({ collectionId, parentDocumentId });
@@ -103,7 +92,7 @@ function DocumentMove({ document }: Props) {
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath || moving} onClick={() => move()}>
<Button disabled={!selectedPath || moving} onClick={move}>
{moving ? `${t("Moving")}` : t("Move")}
</Button>
</Footer>
@@ -33,14 +33,15 @@ function TemplateMove({ template }: Props) {
[policies, collectionTrees]
);
const move = async (path = selectedPath) => {
if (!path) {
const move = async () => {
if (!selectedPath) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = (path.collectionId ?? path.id) as string;
const collectionId = (selectedPath.collectionId ??
selectedPath.id) as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
@@ -75,7 +76,7 @@ function TemplateMove({ template }: Props) {
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={() => move()}>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
</Footer>
+1 -24
View File
@@ -5,7 +5,6 @@ import {
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { Link } from "react-router-dom";
import { DocumentIcon } from "outline-icons";
import styled, { css, useTheme } from "styled-components";
@@ -28,7 +27,6 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import { useDragDocument } from "./Sidebar/hooks/useDragAndDrop";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
@@ -100,23 +98,6 @@ function DocumentListItem(
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
const [{ isDragging }, draggableRef] = useDragDocument(
document.asNavigationNode,
0,
document,
false,
false
);
const mergedRef = React.useMemo(
() =>
mergeRefs<HTMLAnchorElement>([
itemRef,
draggableRef,
] as React.Ref<HTMLAnchorElement>[]),
[itemRef, draggableRef]
);
return (
<ActionContextProvider
value={{
@@ -133,10 +114,9 @@ function DocumentListItem(
onClose={handleMenuClose}
>
<DocumentLink
ref={mergedRef}
ref={itemRef}
dir={document.dir}
$isStarred={document.isStarred}
$isDragging={isDragging}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
@@ -247,7 +227,6 @@ const Actions = styled(EventBoundary)`
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$isDragging?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
@@ -258,8 +237,6 @@ const DocumentLink = styled(Link)<{
max-height: 50vh;
width: calc(100vw - 8px);
cursor: var(--pointer);
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
&:focus-visible {
outline: none;
+2 -1
View File
@@ -1,4 +1,5 @@
import { compact, sortBy } from "es-toolkit/compat";
import compact from "lodash/compact";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
+17 -10
View File
@@ -1,9 +1,8 @@
import { difference } from "es-toolkit/compat";
import difference from "lodash/difference";
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";
@@ -17,6 +16,7 @@ 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,7 +28,12 @@ const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
export type Props = Optional<
EditorProps,
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "extensions"
| "placeholder"
| "defaultValue"
| "onClickLink"
| "embeds"
| "dictionary"
| "extensions"
> & {
embedsDisabled?: boolean;
onSynced?: () => Promise<void>;
@@ -47,7 +52,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
} = props;
const { comments } = useStores();
const { shareId } = useShare();
const { t } = useTranslation();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
@@ -90,11 +95,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const handleFileUploadStart = React.useCallback(() => {
uploadState.current.timeoutId = setTimeout(() => {
uploadState.current.toastId = toast.loading(
t("Uploading… {{ progress }}%", { progress: 0 })
dictionary.uploadingWithProgress(0)
);
}, 2000);
onFileUploadStart?.();
}, [onFileUploadStart, t]);
}, [onFileUploadStart, dictionary.uploadingWithProgress]);
const handleFileUploadProgress = React.useCallback(
(fileId: string, fractionComplete: number) => {
@@ -108,12 +113,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
// Update toast if visible
if (uploadState.current.toastId) {
toast.loading(t("Uploading… {{ progress }}%", { progress: percent }), {
toast.loading(dictionary.uploadingWithProgress(percent), {
id: uploadState.current.toastId,
});
}
},
[t]
[dictionary.uploadingWithProgress]
);
const handleFileUploadStop = React.useCallback(() => {
@@ -178,6 +183,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
onFileUploadStart: handleFileUploadStart,
onFileUploadStop: handleFileUploadStop,
onFileUploadProgress: handleFileUploadProgress,
dictionary,
isAttachment,
});
},
@@ -186,6 +192,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
handleFileUploadStart,
handleFileUploadStop,
handleFileUploadProgress,
dictionary,
handleUploadFile,
]
);
@@ -204,7 +211,6 @@ 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 ?? [],
@@ -214,7 +220,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, { focus });
onCreateCommentMark(mark.id, mark.userId);
}
});
@@ -282,6 +288,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
-63
View File
@@ -1,63 +0,0 @@
import type { BackendFactory } from "dnd-core";
import { HTML5Backend } from "react-dnd-html5-backend";
/**
* react-dnd's HTML5 backend installs global capture-phase listeners on `window`
* that call `preventDefault()` on drops whose dataTransfer resembles a native
* item including a dragged `<img>`, which is how ProseMirror serializes an
* image drag.
*
* These handlers run before ProseMirror's, and they live on `window`, so a
* propagation-based guard can't stop react-dnd without also starving the editor
* of the event. Instead we wrap the backend and make its top-level capture
* handlers no-op for events that occur within the editor surface.
*/
const captureHandlerNames = [
"handleTopDragStartCapture",
"handleTopDragEnterCapture",
"handleTopDragOverCapture",
"handleTopDragLeaveCapture",
"handleTopDropCapture",
"handleTopDragEndCapture",
] as const;
const isWithinEditor = (target: EventTarget | null): boolean =>
target instanceof Element && Boolean(target.closest(".ProseMirror"));
/**
* An HTML5 drag-and-drop backend that ignores drag events originating within the
* rich text editor so that ProseMirror can handle them itself.
*
* @param manager The drag-and-drop manager.
* @param context The global context.
* @param options Backend options.
* @returns The wrapped HTML5 backend instance.
*/
export const EditorAwareHTML5Backend: BackendFactory = (
manager,
context,
options
) => {
const backend = HTML5Backend(manager, context, options);
// The capture handlers are private instance fields on the backend, so reach
// for them through an index signature view of the instance.
const handlers = backend as unknown as Record<
string,
(event: DragEvent) => void
>;
for (const name of captureHandlerNames) {
const original = handlers[name];
if (typeof original === "function") {
handlers[name] = (event: DragEvent) => {
if (isWithinEditor(event.target)) {
return;
}
original.call(backend, event);
};
}
}
return backend;
};
+233
View File
@@ -0,0 +1,233 @@
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
@@ -1,161 +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 { 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;
`;
@@ -1,132 +0,0 @@
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>
);
}
@@ -1,86 +0,0 @@
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>
);
}
+5 -16
View File
@@ -1,8 +1,8 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import type { TFunction } from "i18next";
import { Trans, useTranslation } from "react-i18next";
import type { WithTranslation } from "react-i18next";
import { withTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -18,26 +18,20 @@ import Storage from "@shared/utils/Storage";
import { deleteAllDatabases } from "~/utils/developer";
import Flex from "./Flex";
interface OwnProps {
type Props = WithTranslation & {
/** 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 ErrorBoundaryClass extends React.Component<Props> {
class ErrorBoundary extends React.Component<Props> {
@observable
error: Error | null | undefined;
@@ -229,9 +223,4 @@ const Pre = styled.pre`
white-space: pre-wrap;
`;
function ErrorBoundary(props: OwnProps) {
const { t } = useTranslation();
return <ErrorBoundaryClass t={t} {...props} />;
}
export default ErrorBoundary;
export default withTranslation()(ErrorBoundary);
@@ -12,11 +12,11 @@ import {
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Text from "@shared/components/Text";
import type Document from "~/models/Document";
import type Event from "~/models/Event";
import Time from "~/components/Time";
import Logger from "~/utils/Logger";
import Text from "./Text";
type Props = {
document: Document;
+1 -1
View File
@@ -18,7 +18,7 @@ type Props = {
};
/**
* Wraps children in a <Fade> if animate is true on mount.
* Wraps children in a <Fade> if loading is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = useState(animate);
+41 -124
View File
@@ -1,26 +1,16 @@
import { deburr } from "es-toolkit/compat";
import { CheckmarkIcon } from "outline-icons";
import deburr from "lodash/deburr";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Input, { NativeInput, Outline } from "./Input";
import type { PaginatedItem } from "./PaginatedList";
import PaginatedList from "./PaginatedList";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
import * as MenuComponents from "./primitives/components/Menu";
import { MenuIconWrapper } from "./primitives/components/Menu";
interface TFilterOption extends PaginatedItem {
@@ -44,7 +34,7 @@ type Props = {
const FilterOptions = ({
options,
selectedKeys,
selectedKeys = [],
className,
onSelect,
showFilter,
@@ -55,7 +45,6 @@ const FilterOptions = ({
...rest
}: Props) => {
const { t } = useTranslation();
const isMobile = useMobile();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const [open, setOpen] = React.useState(false);
@@ -69,45 +58,23 @@ const FilterOptions = ({
: "";
const renderItem = React.useCallback(
(option) => {
const handleClick = () => {
onSelect(option.key);
setOpen(false);
};
const icon =
option.icon && showIcons ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined;
// On mobile the options render inside a Drawer (bottom sheet) rather than
// a Radix dropdown menu, so use the raw menu components directly instead
// of the dropdown-bound MenuButton which expects a menu root context.
if (isMobile) {
return (
<MenuComponents.MenuButton key={option.key} onClick={handleClick}>
{icon}
<MenuComponents.MenuLabel>{option.label}</MenuComponents.MenuLabel>
<MenuComponents.SelectedIconWrapper aria-hidden>
{selectedKeys.includes(option.key) ? (
<CheckmarkIcon size={18} />
) : null}
</MenuComponents.SelectedIconWrapper>
</MenuComponents.MenuButton>
);
}
return (
<MenuButton
key={option.key}
icon={icon}
label={option.label}
onClick={handleClick}
selected={selectedKeys.includes(option.key)}
/>
);
},
[onSelect, showIcons, selectedKeys, isMobile]
(option) => (
<MenuButton
key={option.key}
icon={
option.icon && showIcons ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined
}
label={option.label}
onClick={() => {
onSelect(option.key);
setOpen(false);
}}
selected={selectedKeys.includes(option.key)}
/>
),
[onSelect, showIcons, selectedKeys]
);
const handleFilter = React.useCallback(
@@ -202,73 +169,39 @@ const FilterOptions = ({
React.useEffect(() => {
if (open) {
// Avoid auto-focusing on mobile as it immediately pops the on-screen
// keyboard over the drawer.
if (!isMobile) {
searchInputRef.current?.focus();
}
searchInputRef.current?.focus();
} else {
setQuery("");
}
}, [open, isMobile]);
}, [open]);
const showFilterInput = showFilter || options.length > 10;
const defaultLabel = rest.defaultLabel || t("Filter options");
const trigger = (
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
);
const list = (
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput && !isMobile ? <Spacer /> : undefined}
empty={<Empty />}
/>
);
// On mobile render the options inside a Drawer (bottom sheet) to match the
// popover style used by context menus across the app.
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent aria-label={defaultLabel} aria-describedby={undefined}>
<DrawerTitle>{defaultLabel}</DrawerTitle>
{showFilterInput && (
<MobileSearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
margin={0}
/>
)}
<StyledScrollable hiddenScrollbars>{list}</StyledScrollable>
</DrawerContent>
</Drawer>
);
}
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger>{trigger}</MenuTrigger>
<MenuTrigger>
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
</MenuTrigger>
<MenuContent aria-label={defaultLabel} align="start">
{list}
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
@@ -327,22 +260,6 @@ const SearchInput = styled(Input)`
}
`;
const MobileSearchInput = styled(Input)`
/* "none" keeps an auto basis so the input retains its natural height; a
flexible/0% basis would collapse it and overlap the list below. */
flex: none;
margin: 0 6px 6px;
${NativeInput} {
/* 16px avoids iOS zooming the viewport when the input is focused. */
font-size: 16px;
}
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
+3 -9
View File
@@ -1,4 +1,4 @@
import { throttle } from "es-toolkit/compat";
import throttle from "lodash/throttle";
import { observer } from "mobx-react";
import { MenuIcon } from "outline-icons";
import { transparentize } from "polished";
@@ -116,23 +116,17 @@ function Header(
const Breadcrumbs = styled("div")`
flex-grow: 1;
flex-basis: 0;
min-width: 0;
align-items: center;
padding-inline: 0 8px;
padding-right: 8px;
display: flex;
${breakpoint("tablet")`
min-width: auto;
`};
`;
const Actions = styled(Flex)`
flex-grow: 1;
flex-basis: 0;
min-width: auto;
padding-inline: 8px 0;
padding-left: 8px;
gap: 12px;
margin-inline-start: 8px;
${breakpoint("tablet")`
position: unset;
+1 -6
View File
@@ -1,16 +1,11 @@
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: 3vh; font-weight: 600;")}
${(props) => (props.as ? "" : "margin-top: 6vh; 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 "es-toolkit/compat";
import escapeRegExp from "lodash/escapeRegExp";
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
+1 -1
View File
@@ -88,7 +88,6 @@ 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;
@@ -113,6 +112,7 @@ 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>
{lastActivityByViewer && <Info>{lastActivityByViewer}</Info>}
<Info>{lastActivityByViewer}</Info>
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
@@ -1,4 +1,4 @@
import { concat } from "es-toolkit/compat";
import concat from "lodash/concat";
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/EmojiDialog/EmojiCreateDialog";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
import { DisplayCategory } from "../utils";
import type { DataNode, EmojiNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
@@ -1,25 +1,20 @@
import { chunk, compact } from "es-toolkit/compat";
import chunk from "lodash/chunk";
import compact from "lodash/compact";
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";
/**
* 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.
* icon/emoji size is 24px; and we add 4px padding on all sides,
*/
const BUTTON_SIZE_DESKTOP = 32;
const BUTTON_SIZE_MOBILE = 40;
const ICON_SIZE_DESKTOP = 24;
const ICON_SIZE_MOBILE = 32;
const BUTTON_SIZE = 32;
type OutlineNode = {
type: IconType.SVG;
@@ -58,11 +53,8 @@ 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.max(1, Math.floor((width - 24) / buttonSize));
const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE);
const gridItems = compact(
data.flatMap((node) => {
@@ -92,11 +84,7 @@ 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}
size={iconSize}
>
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
{item.initial}
</Icon>
</IconButton>
@@ -108,11 +96,7 @@ const GridTemplate = (
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji
width={iconSize}
height={iconSize}
size={isMobile ? iconSize : undefined}
>
<Emoji width={24} height={24}>
{item.type === IconType.Custom ? (
<CustomEmoji value={item.value} title={item.name} />
) : (
@@ -135,7 +119,7 @@ const GridTemplate = (
height={height}
data={gridItems}
columns={itemsPerRow}
itemWidth={buttonSize}
itemWidth={BUTTON_SIZE}
/>
);
};
@@ -1,5 +1,5 @@
import styled from "styled-components";
import { breakpoints, s, hover } from "@shared/styles";
import { s, hover } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
@@ -10,9 +10,4 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
&: ${hover} {
background: ${s("listItemHoverBackground")};
}
@media (max-width: ${breakpoints.tablet - 1}px) {
width: 40px;
height: 40px;
}
`;
+2 -3
View File
@@ -79,9 +79,7 @@ const IconPicker = ({
const [activeTab, setActiveTab] = React.useState<TabName>(defaultTab);
// 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 popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
const handleTabChange = React.useCallback((value: string) => {
setActiveTab(value as TabName);
@@ -107,6 +105,7 @@ const IconPicker = ({
const handleIconChange = React.useCallback(
(ic: string) => {
setOpen(false);
const icType = determineIconType(ic);
const finalColor = icType === IconType.SVG ? chosenColor : null;
onChange(ic, finalColor);
+5 -100
View File
@@ -6,15 +6,11 @@ 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;
@@ -24,10 +20,6 @@ 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")};
@@ -95,7 +87,7 @@ export const Wrapper = styled.div<{
const IconWrapper = styled.span`
position: relative;
inset-inline-start: 4px;
left: 4px;
width: 24px;
height: 24px;
`;
@@ -104,9 +96,7 @@ 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"};
@@ -119,7 +109,7 @@ export const Outline = styled(Flex)<{
: props.$focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: ${(props) => (props.$round ? "16px" : "4px")};
border-radius: 4px;
font-weight: normal;
align-items: center;
overflow: hidden;
@@ -129,24 +119,6 @@ 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;
@@ -169,18 +141,6 @@ 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 */
@@ -197,21 +157,6 @@ 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);
@@ -229,15 +174,6 @@ 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>
) => {
@@ -269,31 +205,14 @@ 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 (
@@ -305,7 +224,7 @@ function Input(
) : (
wrappedLabel
))}
<Outline $focused={focused} $round={round} margin={margin}>
<Outline $focused={focused} margin={margin}>
{prefix}
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
@@ -318,14 +237,9 @@ function Input(
onFocus={handleFocus}
hasIcon={!!icon}
hasPrefix={!!prefix}
$autoSize={autoSize}
$minHeight={minHeight}
$maxHeight={maxHeight}
{...rest}
// set it after "rest" to override props from spread.
maxLength={maxLength}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
onChange={handleChange}
/>
) : (
<NativeInput
@@ -339,19 +253,10 @@ function Input(
hasPrefix={!!prefix}
type={type}
{...rest}
// set it after "rest" to override "onKeyDown" and "onChange" from prop.
maxLength={maxLength}
// set it after "rest" to override "onKeyDown" from prop.
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;
inset-inline-end: 6px;
right: 6px;
`;
export default InputColor;
@@ -39,7 +39,7 @@ export default function InputMemberPermissionSelect(
value={value || EmptySelectValue}
onChange={onChange}
label={t("Permissions")}
labelHidden
hideLabel
nude
{...rest}
/>
+8 -35
View File
@@ -4,18 +4,11 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import {
isModKey,
metaDisplay,
shortcutSeparator,
} from "@shared/utils/keyboard";
import { isModKey } 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 from "./Input";
import Input, { Outline } from "./Input";
type Props = {
/** A string representing where the search started, for tracking. */
@@ -49,7 +42,6 @@ function InputSearchPage({
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
const isMobile = useMobile();
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
useKeyDown("f", (ev: KeyboardEvent) => {
@@ -105,35 +97,16 @@ function InputSearchPage({
onBlur={setUnfocused}
margin={0}
labelHidden
>
{!isMobile && (
<Shortcut $visible={!isFocused && !value && !collectionId}>
{metaDisplay}
{shortcutSeparator}K
</Shortcut>
)}
</InputMaxWidth>
/>
);
}
const InputMaxWidth = styled(Input).attrs({ round: true })`
max-width: min(calc(30vw + 20px), 100%);
const InputMaxWidth = styled(Input)`
max-width: 30vw;
/* On mobile the input grows to fill the header, so add a gap before the
* adjacent action button (e.g. "New doc"). */
${breakpoint("mobile", "tablet")`
margin-inline-end: 8px;
`}
`;
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;
${Outline} {
border-radius: 16px;
}
`;
export default observer(InputSearchPage);
+9 -57
View File
@@ -21,7 +21,6 @@ import {
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectHeading,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
@@ -36,13 +35,6 @@ 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";
@@ -56,7 +48,7 @@ export type Item = {
icon?: React.ReactElement;
};
export type Option = Item | Separator | Heading;
export type Option = Item | Separator;
type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
/* Options to display in the select menu. */
@@ -68,15 +60,13 @@ type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
labelHidden?: boolean;
hideLabel?: 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>(
@@ -86,10 +76,9 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
value,
onChange,
label,
labelHidden,
hideLabel,
short,
help,
displayValue,
...triggerProps
} = props;
@@ -106,34 +95,12 @@ 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} />
@@ -176,14 +143,13 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
resolvedDisplayValue={resolvedDisplayValue}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={labelHidden ?? false} help={help} />
<Label text={label} hidden={hideLabel ?? false} help={help} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
@@ -193,7 +159,6 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
<InputSelectTrigger
ref={ref}
placeholder={placeholder}
displayValue={resolvedDisplayValue}
{...triggerProps}
/>
<InputSelectContent
@@ -214,7 +179,6 @@ InputSelect.displayName = "InputSelect";
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
resolvedDisplayValue?: React.ReactNode;
};
const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
@@ -224,13 +188,11 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
value,
onChange,
label,
labelHidden,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
displayValue: _displayValue,
resolvedDisplayValue,
...triggerProps
} = props;
@@ -260,14 +222,6 @@ 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 (
@@ -298,7 +252,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
return (
<Wrapper>
<Label text={label} hidden={labelHidden ?? false} />
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
@@ -308,9 +262,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
disclosure
data-placeholder={selectedOption ? false : ""}
>
{resolvedDisplayValue !== undefined ? (
resolvedDisplayValue
) : selectedOption ? (
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
@@ -413,8 +365,8 @@ const IconWrapper = styled.span`
align-items: center;
width: 24px;
height: 24px;
margin-inline-start: -4px;
margin-inline-end: 4px;
margin-left: -4px;
margin-right: 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" | "labelHidden" | "nude" | "help"
"value" | "onChange" | "disabled" | "hideLabel" | "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 "es-toolkit/compat";
import find from "lodash/find";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { languages, languageOptions } from "@shared/i18n";
+4 -5
View File
@@ -48,7 +48,6 @@ const Layout = React.forwardRef(function Layout_(
<Content
auto
justify="center"
role="main"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
@@ -56,7 +55,7 @@ const Layout = React.forwardRef(function Layout_(
sidebarCollapsed
? undefined
: {
marginInlineStart: `${ui.sidebarWidth}px`,
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
@@ -86,21 +85,21 @@ type ContentProps = {
const Content = styled(Flex)<ContentProps>`
margin: 0;
transition: ${(props) =>
props.$isResizing ? "none" : `margin-inline-start 100ms ease-out`};
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
@media print {
margin: 0 !important;
}
${breakpoint("mobile", "tablet")`
margin-inline-start: 0 !important;
margin-left: 0 !important;
`}
${breakpoint("tablet")`
${(props: ContentProps) =>
props.$hasSidebar &&
props.$sidebarCollapsed &&
`margin-inline-start: ${props.theme.sidebarCollapsedWidth}px;`}
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
`};
`;
-1
View File
@@ -1,7 +1,6 @@
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 }>;
+12 -174
View File
@@ -2,12 +2,7 @@ import { observer } from "mobx-react";
import * as Dialog from "@radix-ui/react-dialog";
import type { Keyframes } from "styled-components";
import styled, { css, keyframes } from "styled-components";
import type {
ComponentProps,
HTMLAttributes,
ReactNode,
SyntheticEvent,
} from "react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import {
createContext,
forwardRef,
@@ -23,7 +18,6 @@ import { Error as ImageError } from "@shared/editor/components/Image";
import {
BackIcon,
CloseIcon,
CommentIcon,
CrossIcon,
DownloadIcon,
LinkIcon,
@@ -46,7 +40,7 @@ import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
import { toast } from "sonner";
import { findIndex } from "es-toolkit/compat";
import { findIndex } from "lodash";
import type { LightboxImage } from "@shared/editor/lib/Lightbox";
import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch";
import {
@@ -61,9 +55,6 @@ import { NodeSelection } from "prosemirror-state";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
import { HStack } from "./primitives/HStack";
import { useDocumentContext } from "./DocumentContext";
import LightboxComments from "~/scenes/Document/components/Comments/LightboxComments";
import { PortalContext } from "./Portal";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -97,15 +88,6 @@ type Animation = {
const ANIMATION_DURATION = 0.3 * Second.ms;
/**
* Stops a React synthetic event from propagating to ancestor handlers, including
* Radix Dialog's outside-interaction detection and the editor's own click
* handlers, so the comments sidebar can manage its own focus.
*/
const stopPropagation = (event: SyntheticEvent) => {
event.stopPropagation();
};
type Props = {
/** List of allowed images */
images: LightboxImage[];
@@ -243,11 +225,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
const [commentsOpen, setCommentsOpen] = useState(false);
const [commentsRendered, setCommentsRendered] = useState(false);
const [commentsVisible, setCommentsVisible] = useState(false);
const [commentsPortalEl, setCommentsPortalEl] =
useState<HTMLDivElement | null>(null);
const animation = useRef<Animation | null>(null);
const finalImage = useRef<{
center: { x: number; y: number };
@@ -256,10 +233,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
} | null>(null);
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
const editor = useEditor();
const { document: contextDocument } = useDocumentContext();
const activeNode = editor?.view?.state?.doc?.nodeAt(activeImage.pos);
const canShowComments =
!!contextDocument && activeNode?.type.name === "image";
const currentImageIndex = findIndex(
images,
@@ -339,19 +312,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
}
}, [status.lightbox]);
useEffect(() => {
if (commentsOpen) {
setCommentsRendered(true);
const frame = window.requestAnimationFrame(() =>
setCommentsVisible(true)
);
return () => window.cancelAnimationFrame(frame);
}
setCommentsVisible(false);
const timer = window.setTimeout(() => setCommentsRendered(false), 200);
return () => window.clearTimeout(timer);
}, [commentsOpen]);
useEffect(() => {
if (status.image === ImageStatus.MIN_ZOOM) {
// It was observed that focus went to `body` as the zoom out button was disabled
@@ -481,10 +441,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
status.image === ImageStatus.MAX_ZOOM
)
) {
// Refresh the cached natural image position to account for any layout
// changes (e.g., the comments sidebar opening) since the image loaded.
rememberImagePosition();
// in lightbox
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
@@ -676,30 +632,17 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
}, [activeImage, status.lightbox]);
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
// Don't intercept keys while typing into an input, textarea, or editor.
const target = ev.target as HTMLElement | null;
if (
target &&
target !== ev.currentTarget &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable)
) {
return;
}
ev.preventDefault();
switch (ev.key) {
case "ArrowLeft": {
ev.preventDefault();
prev();
break;
}
case "ArrowRight": {
ev.preventDefault();
next();
break;
}
case "Escape": {
ev.preventDefault();
close();
break;
}
@@ -755,21 +698,14 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
onAnimationStart={handleFadeStart}
onAnimationEnd={handleFadeEnd}
/>
<StyledContent
onKeyDown={handleKeyDown}
ref={contentRef}
$commentsOpen={canShowComments && commentsOpen}
>
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
<VisuallyHidden.Root>
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
<Dialog.Description>
{t("View, navigate, or download images in the document")}
</Dialog.Description>
</VisuallyHidden.Root>
<Actions
animation={animation.current}
$commentsOpen={canShowComments && commentsOpen}
>
<Actions animation={animation.current}>
<Tooltip content={t("Zoom in")} placement="bottom">
<ActionButton
tabIndex={-1}
@@ -852,22 +788,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
/>
</Tooltip>
)}
{canShowComments && (
<Tooltip content={t("Comments")} placement="bottom">
<ActionButton
tabIndex={-1}
onClick={() => setCommentsOpen((open) => !open)}
aria-label={t("Comments")}
aria-pressed={commentsOpen}
size={32}
icon={<CommentIcon />}
borderOnHover
neutral
/>
</Tooltip>
)}
</Actions>
<CloseAction animation={animation.current}>
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<ActionButton
@@ -881,7 +802,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
/>
</Tooltip>
</Dialog.Close>
</CloseAction>
</Actions>
{currentImageIndex > 0 &&
!(
status.image === ImageStatus.ZOOMED ||
@@ -957,36 +878,12 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<Nav
dir="right"
$hidden={isIdle}
animation={animation.current}
$commentsOpen={canShowComments && commentsOpen}
>
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
{canShowComments && commentsRendered && contextDocument && (
<CommentsSidebar
ref={setCommentsPortalEl}
animation={animation.current}
$open={commentsVisible}
onPointerDown={stopPropagation}
onPointerUp={stopPropagation}
onMouseDown={stopPropagation}
onMouseUp={stopPropagation}
onClick={stopPropagation}
>
<PortalContext.Provider value={commentsPortalEl}>
<LightboxComments
document={contextDocument}
pos={activeImage.pos}
/>
</PortalContext.Provider>
</CommentsSidebar>
)}
</StyledContent>
</Dialog.Portal>
</Dialog.Root>
@@ -1193,7 +1090,7 @@ const StyledImg = styled.img<{
: ""}
`;
const StyledContent = styled(Dialog.Content)<{ $commentsOpen: boolean }>`
const StyledContent = styled(Dialog.Content)`
position: fixed;
inset: 0;
z-index: ${depths.modal};
@@ -1201,8 +1098,6 @@ const StyledContent = styled(Dialog.Content)<{ $commentsOpen: boolean }>`
justify-content: center;
align-items: center;
outline: none;
padding-inline-end: ${(props) => (props.$commentsOpen ? "360px" : "0")};
transition: padding-inline-end 200ms ease-out;
`;
const ActionButton = styled(Button)`
@@ -1211,42 +1106,12 @@ const ActionButton = styled(Button)`
const Actions = styled(HStack)<{
animation: Animation | null;
$commentsOpen: boolean;
}>`
position: absolute;
top: 0;
right: ${(props) => (props.$commentsOpen ? "360px" : "44px")};
margin: 16px 12px;
z-index: ${depths.modal};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);
border-radius: 6px;
transition: right 200ms ease-out;
${(props) =>
props.animation === null
? css`
opacity: 0;
`
: props.animation.fadeIn
? css`
animation: ${props.animation.fadeIn.apply()}
${props.animation.fadeIn.duration}ms;
`
: props.animation.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const CloseAction = styled.div<{ animation: Animation | null }>`
position: fixed;
top: 0;
right: 0;
margin: 16px 12px;
z-index: ${depths.modal + 1};
z-index: ${depths.modal};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);
border-radius: 6px;
@@ -1273,16 +1138,10 @@ const Nav = styled.div<{
$hidden: boolean;
dir: "left" | "right";
animation: Animation | null;
$commentsOpen?: boolean;
}>`
position: absolute;
${(props) =>
props.dir === "left"
? "left: 0;"
: `right: ${props.$commentsOpen ? "360px" : "0"};`}
transition:
opacity 500ms ease-in-out,
right 200ms ease-out;
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
transition: opacity 500ms ease-in-out;
z-index: ${depths.modal};
${(props) => props.$hidden && "opacity: 0;"}
${(props) =>
@@ -1324,27 +1183,6 @@ const StyledError = styled(ImageError)<{
: ""}
`;
const CommentsSidebar = styled.div<{
animation: Animation | null;
$open: boolean;
}>`
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: ${depths.modal};
display: flex;
transform: translateX(${(props) => (props.$open ? "0" : "100%")});
transition: transform 200ms ease-out;
${(props) =>
props.animation?.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const NavButton = styled(NudeButton)`
margin: 16px;
opacity: 0.75;
+1 -1
View File
@@ -203,7 +203,7 @@ const Wrapper = styled.a<{
`;
const Image = styled(Flex)`
padding-inline-end: 8px;
padding: 0 8px 0 0;
max-height: 32px;
align-items: center;
user-select: none;
+1 -1
View File
@@ -1,4 +1,4 @@
import { times } from "es-toolkit/compat";
import times from "lodash/times";
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?.();
}
},
[onOpen, onClose]
[open, onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
+1 -8
View File
@@ -33,12 +33,6 @@ type Props = {
align?: "start" | "end";
/** ARIA label for the menu */
ariaLabel: string;
/**
* Whether the menu should lock page scroll and trap focus while open.
* Defaults to true. Set to false to avoid the scrollbar-removal layout
* shift when the menu lives inside a scrollable container.
*/
modal?: boolean;
/** Additional component to display at the bottom of the top-level menu */
append?: React.ReactNode;
/** Callback when menu is opened */
@@ -56,7 +50,6 @@ export const DropdownMenu = observer(
children,
align = "start",
ariaLabel,
modal = true,
append,
onOpen,
onClose,
@@ -123,7 +116,7 @@ export const DropdownMenu = observer(
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={handleOpenChange} modal={modal}>
<Menu open={open} onOpenChange={handleOpenChange}>
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</MenuTrigger>
+10 -13
View File
@@ -42,14 +42,13 @@ export function toMenuItems(items: MenuItem[]) {
case "button":
return (
<MenuButton
key={`${item.type}-${index}`}
key={`${item.type}-${item.title}-${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}
/>
);
@@ -57,11 +56,10 @@ export function toMenuItems(items: MenuItem[]) {
case "route":
return (
<MenuInternalLink
key={`${item.type}-${index}`}
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
shortcut={item.shortcut}
to={item.to}
/>
);
@@ -69,11 +67,10 @@ export function toMenuItems(items: MenuItem[]) {
case "link":
return (
<MenuExternalLink
key={`${item.type}-${index}`}
key={`${item.type}-${item.title}-${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
@@ -95,7 +92,7 @@ export function toMenuItems(items: MenuItem[]) {
};
return (
<SubMenu key={`${item.type}-${index}`}>
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
label={item.title as string}
icon={icon}
@@ -121,7 +118,7 @@ export function toMenuItems(items: MenuItem[]) {
return (
<MenuGroup
key={`${item.type}-${index}`}
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
items={groupItems}
/>
@@ -171,7 +168,7 @@ export function toMobileMenuItems(
case "button":
return (
<Components.MenuButton
key={`${item.type}-${index}`}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
$dangerous={item.dangerous}
onClick={(e) => {
@@ -192,7 +189,7 @@ export function toMobileMenuItems(
case "route":
return (
<Components.MenuInternalLink
key={`${item.type}-${index}`}
key={`${item.type}-${item.title}-${index}`}
to={item.to}
disabled={item.disabled}
onClick={closeMenu}
@@ -205,7 +202,7 @@ export function toMobileMenuItems(
case "link":
return (
<Components.MenuExternalLink
key={`${item.type}-${index}`}
key={`${item.type}-${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
typeof item.href === "string" ? undefined : item.href.target
@@ -231,7 +228,7 @@ export function toMobileMenuItems(
return (
<Components.MenuButton
key={`${item.type}-${index}`}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
onClick={() => {
openSubmenu(item.title as string);
@@ -256,7 +253,7 @@ export function toMobileMenuItems(
}
return (
<div key={`${item.type}-${index}`}>
<div key={`${item.type}-${item.title}-${index}`}>
<Components.MenuHeader>{item.title}</Components.MenuHeader>
{groupItems}
</div>
+19 -27
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,
title = "Untitled",
style,
width,
height,
@@ -40,43 +40,42 @@ 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 && onClose()}>
<Dialog.Root
open={isOpen}
onOpenChange={(open) => !open && onRequestClose()}
>
<Dialog.Portal>
<StyledOverlay />
<Dialog.Title asChild>
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
</Dialog.Title>
<StyledContent
onEscapeKeyDown={onClose}
onPointerDownOutside={onClose}
onEscapeKeyDown={onRequestClose}
onPointerDownOutside={onRequestClose}
aria-describedby={undefined}
>
{isMobile ? (
<Mobile>
<MobileContent>
<Centered onClick={(ev) => ev.stopPropagation()} column>
<Dialog.Title asChild>
{title && (
<Text size="xlarge" weight="bold">
{resolvedTitle}
{title}
</Text>
</Dialog.Title>
)}
<ErrorBoundary>{children}</ErrorBoundary>
</Centered>
</MobileContent>
<Close onClick={onClose}>
<Close onClick={onRequestClose}>
<CloseIcon size={32} />
</Close>
<Back onClick={onClose}>
<Back onClick={onRequestClose}>
<BackIcon size={32} />
<Text>{t("Back")} </Text>
</Back>
@@ -90,20 +89,13 @@ const Modal: React.FC<Props> = ({
column
reverse
>
<DesktopContent
style={style}
topShadow
overflow={dialog.animating ? "hidden" : undefined}
onAnimationEnd={() => dialog.setAnimating(false)}
>
<DesktopContent style={style} topShadow>
<ErrorBoundary component="div">{children}</ErrorBoundary>
</DesktopContent>
<Header>
<Dialog.Title asChild>
<Text size="large">{resolvedTitle}</Text>
</Dialog.Title>
{title && <Text size="large">{title}</Text>}
<Tooltip content={t("Close")} shortcut="Esc">
<NudeButton onClick={onClose}>
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Tooltip>
@@ -1,121 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { DocumentPermission } from "@shared/types";
import type Notification from "~/models/Notification";
import { client } from "~/utils/ApiClient";
import Button from "../Button";
import Flex from "../Flex";
import { SplitButton } from "../primitives/SplitButton";
type Props = {
notification: Notification;
};
function AccessRequestActions({ notification }: Props) {
const { t } = useTranslation();
const [processing, setProcessing] = React.useState(false);
const [selectedPermission, setSelectedPermission] =
React.useState<DocumentPermission>(DocumentPermission.Read);
const permissionOptions = React.useMemo(
() => [
{
label: t("View only"),
value: DocumentPermission.Read,
description: t("Can view the document"),
},
{
label: t("Can edit"),
value: DocumentPermission.ReadWrite,
description: t("Can view and edit the document"),
},
{
label: t("Manage"),
value: DocumentPermission.Admin,
description: t("Full access including sharing"),
},
],
[t]
);
const selectedLabel = permissionOptions.find(
(o) => o.value === selectedPermission
)?.label;
const handleApprove = React.useCallback(async () => {
if (!notification.actor || processing) {
return;
}
setProcessing(true);
try {
await client.post("/accessRequests.approve", {
id: notification.accessRequestId,
permission: selectedPermission,
});
notification.accessRequestStatus = "approved";
toast.success(
t(`Permissions for {{ userName }} updated`, {
userName: notification.actor?.name,
})
);
void notification.markAsRead();
} catch {
toast.error(t("Failed to approve access request"));
} finally {
setProcessing(false);
}
}, [notification, processing, selectedPermission, t]);
const handleDismiss = React.useCallback(async () => {
if (processing) {
return;
}
setProcessing(true);
try {
await client.post("/accessRequests.dismiss", {
id: notification.accessRequestId,
});
notification.accessRequestStatus = "dismissed";
toast.success(t("Access request dismissed"));
void notification.markAsRead();
} catch {
toast.error(t("Failed to dismiss access request"));
} finally {
setProcessing(false);
}
}, [notification, processing, t]);
const handleContainerClick = React.useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
return (
<Container gap={8} align="center" onClick={handleContainerClick}>
<Button onClick={handleDismiss} disabled={processing} neutral>
{t("Dismiss")}
</Button>
<SplitButton
options={permissionOptions}
selectedValue={selectedPermission}
onSelect={(value) => setSelectedPermission(value as DocumentPermission)}
onClick={handleApprove}
disabled={processing}
>
{t("Approve")} · {selectedLabel}
</SplitButton>
</Container>
);
}
const Container = styled(Flex)`
margin-top: 8px;
flex-wrap: wrap;
`;
export default observer(AccessRequestActions);
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, hover, truncateMultiline } from "@shared/styles";
import { NotificationEventType } from "@shared/types";
import type Notification from "~/models/Notification";
import useStores from "~/hooks/useStores";
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
@@ -22,7 +21,6 @@ import {
notificationArchive,
} from "~/actions/definitions/notifications";
import { NotificationSection } from "~/actions/sections";
import AccessRequestActions from "./AccessRequestActions";
const CommentEditor = lazyWithRetry(
() => import("~/scenes/Document/components/Comments/CommentEditor")
@@ -39,10 +37,6 @@ function NotificationListItem({ notification, onNavigate }: Props) {
const collectionId = notification.document?.collectionId;
const collection = collectionId ? collections.get(collectionId) : undefined;
const isAccessRequestPending =
notification.event === NotificationEventType.RequestDocumentAccess &&
notification.accessRequestStatus === "pending";
const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
if (event.altKey) {
event.preventDefault();
@@ -92,9 +86,6 @@ function NotificationListItem({ notification, onNavigate }: Props) {
defaultValue={toJS(notification.comment.data)}
/>
)}
{isAccessRequestPending && (
<AccessRequestActions notification={notification} />
)}
</Flex>
{notification.viewedAt ? null : <UnreadBadge />}
</Container>
@@ -126,12 +117,11 @@ const StyledAvatar = styled(Avatar).attrs({
const Container = styled(Flex)<{ $unread: boolean }>`
position: relative;
padding-block: 8px;
padding-inline: 12px 40px;
padding: 8px 12px;
padding-right: 40px;
border-radius: 4px;
${StyledLink}[data-state=open] &,
&:has([data-state="open"]),
&:${hover},
&:active {
background: ${s("listItemHoverBackground")};
@@ -6,7 +6,6 @@ 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";
@@ -84,7 +83,6 @@ function Notifications(
) {
const { notifications } = useStores();
const { t } = useTranslation();
const isMobile = useMobile();
const [filter, setFilter] = React.useState<NotificationFilter>("all");
const filterOptions = React.useMemo<Option[]>(
@@ -112,9 +110,8 @@ function Notifications(
<Flex
style={{
width: "100%",
minHeight: isMobile ? "75vh" : "300px",
maxHeight:
"min(75vh, calc(var(--radix-popover-content-available-height, 75vh) - 44px))",
height:
"min(300px, calc(var(--radix-popover-content-available-height) - 44px))",
}}
column
>
@@ -125,7 +122,7 @@ function Notifications(
<HStack>
<StyledInputSelect
label={t("Filter")}
labelHidden
hideLabel
options={filterOptions}
value={filter}
onChange={(value) => setFilter(value as NotificationFilter)}
@@ -1,21 +1,15 @@
import { observer } from "mobx-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Suspense, 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 useConsumeQueryParam from "~/hooks/useConsumeQueryParam";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import Notifications from "./Notifications";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Notifications = lazyWithRetry(() => import("./Notifications"));
type Props = {
children?: React.ReactNode;
@@ -24,11 +18,8 @@ type Props = {
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const shouldOpen = useConsumeQueryParam("notifications") === "true";
const [open, setOpen] = useState(shouldOpen);
const isMobile = useMobile();
const [open, setOpen] = useState(false);
const scrollableRef = useRef<HTMLDivElement>(null);
const drawerContentRef = useRef<React.ElementRef<typeof DrawerContent>>(null);
useEffect(() => {
void notifications.fetchPage({ archived: false });
@@ -49,40 +40,6 @@ 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>
@@ -94,7 +51,12 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
scrollable={false}
shrink
>
{notificationsList}
<Suspense fallback={null}>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</Suspense>
</PopoverContent>
</Popover>
);
@@ -13,7 +13,7 @@ import Switch from "../Switch";
import EventBoundary from "@shared/components/EventBoundary";
import { InputClientType } from "./InputClientType";
export type FormData = {
export interface FormData {
name: string;
developerName: string;
developerUrl: string;
@@ -22,7 +22,7 @@ export type FormData = {
redirectUris: string[];
published: boolean;
clientType: "confidential" | "public";
};
}
export const OAuthClientForm = observer(function OAuthClientForm_({
handleSubmit,

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