mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
263 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 568b4ac074 | |||
| e59d7ee973 | |||
| f3f97cc3ea | |||
| 4c4649346b | |||
| 22538e7392 | |||
| 1b0a5fb067 | |||
| eefa8d4222 | |||
| 5b2283386d | |||
| ccbc9b75fc | |||
| 05da80d318 | |||
| 26bc3fb1b8 | |||
| bc982cb516 | |||
| 733355f514 | |||
| d55c9ccc1f | |||
| 1649b46778 | |||
| 276ae71a91 | |||
| 4e07cf75bf | |||
| a422c537ec | |||
| 1b91a295e1 | |||
| 06d5969099 | |||
| 321b232f17 | |||
| 69e8aac4f1 | |||
| 7b182f9038 | |||
| c52c96dc96 | |||
| ce409c0a8a | |||
| 666b3879b3 | |||
| 46b040a9f4 | |||
| 36f6cb9e01 | |||
| 182f7f38f6 | |||
| 49d5052a51 | |||
| e6cfc45fb4 | |||
| b90659d8c1 | |||
| c02ac30eb0 | |||
| 8535f2c092 | |||
| 267835ce6f | |||
| 60562f4f6a | |||
| 600108bc43 | |||
| 6d7d8b056c | |||
| 5cb4b71652 | |||
| 4dd24b59ad | |||
| 04debcb607 | |||
| 505082b196 | |||
| 347bdb10d4 | |||
| e49e3136b6 | |||
| 60903fef84 | |||
| cbb53285a7 | |||
| 5bbc240628 | |||
| 400c0aa262 | |||
| 5e1a5a208f | |||
| 8e371ea263 | |||
| fccc343cb9 | |||
| 26f5bb9784 | |||
| 1596e51fa5 | |||
| a0acf410c5 | |||
| 7a4b545e7f | |||
| 15bd969cfa | |||
| 5e8901652e | |||
| 395da9ea8d | |||
| 30a14d7022 | |||
| d7cea83ed7 | |||
| a5219763d3 | |||
| 3c6e7ef042 | |||
| f1033f37b8 | |||
| 36ab06ab3f | |||
| 03c3be4cf2 | |||
| 2a2774a6d0 | |||
| ff34c933eb | |||
| 0d98754f5f | |||
| ff2e408c05 | |||
| 6c569f3088 | |||
| b494f64c4e | |||
| 89fe4b88be | |||
| 3fa5e745be | |||
| f1e4077457 | |||
| 9b270dabde | |||
| d3f1884fa7 | |||
| 46f1f99ce6 | |||
| 88ae883bd1 | |||
| 831c6f0898 | |||
| b3042540c4 | |||
| ff57958ebf | |||
| 0d47c10efc | |||
| db26dd5020 | |||
| 6c7a38f755 | |||
| f4f2506d36 | |||
| 51ba02715f | |||
| e61de60475 | |||
| d9b54c63c0 | |||
| fc16d3100a | |||
| 054404d716 | |||
| aab64da0e9 | |||
| f9c5540582 | |||
| 4a1c9dedff | |||
| 336bbb251f | |||
| 299e0723f3 | |||
| b935dd7d27 | |||
| b4c1f88731 | |||
| b650a0f9df | |||
| 6874b02cc7 | |||
| 4d799e7690 | |||
| e8bafaa9f3 | |||
| d54a861894 | |||
| fb9f4bb991 | |||
| c6a1db6bd1 | |||
| 79df2f2dc8 | |||
| 15524cdd08 | |||
| 21d4816a00 | |||
| c0ebed66f5 | |||
| d840a7abe7 | |||
| c72346b799 | |||
| fdb0d84e13 | |||
| e24fe02f9b | |||
| 34126a55bf | |||
| 3255f6b9ff | |||
| 64e75dac76 | |||
| ffe4e5c7e4 | |||
| 3ace24c966 | |||
| f8de6f24bf | |||
| 09fe5d6785 | |||
| 6b950aae81 | |||
| 55e29bb82e | |||
| 45b6c3eefa | |||
| 121c6e198a | |||
| 5a8e730d81 | |||
| 2fffb2f83d | |||
| 30d00df1e3 | |||
| d4dec42bc5 | |||
| a411e08f1f | |||
| a0c70cee62 | |||
| e0021a3d4f | |||
| a02793514c | |||
| 741f6c07d2 | |||
| b9c9dc4127 | |||
| 4ad1baa115 | |||
| f901435226 | |||
| 81ef635f36 | |||
| d4f747b43d | |||
| 3421b5a8b5 | |||
| b7afc9ec68 | |||
| d2f94f54ed | |||
| c4930f315c | |||
| 5d5213101e | |||
| 025f422695 | |||
| b2aad71cb4 | |||
| 12c71f267e | |||
| 9516459d31 | |||
| b3227050c8 | |||
| bcc5a94070 | |||
| b354d1f5b0 | |||
| c3c5f148b7 | |||
| 0d0f5cb5c7 | |||
| af22ed4d06 | |||
| 864ec3e24b | |||
| db953c8b2f | |||
| c4479e257e | |||
| 222de9ef01 | |||
| 6e95aa441b | |||
| b70950627e | |||
| e354db8164 | |||
| 7f6ec4ae31 | |||
| 701d4bb6ee | |||
| 032d5c6b95 | |||
| 33b9a52dfe | |||
| 4b16545b10 | |||
| 27dc02aad1 | |||
| df5dd0b98d | |||
| 3cc85f1cdf | |||
| 0b213bd6b8 | |||
| c91b839d22 | |||
| 45b2f6e222 | |||
| b91d9e9a72 | |||
| 979d9a412d | |||
| c2ccdb6fd4 | |||
| 793804cd0d | |||
| f1e5a7cfa7 | |||
| 84aed78ee2 | |||
| 33d8e41e41 | |||
| 7dc1d12d3b | |||
| 0e978e1e34 | |||
| 0390f30e1d | |||
| 4a40712dcc | |||
| 0ba310e027 | |||
| eda59b1450 | |||
| ac1f68a447 | |||
| 5691ea5ae3 | |||
| 8f541eb321 | |||
| c0a6bc911c | |||
| fddf630e49 | |||
| a4badbea9c | |||
| f22bc4a0b2 | |||
| 5693618de4 | |||
| a0039b2a09 | |||
| fa17f78ae6 | |||
| beec9f5675 | |||
| 5256cdc185 | |||
| 1bd6ad830e | |||
| 9efcb2d534 | |||
| 14fc3b01db | |||
| 05eac5bc3b | |||
| 64dc5e8ea7 | |||
| f03ac1f8de | |||
| 07099bb4f6 | |||
| 4673ff0840 | |||
| 500c3f91b0 | |||
| f8098ab464 | |||
| 3740e09e5c | |||
| 62cfd4e9bc | |||
| 85072dab92 | |||
| 1e8d9b5f80 | |||
| 613877714b | |||
| cc1c4b22d4 | |||
| a9401c9bb6 | |||
| 1345471338 | |||
| 0ddddac9c9 | |||
| 24954204ea | |||
| 1a893b0e45 | |||
| 255efe9844 | |||
| 20e55141de | |||
| 9940f48efa | |||
| b1a192c078 | |||
| 22138957ab | |||
| ff0a1766f8 | |||
| d1203408b5 | |||
| 576117e27b | |||
| 4bc0f15323 | |||
| 36d555f3fb | |||
| 350f69e194 | |||
| a92a1785ff | |||
| 631a4b0efa | |||
| 52077e4d47 | |||
| 79fc0b90b9 | |||
| ea4fbdb7bb | |||
| 88f7ef9d03 | |||
| 951fb8a34a | |||
| 0b5bd31017 | |||
| 48c7bd990a | |||
| 54f2994b13 | |||
| 8d9cd25b4e | |||
| 16a4b8417e | |||
| c993305c1b | |||
| 70891d5fa7 | |||
| 89511d4026 | |||
| bd573c44c1 | |||
| 2e50fb0344 | |||
| fee9791cc9 | |||
| e913075d75 | |||
| bb3d72cb83 | |||
| 0d8d9a1798 | |||
| 0c6e4f349b | |||
| a8b701aff3 | |||
| 83977f85bd | |||
| 9f1e6d8b40 | |||
| 257d01af89 | |||
| 1a54625cdb | |||
| 1a380f844c | |||
| 03a78ab6d6 | |||
| b63225fa73 | |||
| 3066b7ba4e | |||
| aeb6d12f17 | |||
| db19a5cf0d | |||
| c875930430 | |||
| 3d1c2a8759 | |||
| 2681a2cfaf |
+18
-3
@@ -1,5 +1,21 @@
|
||||
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=
|
||||
@@ -129,9 +145,8 @@ FORCE_HTTPS=true
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
|
||||
# Discord, or Microsoft is required for a working installation or you'll
|
||||
# have no sign-in options.
|
||||
# Third party signin credentials, at least ONE OF these is required for a
|
||||
# working installation or you'll have no sign-in options.
|
||||
|
||||
# Slack sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
|
||||
|
||||
@@ -31,6 +31,12 @@ jobs:
|
||||
|
||||
if (prAge < TWO_WEEKS) continue;
|
||||
|
||||
const hasSkipLabel = pr.labels.some(label =>
|
||||
label.name === 'pinned'
|
||||
);
|
||||
|
||||
if (hasSkipLabel) continue;
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
+13
-13
@@ -24,17 +24,17 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Use Node.js 22.x
|
||||
- name: Use Node.js 24.x
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.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') }}
|
||||
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: yarn install --immutable
|
||||
@@ -48,13 +48,13 @@ jobs:
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
@@ -66,13 +66,13 @@ jobs:
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
@@ -114,13 +114,13 @@ jobs:
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
@@ -152,13 +152,13 @@ jobs:
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: |
|
||||
@@ -175,13 +175,13 @@ jobs:
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
cache: "yarn"
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
|
||||
- name: Set environment to production
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Docker Build Check
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
|
||||
env:
|
||||
BASE_IMAGE_NAME: outline-base
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Build base image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
push: false
|
||||
|
||||
- name: Build main image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
|
||||
@@ -17,11 +17,11 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
@@ -30,14 +30,14 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -96,11 +96,11 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
@@ -109,14 +109,14 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -182,17 +182,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
name: Update Node.js LTS
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Monday at 9:00 UTC
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-node:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check for Node.js LTS update
|
||||
id: check
|
||||
run: |
|
||||
# Get current Node version from Dockerfile
|
||||
CURRENT_VERSION=$(grep -oP 'FROM node:\K[0-9]+\.[0-9]+\.[0-9]+' Dockerfile.base)
|
||||
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Current Node.js version: $CURRENT_VERSION"
|
||||
|
||||
# Fetch the latest LTS release (any major version) from nodejs.org
|
||||
LATEST_VERSION=$(curl -s https://nodejs.org/dist/index.json | \
|
||||
jq -r '[.[] | select(.lts != false)][0].version' | \
|
||||
sed 's/^v//')
|
||||
|
||||
if ! [[ "$LATEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Failed to fetch a valid LTS version (got '$LATEST_VERSION')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "latest=$LATEST_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Latest Node.js LTS version: $LATEST_VERSION"
|
||||
|
||||
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
|
||||
echo "updated=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Already up to date."
|
||||
else
|
||||
echo "updated=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Update available: $CURRENT_VERSION -> $LATEST_VERSION"
|
||||
fi
|
||||
|
||||
- name: Update Node.js version references
|
||||
if: steps.check.outputs.updated == 'true'
|
||||
env:
|
||||
CURRENT: ${{ steps.check.outputs.current }}
|
||||
LATEST: ${{ steps.check.outputs.latest }}
|
||||
run: |
|
||||
CURRENT_MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
LATEST_MAJOR=$(echo "$LATEST" | cut -d. -f1)
|
||||
|
||||
# Update Dockerfiles
|
||||
sed -i "s/node:${CURRENT}-slim/node:${LATEST}-slim/g" Dockerfile
|
||||
sed -i "s/node:${CURRENT} /node:${LATEST} /g" Dockerfile.base
|
||||
|
||||
# Update references that depend on major version
|
||||
if [ "$CURRENT_MAJOR" != "$LATEST_MAJOR" ]; then
|
||||
# .nvmrc
|
||||
echo "$LATEST_MAJOR" > .nvmrc
|
||||
|
||||
# CI workflow: step name, node-version, and cache keys
|
||||
sed -i "s/Use Node.js ${CURRENT_MAJOR}.x/Use Node.js ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
|
||||
sed -i "s/node-version: ${CURRENT_MAJOR}.x/node-version: ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
|
||||
# Update cache keys: replace node-modules-[optional old version] with new version
|
||||
sed -i -E "s/node-modules-([0-9]+\.x-)?/node-modules-${LATEST_MAJOR}.x-/g" .github/workflows/ci.yml
|
||||
|
||||
# package.json engines field: append new major version
|
||||
sed -i "s/\"node\": \"\(.*\)\"/\"node\": \"\1 || ${LATEST_MAJOR}\"/" package.json
|
||||
fi
|
||||
|
||||
echo "Updated Node.js from $CURRENT to $LATEST"
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.check.outputs.updated == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
body: |
|
||||
Automated update of Node.js in Docker images.
|
||||
|
||||
- **Previous version:** ${{ steps.check.outputs.current }}
|
||||
- **New version:** ${{ steps.check.outputs.latest }}
|
||||
|
||||
[Release notes](https://nodejs.org/en/blog/release/v${{ steps.check.outputs.latest }})
|
||||
branch: automated/update-node-lts
|
||||
delete-branch: true
|
||||
labels: dependencies
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 86400
|
||||
npmMinimalAgeGate: 1440
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- outline-icons
|
||||
|
||||
@@ -70,7 +70,7 @@ yarn install
|
||||
### Exports
|
||||
|
||||
- Exported members must appear at the top of the file.
|
||||
- Prefer named exports for components & classes.
|
||||
- Always use named exports for new components & classes.
|
||||
- Document ALL public/exported functions with JSDoc.
|
||||
|
||||
## React Usage
|
||||
@@ -188,6 +188,7 @@ yarn test:shared # All shared code tests
|
||||
## Security
|
||||
|
||||
- Sanitize all user input.
|
||||
- Always use `sanitizeUrl()` when setting `href` or `src` from user-controlled data in ProseMirror `toDOM` methods, regardless of whether it is imported via an alias or a relative path. Unlike React components, `toDOM` writes raw DOM and does not sanitize attribute values.
|
||||
- Use CSRF protection.
|
||||
- Use rateLimiter middleware for sensitive endpoints.
|
||||
- Follow OWASP guidelines.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:22.21.0-slim AS runner
|
||||
FROM node:24.15.0-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:22.21.0 AS deps
|
||||
FROM node:24.15.0 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.5.0
|
||||
Licensed Work: Outline 1.7.0
|
||||
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-02-15
|
||||
Change Date: 2030-04-24
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -27,23 +27,23 @@ Please see the [documentation](https://docs.getoutline.com/s/hosting/) for runni
|
||||
|
||||
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
|
||||
|
||||
# Development
|
||||
# Contributing
|
||||
|
||||
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
|
||||
> **Note:** Please do not submit AI-generated pull requests. We receive a high volume of mass, low-quality PRs generated by AI tools like Claude, ChatGPT, and Copilot from contributors who are unfamiliar with the codebase. These PRs are almost never mergeable and waste maintainer time reviewing them. If you’d like to contribute, please take the time to understand the codebase and write your changes thoughtfully.
|
||||
|
||||
## Contributing
|
||||
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – 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.
|
||||
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
Before submitting a pull request _please_ 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. This will result in a much higher likelihood of your code being accepted.
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
If you’re 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 and other issues listed on GitHub
|
||||
- Bugs, quality fixes, and other issues listed on GitHub
|
||||
|
||||
# Development
|
||||
|
||||
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PlusIcon, TrashIcon } from "outline-icons";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, PlusIcon, TrashIcon } from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import env from "~/env";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import ApiKeyNew from "~/scenes/ApiKeyNew";
|
||||
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
|
||||
@@ -25,6 +28,22 @@ export const createApiKey = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy API key",
|
||||
section: SettingsSection,
|
||||
icon: <CopyIcon />,
|
||||
visible: () => !!apiKey.value,
|
||||
perform: ({ t }) => {
|
||||
copy(apiKey.value, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
toast.success(t("API key copied"));
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createAction({
|
||||
name: ({ t, isMenu }) =>
|
||||
|
||||
@@ -32,16 +32,20 @@ import {
|
||||
CaseSensitiveIcon,
|
||||
RestoreIcon,
|
||||
EditIcon,
|
||||
EmbedIcon,
|
||||
OpenIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import { 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";
|
||||
@@ -73,6 +77,7 @@ import {
|
||||
searchPath,
|
||||
documentPath,
|
||||
urlify,
|
||||
desktopify,
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
@@ -86,6 +91,8 @@ import type {
|
||||
} from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import env from "~/env";
|
||||
import { isMac, isWindows } from "@shared/utils/browser";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
@@ -122,7 +129,7 @@ export const openDocument = createActionWithChildren({
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
),
|
||||
section: DocumentSection,
|
||||
to: item.url,
|
||||
@@ -335,8 +342,15 @@ export const createNewDocument = createActionWithChildren({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
|
||||
});
|
||||
|
||||
@@ -565,7 +579,10 @@ export const shareDocument = createAction({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId!);
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const can = stores.policies.abilities(activeDocumentId);
|
||||
return can.manageUsers || can.share;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -722,8 +739,6 @@ export const copyDocumentAsPlainText = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toPlainText(document));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
@@ -944,6 +959,58 @@ export const printDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInDesktop = createAction({
|
||||
name: ({ t }) => t("Open in desktop app"),
|
||||
analyticsName: "Open in desktop",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <OpenIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
isCloudHosted &&
|
||||
(isMac || isWindows) &&
|
||||
!!document &&
|
||||
!document.isDeleted &&
|
||||
!isMobile()
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
window.location.href = desktopify(documentPath(document));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const presentDocument = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
|
||||
analyticsName: "Present document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EmbedIcon />,
|
||||
shortcut: ["Control+Alt+KeyP"],
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId && !isMobile(),
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (stores.ui.presentationData) {
|
||||
stores.ui.setPresentingDocument(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.setPresentingDocument(document);
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
@@ -1487,11 +1554,13 @@ export const rootDocumentActions = [
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
permanentlyDeleteDocumentsInTrash,
|
||||
presentDocument,
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
shareDocument,
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PlusIcon } from "outline-icons";
|
||||
import { createAction } from "~/actions";
|
||||
import { TeamSection } from "../sections";
|
||||
import stores from "~/stores";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
|
||||
|
||||
export const createEmoji = createAction({
|
||||
name: ({ t }) => `${t("New emoji")}…`,
|
||||
|
||||
@@ -241,7 +241,10 @@ export const logout = createAction({
|
||||
section: NavigationSection,
|
||||
icon: <LogoutIcon />,
|
||||
perform: async () => {
|
||||
await stores.auth.logout({ userInitiated: true });
|
||||
await stores.auth.logout({
|
||||
userInitiated: true,
|
||||
clearCache: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
settingsPath,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
@@ -200,8 +201,6 @@ export const copyTemplateAsPlainText = createAction({
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toPlainText(template));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export function actionToMenuItem(
|
||||
tooltip: resolve<React.ReactChild>(action.tooltip, context),
|
||||
selected: resolve<boolean>(action.selected, context),
|
||||
dangerous: action.dangerous,
|
||||
shortcut: action.shortcut,
|
||||
onClick: () => performAction(action, context),
|
||||
};
|
||||
|
||||
@@ -143,6 +144,7 @@ export function actionToMenuItem(
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
shortcut: action.shortcut,
|
||||
to,
|
||||
};
|
||||
}
|
||||
@@ -154,6 +156,7 @@ export function actionToMenuItem(
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
shortcut: action.shortcut,
|
||||
href: action.target
|
||||
? { url: action.url, target: action.target }
|
||||
: action.url,
|
||||
@@ -210,6 +213,7 @@ 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
|
||||
@@ -229,6 +233,7 @@ export function actionToKBar(
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
subtitle,
|
||||
icon,
|
||||
priority,
|
||||
perform: () => performAction(action, context),
|
||||
@@ -254,6 +259,7 @@ export function actionToKBar(
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
subtitle,
|
||||
priority,
|
||||
},
|
||||
...children.map((child) => ({
|
||||
|
||||
@@ -15,6 +15,9 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
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) => {
|
||||
@@ -58,7 +61,7 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
t("Recent searches");
|
||||
t("Recently viewed");
|
||||
|
||||
RecentSearchesSection.priority = -0.1;
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,10 @@ const Authenticated = ({ children }: Props) => {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout({ savePath: true });
|
||||
void auth.logout({
|
||||
savePath: true,
|
||||
clearCache: false,
|
||||
});
|
||||
|
||||
if (auth.logoutRedirectUri) {
|
||||
window.location.href = auth.logoutRedirectUri;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { RightSidebarProvider } from "~/components/RightSidebarContext";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import {
|
||||
searchPath,
|
||||
@@ -33,11 +35,18 @@ type Props = {
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const layoutRef = React.useRef<HTMLDivElement>(null);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
|
||||
useKeyDown(".", (event) => {
|
||||
if (isModKey(event)) {
|
||||
ui.toggleCollapsedSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
ev.preventDefault();
|
||||
@@ -57,21 +66,27 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
history.push(newDocumentPath(activeCollectionId));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
history.replace(postLoginPath);
|
||||
}
|
||||
}, [spendPostLoginPath]);
|
||||
|
||||
if (auth.isSuspended) {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
return <Redirect to={postLoginPath} />;
|
||||
}
|
||||
const isSettings = location.pathname.startsWith(settingsPath());
|
||||
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
<React.Suspense fallback={null}>
|
||||
{isSettings && <SettingsSidebar />}
|
||||
</React.Suspense>
|
||||
<div style={isSettings ? { display: "none" } : undefined}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
|
||||
@@ -55,6 +55,15 @@ 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") {
|
||||
@@ -68,6 +77,7 @@ function Breadcrumb(
|
||||
{item.icon}
|
||||
<Item
|
||||
to={item.to}
|
||||
onClick={handleClick}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
@@ -76,7 +86,7 @@ function Breadcrumb(
|
||||
</>
|
||||
);
|
||||
},
|
||||
[actionContext, highlightFirstItem]
|
||||
[actionContext, handleClick, highlightFirstItem]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -103,19 +113,16 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("text")};
|
||||
font-size: 15px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
margin-inline-start: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
max-width: 460px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { DisclosureIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import type { HapticInput } from "web-haptics";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { s } from "@shared/styles";
|
||||
import type { Props as ActionButtonProps } from "~/components/ActionButton";
|
||||
import ActionButton from "~/components/ActionButton";
|
||||
@@ -123,7 +125,7 @@ const Label = styled.span<{ hasIcon?: boolean }>`
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
${(props) => props.hasIcon && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && "padding-inline-start: 4px;"};
|
||||
`;
|
||||
|
||||
export const Inner = styled.span<{
|
||||
@@ -133,13 +135,13 @@ export const Inner = styled.span<{
|
||||
}>`
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
padding-inline-end: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
|
||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && props.hasText && "padding-inline-start: 4px;"};
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
@@ -152,6 +154,8 @@ export type Props<T> = ActionButtonProps & {
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: LocationDescriptor;
|
||||
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
|
||||
haptic?: HapticInput;
|
||||
borderOnHover?: boolean;
|
||||
hideIcon?: boolean;
|
||||
href?: string;
|
||||
@@ -176,11 +180,13 @@ const Button = <T extends React.ElementType = "button">(
|
||||
hideIcon,
|
||||
fullwidth,
|
||||
danger,
|
||||
haptic,
|
||||
...rest
|
||||
} = props;
|
||||
const hasText = !!children || value !== undefined;
|
||||
const ic = hideIcon ? undefined : (action?.icon ?? icon);
|
||||
const hasIcon = ic !== undefined;
|
||||
const { trigger } = useWebHaptics();
|
||||
|
||||
return (
|
||||
<RealButton
|
||||
@@ -191,6 +197,7 @@ const Button = <T extends React.ElementType = "button">(
|
||||
$danger={danger}
|
||||
$fullwidth={fullwidth}
|
||||
$borderOnHover={borderOnHover}
|
||||
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
|
||||
{...rest}
|
||||
>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
|
||||
@@ -22,9 +22,7 @@ const Circle = ({
|
||||
if (percentage) {
|
||||
// because the circle is so small, anything greater than 85% appears like 100%
|
||||
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
|
||||
strokePercentage = percentage
|
||||
? ((100 - percentage) * circumference) / 100
|
||||
: 0;
|
||||
strokePercentage = ((100 - percentage) * circumference) / 100;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -64,7 +64,7 @@ const StyledTrigger = styled(RadixCollapsible.Trigger)`
|
||||
padding: 0 0 8px 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14pxte
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import type { CollectionPermission } from "@shared/types";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { CollectionPermission, TeamPreference } from "@shared/types";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
@@ -15,6 +15,7 @@ import type Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import { Collapsible } from "~/components/Collapsible";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import Switch from "~/components/Switch";
|
||||
@@ -24,6 +25,7 @@ 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"));
|
||||
|
||||
@@ -34,6 +36,7 @@ export interface FormData {
|
||||
sharing: boolean;
|
||||
permission: CollectionPermission | undefined;
|
||||
commenting?: boolean | null;
|
||||
templateManagement: CollectionPermission;
|
||||
}
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
@@ -65,9 +68,26 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
}) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const dialog = useDialogContext();
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const templateManagementOptions = useMemo<Option[]>(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("Managers"),
|
||||
value: CollectionPermission.Admin,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Members"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const fallbackIcon = (
|
||||
<Icon
|
||||
@@ -93,6 +113,8 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
sharing: collection?.sharing ?? true,
|
||||
permission: collection?.permission,
|
||||
commenting: collection?.commenting ?? true,
|
||||
templateManagement:
|
||||
collection?.templateManagement ?? CollectionPermission.Admin,
|
||||
color: iconColor,
|
||||
},
|
||||
});
|
||||
@@ -135,6 +157,71 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const initial = values.name.charAt(0).toUpperCase();
|
||||
|
||||
const options = (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateManagement"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<InputSelect
|
||||
value={field.value}
|
||||
onChange={(value: string) => {
|
||||
field.onChange(value as CollectionPermission);
|
||||
}}
|
||||
options={templateManagementOptions}
|
||||
label={t("Manage templates")}
|
||||
/>
|
||||
<Text
|
||||
type="secondary"
|
||||
size="small"
|
||||
as="p"
|
||||
style={{ paddingTop: 4 }}
|
||||
>
|
||||
{t(
|
||||
"Choose who can create and edit templates in this collection."
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t("Allow commenting on documents within this collection.")}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<Text as="p">
|
||||
@@ -190,43 +277,14 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
|
||||
<Collapsible label={t("Advanced options")}>
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t(
|
||||
"Allow commenting on documents within this collection."
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{collection ? (
|
||||
options
|
||||
) : (
|
||||
<Collapsible
|
||||
label={t("Advanced options")}
|
||||
onOpenChange={() => dialog.setAnimating(true)}
|
||||
>
|
||||
{options}
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ArrowIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
|
||||
import { normalizeKeyDisplay, shortcutSeparator } from "@shared/utils/keyboard";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "~/components/Text";
|
||||
@@ -15,14 +16,22 @@ 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) {
|
||||
return action.ancestors;
|
||||
if (!currentRootActionId || !action.ancestors) {
|
||||
return action.ancestors ?? [];
|
||||
}
|
||||
const index = action.ancestors.findIndex(
|
||||
(ancestor) => ancestor.id === currentRootActionId
|
||||
@@ -56,6 +65,16 @@ function CommandBarItem(
|
||||
))}
|
||||
{action.name}
|
||||
{action.children?.length ? "…" : ""}
|
||||
{action.subtitle && (
|
||||
<Text type="secondary" ellipsis>
|
||||
|
||||
<Highlight
|
||||
text={action.subtitle}
|
||||
highlight={SEARCH_RESULT_REGEX}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Content>
|
||||
{action.shortcut?.length ? (
|
||||
<Shortcut>
|
||||
@@ -71,9 +90,12 @@ function CommandBarItem(
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((key) => (
|
||||
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
|
||||
))}
|
||||
{sc.split("+").flatMap((key, i, arr) => {
|
||||
const el = <Key key={key}>{normalizeKeyDisplay(key)}</Key>;
|
||||
return i < arr.length - 1 && shortcutSeparator
|
||||
? [el, shortcutSeparator]
|
||||
: [el];
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Shortcut>
|
||||
|
||||
@@ -43,7 +43,8 @@ const Container = styled.div`
|
||||
const Header = styled(Text).attrs({ as: "h3" })`
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
padding-block: 16px 4px;
|
||||
padding-inline: 20px 0;
|
||||
height: 36px;
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useKBar } from "kbar";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
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);
|
||||
@@ -26,7 +26,7 @@ const useRecentDocumentActions = (count = 6) => {
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
),
|
||||
to: documentPath(item),
|
||||
})
|
||||
|
||||
@@ -128,7 +128,14 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
setInnerValue(value);
|
||||
if (document.activeElement === contentRef.current) {
|
||||
// Don't reset content while the user is actively editing. Update
|
||||
// lastValue so that the next input or blur event will push the
|
||||
// current DOM text back to the model via onChange.
|
||||
lastValue.current = value;
|
||||
} else {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ const DefaultCollectionInputSelect = observer(
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
labelHidden
|
||||
short
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
|
||||
export type DialogContext = {
|
||||
animating: boolean;
|
||||
setAnimating: (isAnimating: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Context for the dialogs (Guide/Modal) being rendered.
|
||||
* This helps control the dialog's behavior from within any nested component.
|
||||
*/
|
||||
const DialogContext = createContext<DialogContext>({
|
||||
animating: false,
|
||||
setAnimating: () => {},
|
||||
});
|
||||
|
||||
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const ctx = useMemo<DialogContext>(
|
||||
() => ({
|
||||
animating,
|
||||
setAnimating,
|
||||
}),
|
||||
[animating]
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={ctx}>{children}</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useDialogContext = () => useContext(DialogContext);
|
||||
+30
-27
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { Suspense } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import { DialogProvider } from "./DialogContext";
|
||||
|
||||
const Guide = lazyWithRetry(() => import("~/components/Guide"));
|
||||
const Modal = lazyWithRetry(() => import("~/components/Modal"));
|
||||
@@ -12,33 +13,35 @@ function Dialogs() {
|
||||
const modals = [...modalStack];
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
onRequestClose={dialogs.closeGuide}
|
||||
title={guide.title}
|
||||
>
|
||||
{guide.content}
|
||||
</Guide>
|
||||
) : undefined}
|
||||
{modals.map(([id, modal]) => (
|
||||
<Modal
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
width={modal.width}
|
||||
height={modal.height}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</Suspense>
|
||||
<DialogProvider>
|
||||
<Suspense fallback={null}>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
onRequestClose={dialogs.closeGuide}
|
||||
title={guide.title}
|
||||
>
|
||||
{guide.content}
|
||||
</Guide>
|
||||
) : undefined}
|
||||
{modals.map(([id, modal]) => (
|
||||
<Modal
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
width={modal.width}
|
||||
height={modal.height}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</Suspense>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
@@ -5,9 +5,14 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
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";
|
||||
@@ -68,7 +73,9 @@ function DocumentBreadcrumb(
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
name: collection?.name,
|
||||
name: collection ? (
|
||||
<CollectionName collection={collection} />
|
||||
) : undefined,
|
||||
section: ActiveDocumentSection,
|
||||
icon: collection ? (
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
@@ -90,18 +97,20 @@ function DocumentBreadcrumb(
|
||||
...path.map((node) => {
|
||||
const title = node.title || t("Untitled");
|
||||
return createInternalLinkAction({
|
||||
name: node.icon ? (
|
||||
<>
|
||||
<StyledIcon
|
||||
value={node.icon}
|
||||
color={node.color}
|
||||
initial={node.title.charAt(0).toUpperCase()}
|
||||
/>{" "}
|
||||
{title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
name: (
|
||||
<DocumentName
|
||||
documentId={node.id}
|
||||
collection={collection}
|
||||
title={title}
|
||||
/>
|
||||
),
|
||||
icon: node.icon ? (
|
||||
<Icon
|
||||
value={node.icon}
|
||||
color={node.color}
|
||||
initial={title.charAt(0).toUpperCase()}
|
||||
/>
|
||||
) : undefined,
|
||||
section: ActiveDocumentSection,
|
||||
to: {
|
||||
pathname: node.url,
|
||||
@@ -169,9 +178,57 @@ function DocumentBreadcrumb(
|
||||
);
|
||||
}
|
||||
|
||||
const StyledIcon = styled(Icon)`
|
||||
margin-right: 2px;
|
||||
`;
|
||||
/** 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 SmallSlash = styled(GoToIcon)`
|
||||
width: 12px;
|
||||
|
||||
@@ -110,7 +110,7 @@ function DocumentCard(props: Props) {
|
||||
dir={document.dir}
|
||||
$isDragging={isDragging}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
pathname: document.path,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
|
||||
@@ -12,7 +12,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
@@ -23,7 +22,6 @@ import DocumentExplorerNode from "./DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -42,6 +40,28 @@ type Props = {
|
||||
showDocuments?: boolean;
|
||||
};
|
||||
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function DocumentExplorer({
|
||||
onSubmit,
|
||||
onSelect,
|
||||
@@ -67,8 +87,6 @@ function DocumentExplorer({
|
||||
return node || null;
|
||||
}
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] =
|
||||
React.useState<number>(0);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
@@ -91,9 +109,6 @@ function DocumentExplorer({
|
||||
);
|
||||
const listRef = React.useRef<List<NavigationNode[]>>(null);
|
||||
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
|
||||
@@ -144,7 +159,8 @@ function DocumentExplorer({
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, [defaultValue, selectedNode, nodes]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultValue]);
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
@@ -152,17 +168,9 @@ function DocumentExplorer({
|
||||
const normalizedBaseDepth =
|
||||
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
if (itemRefs[node] && itemRefs[node].current) {
|
||||
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
},
|
||||
[itemRefs]
|
||||
);
|
||||
const scrollNodeIntoView = React.useCallback((node: number) => {
|
||||
listRef.current?.scrollToItem(node, "smart");
|
||||
}, []);
|
||||
|
||||
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(ev.target.value);
|
||||
@@ -170,16 +178,16 @@ function DocumentExplorer({
|
||||
|
||||
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
|
||||
|
||||
const calculateInitialScrollOffset = (itemCount: number) => {
|
||||
const preserveScrollOffset = (itemCount: number) => {
|
||||
if (listRef.current) {
|
||||
const { height, itemSize } = listRef.current.props;
|
||||
const { scrollOffset } = listRef.current.state as {
|
||||
scrollOffset: number;
|
||||
};
|
||||
const itemsHeight = itemCount * itemSize;
|
||||
return itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
setTimeout(() => listRef.current?.scrollTo(offset), 0);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const collapse = (node: number) => {
|
||||
@@ -190,8 +198,7 @@ function DocumentExplorer({
|
||||
|
||||
// remove children
|
||||
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
preserveScrollOffset(newNodes.length);
|
||||
};
|
||||
|
||||
const expand = (node: number) => {
|
||||
@@ -200,8 +207,7 @@ function DocumentExplorer({
|
||||
// add children
|
||||
const newNodes = nodes.slice();
|
||||
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
preserveScrollOffset(newNodes.length);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -225,7 +231,8 @@ function DocumentExplorer({
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || showDocuments !== false;
|
||||
nodes[node].children.length > 0 ||
|
||||
(showDocuments !== false && nodes[node].type === "collection");
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -387,25 +394,6 @@ function DocumentExplorer({
|
||||
}
|
||||
};
|
||||
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
<ListSearch
|
||||
@@ -425,14 +413,12 @@ function DocumentExplorer({
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
ref={listRef}
|
||||
key={nodes.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={nodes}
|
||||
itemCount={nodes.length}
|
||||
itemSize={isMobile ? 48 : 32}
|
||||
innerElementType={innerElementType}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemKey={(index, results) => results[index].id}
|
||||
>
|
||||
{ListItem}
|
||||
@@ -460,10 +446,7 @@ const FlexContainer = styled(Flex)`
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ListSearch = styled(InputSearch)`
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
const ListSearch = styled(InputSearch).attrs({ round: true })`
|
||||
margin-bottom: 4px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
|
||||
@@ -9,17 +9,27 @@ import Disclosure from "~/components/Sidebar/components/Disclosure";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
/** Whether this node is the chosen destination (committed pick via click or Enter). */
|
||||
selected: boolean;
|
||||
/** Whether this node is currently highlighted by pointer hover or keyboard navigation. */
|
||||
active: boolean;
|
||||
/** Inline style passed in by the virtualized list for absolute positioning. */
|
||||
style: React.CSSProperties;
|
||||
/** Whether this node's children are currently revealed in the tree. */
|
||||
expanded: boolean;
|
||||
/** Icon rendered before the title (document icon, emoji, or star). */
|
||||
icon?: React.ReactNode;
|
||||
/** Display title for the node. */
|
||||
title: string;
|
||||
/** Zero-based nesting depth, used to indent the node. */
|
||||
depth: number;
|
||||
/** Whether this node has descendants and should render a disclosure chevron. */
|
||||
hasChildren: boolean;
|
||||
|
||||
/** Fired when the disclosure chevron is clicked to expand or collapse the node. */
|
||||
onDisclosureClick: (ev: React.MouseEvent) => void;
|
||||
/** Fired on pointer movement over the node; used to update the active highlight. */
|
||||
onPointerMove: (ev: React.MouseEvent) => void;
|
||||
/** Fired when the node is clicked to toggle its selection. */
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
@@ -40,10 +50,8 @@ function DocumentExplorerNode(
|
||||
ref: React.RefObject<HTMLSpanElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const OFFSET = 12;
|
||||
const DISCLOSURE = 20;
|
||||
|
||||
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
|
||||
const DISCLOSURE = 24;
|
||||
const width = (depth + 2) * DISCLOSURE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
@@ -80,7 +88,11 @@ const Title = styled(Text)`
|
||||
const StyledDisclosure = styled(Disclosure)`
|
||||
position: relative;
|
||||
left: auto;
|
||||
margin-top: 2px;
|
||||
margin: 2px 0;
|
||||
|
||||
&&[aria-expanded="true"]:not(:hover) {
|
||||
background: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Spacer = styled(Flex)<{ width: number }>`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { Node as SearchResult } from "./DocumentExplorerNode";
|
||||
@@ -32,22 +31,8 @@ function DocumentExplorerSearchResult({
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLSpanElement | null) => {
|
||||
if (active && node) {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
},
|
||||
[active]
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchResult
|
||||
ref={ref}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -23,13 +24,23 @@ function DocumentMove({ document }: Props) {
|
||||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||||
|
||||
const items = useMemo(() => {
|
||||
// Recursively filter out the document itself and its existing parent doc, if any.
|
||||
// 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.
|
||||
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
|
||||
...node,
|
||||
children: node.children
|
||||
?.filter(
|
||||
(c) => c.id !== document.id && c.id !== document.parentDocumentId
|
||||
)
|
||||
?.filter((c) => !excludedIds.has(c.id))
|
||||
.map(filterSourceDocument),
|
||||
});
|
||||
|
||||
@@ -43,7 +54,7 @@ function DocumentMove({ document }: Props) {
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
|
||||
}, [policies, collectionTrees, document.id]);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
|
||||
@@ -211,6 +211,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const commentMarks = localRef.current.getComments();
|
||||
const commentIds = comments.orderedData.map((c) => c.id);
|
||||
const commentMarkIds = commentMarks?.map((c) => c.id);
|
||||
const focus = previousCommentIds.current !== undefined;
|
||||
const newCommentIds = difference(
|
||||
commentMarkIds,
|
||||
previousCommentIds.current ?? [],
|
||||
@@ -220,7 +221,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
newCommentIds.forEach((commentId) => {
|
||||
const mark = commentMarks.find((c) => c.id === commentId);
|
||||
if (mark) {
|
||||
onCreateCommentMark(mark.id, mark.userId);
|
||||
onCreateCommentMark(mark.id, mark.userId, { focus });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { generateEmojiNameFromFilename } from "~/utils/emoji";
|
||||
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import { VStack } from "./primitives/VStack";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { emojis } = useStores();
|
||||
const [name, setName] = React.useState("");
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
|
||||
const handleFileSelection = React.useCallback(
|
||||
(file: File) => {
|
||||
const isValidType = AttachmentValidation.emojiContentTypes.includes(
|
||||
file.type
|
||||
);
|
||||
|
||||
if (!isValidType) {
|
||||
toast.error(
|
||||
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > AttachmentValidation.emojiMaxFileSize) {
|
||||
toast.error(
|
||||
t("File size too large. Maximum size is {{ size }}.", {
|
||||
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(file);
|
||||
|
||||
// Auto-populate name field if it's empty
|
||||
setName((currentName) => {
|
||||
if (!currentName.trim()) {
|
||||
const generatedName = generateEmojiNameFromFilename(file.name);
|
||||
return generatedName || currentName;
|
||||
}
|
||||
return currentName;
|
||||
});
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const onDrop = React.useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
handleFileSelection(acceptedFiles[0]);
|
||||
}
|
||||
},
|
||||
[handleFileSelection]
|
||||
);
|
||||
|
||||
// Handle paste events
|
||||
React.useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const files = getDataTransferFiles(event);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileSelection(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, [handleFileSelection]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDropAccepted: onDrop,
|
||||
accept: AttachmentValidation.emojiContentTypes,
|
||||
maxSize: AttachmentValidation.emojiMaxFileSize,
|
||||
maxFiles: 1,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("Please enter a name for the emoji"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
toast.error(t("Please select an image file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// Skip compression for GIFs to preserve animation
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
|
||||
await emojis.create({
|
||||
name: name.trim(),
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
toast.success(t("Emoji created successfully"));
|
||||
onSubmit();
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setName(value);
|
||||
};
|
||||
|
||||
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
|
||||
const isValid = name.trim().length > 0 && file && isValidName;
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
disabled={!isValid || isUploading}
|
||||
savingText={isUploading ? `${t("Uploading")}…` : undefined}
|
||||
submitText={t("Add emoji")}
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Square images with transparent backgrounds work best. If your image is too large, we’ll 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;
|
||||
`;
|
||||
@@ -0,0 +1,161 @@
|
||||
import * as React from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Text from "~/components/Text";
|
||||
import { VStack } from "~/components/primitives/VStack";
|
||||
|
||||
interface UseEmojiFileUploadOptions {
|
||||
/** Optional callback fired after a valid file is selected. */
|
||||
onFileSelected?: (file: File) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages emoji image file selection with validation, drag-and-drop,
|
||||
* and paste support.
|
||||
*/
|
||||
export function useEmojiFileUpload(options?: UseEmojiFileUploadOptions) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
|
||||
const handleFileSelection = React.useCallback(
|
||||
(selected: File) => {
|
||||
const isValidType = AttachmentValidation.emojiContentTypes.includes(
|
||||
selected.type
|
||||
);
|
||||
|
||||
if (!isValidType) {
|
||||
toast.error(
|
||||
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.size > AttachmentValidation.emojiMaxFileSize) {
|
||||
toast.error(
|
||||
t("File size too large. Maximum size is {{ size }}.", {
|
||||
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selected);
|
||||
options?.onFileSelected?.(selected);
|
||||
},
|
||||
[t, options]
|
||||
);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
handleFileSelection(acceptedFiles[0]);
|
||||
}
|
||||
},
|
||||
[handleFileSelection]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const files = getDataTransferFiles(event);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileSelection(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, [handleFileSelection]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDropAccepted: handleDrop,
|
||||
accept: AttachmentValidation.emojiContentTypes,
|
||||
maxSize: AttachmentValidation.emojiMaxFileSize,
|
||||
maxFiles: 1,
|
||||
});
|
||||
|
||||
return { file, getRootProps, getInputProps, isDragActive };
|
||||
}
|
||||
|
||||
interface EmojiImageDropZoneProps {
|
||||
/** The currently selected file, if any. */
|
||||
file: File | null;
|
||||
/** Dropzone root props. */
|
||||
getRootProps: ReturnType<typeof useDropzone>["getRootProps"];
|
||||
/** Dropzone input props. */
|
||||
getInputProps: ReturnType<typeof useDropzone>["getInputProps"];
|
||||
/** Whether a drag is currently active. */
|
||||
isDragActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared drop zone component for emoji image upload, showing either a file
|
||||
* preview or placeholder text.
|
||||
*/
|
||||
export function EmojiImageDropZone({
|
||||
file,
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
}: EmojiImageDropZoneProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DropZone {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<VStack>
|
||||
{file ? (
|
||||
<>
|
||||
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
|
||||
<Text size="medium">{file.name}</Text>
|
||||
<Text size="medium" type="secondary">
|
||||
{t("Click or drag to replace")}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="small">
|
||||
{isDragActive
|
||||
? t("Drop the image here")
|
||||
: t("Click, drop, or paste an image here")}
|
||||
</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
|
||||
size: bytesToHumanReadable(
|
||||
AttachmentValidation.emojiMaxFileSize
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</DropZone>
|
||||
);
|
||||
}
|
||||
|
||||
const DropZone = styled.div`
|
||||
border: 2px dashed ${s("inputBorder")};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: var(--pointer);
|
||||
transition: border-color 0.2s;
|
||||
margin-bottom: 1em;
|
||||
|
||||
&:hover {
|
||||
border-color: ${s("inputBorderFocused")};
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewImage = styled.img`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { EmojiValidation } from "@shared/validations";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { generateEmojiNameFromFilename } from "~/utils/emoji";
|
||||
import { useEmojiFileUpload, EmojiImageDropZone } from "./Components";
|
||||
|
||||
interface Props {
|
||||
/** Callback invoked after successful creation. */
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for creating a new custom emoji with image upload and name input.
|
||||
*/
|
||||
export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { emojis } = useStores();
|
||||
const [name, setName] = React.useState("");
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
|
||||
const handleFileSelected = React.useCallback((selected: File) => {
|
||||
setName((currentName) => {
|
||||
if (!currentName.trim()) {
|
||||
const generatedName = generateEmojiNameFromFilename(selected.name);
|
||||
return generatedName || currentName;
|
||||
}
|
||||
return currentName;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { file, getRootProps, getInputProps, isDragActive } =
|
||||
useEmojiFileUpload({ onFileSelected: handleFileSelected });
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("Please enter a name for the emoji"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
toast.error(t("Please select an image file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
|
||||
await emojis.create({
|
||||
name: name.trim(),
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
toast.success(t("Emoji created successfully"));
|
||||
onSubmit();
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setName(value);
|
||||
};
|
||||
|
||||
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
|
||||
const isValid = name.trim().length > 0 && file && isValidName;
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
disabled={!isValid || isUploading}
|
||||
savingText={isUploading ? `${t("Uploading")}…` : undefined}
|
||||
submitText={t("Add emoji")}
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<LabelText as="label">{t("Upload an image")}</LabelText>
|
||||
<EmojiImageDropZone
|
||||
file={file}
|
||||
getRootProps={getRootProps}
|
||||
getInputProps={getInputProps}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t("Choose a name")}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my_custom_emoji"
|
||||
autoFocus
|
||||
required
|
||||
error={
|
||||
!isValidName
|
||||
? t(
|
||||
"name can only contain lowercase letters, numbers, and underscores."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{name.trim() && isValidName && (
|
||||
<Text type="secondary" style={{ marginTop: "8px" }}>
|
||||
{t("This emoji will be available as")} <code>:{name}:</code>
|
||||
</Text>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { useEmojiFileUpload, EmojiImageDropZone } from "./Components";
|
||||
|
||||
interface Props {
|
||||
/** The emoji whose image is being replaced. */
|
||||
emoji: Emoji;
|
||||
/** Callback invoked after a successful replacement. */
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for replacing the image of an existing custom emoji.
|
||||
*/
|
||||
export function EmojiReplaceDialog({ emoji, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { emojis } = useStores();
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
const { file, getRootProps, getInputProps, isDragActive } =
|
||||
useEmojiFileUpload();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
toast.error(t("Please select an image file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
|
||||
await emojis.update({
|
||||
id: emoji.id,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
toast.success(t("Emoji replaced"));
|
||||
onSubmit();
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
disabled={!file || isUploading}
|
||||
savingText={isUploading ? `${t("Uploading")}…` : undefined}
|
||||
submitText={t("Save")}
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Upload a new image to replace the current one for <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically."
|
||||
values={{ emojiName: `:${emoji.name}:` }}
|
||||
components={{ em: <code /> }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<EmojiImageDropZone
|
||||
file={file}
|
||||
getRootProps={getRootProps}
|
||||
getInputProps={getInputProps}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type { WithTranslation } from "react-i18next";
|
||||
import { withTranslation, Trans } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -18,20 +18,26 @@ import Storage from "@shared/utils/Storage";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import Flex from "./Flex";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
interface OwnProps {
|
||||
/** Whether to reload the page if a chunk fails to load. */
|
||||
reloadOnChunkMissing?: boolean;
|
||||
/** Whether to show a title heading. */
|
||||
showTitle?: boolean;
|
||||
/** The wrapping component to use. */
|
||||
component?: React.ComponentType | string;
|
||||
/** Children rendered when no error is present. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
type Props = OwnProps & {
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
const ERROR_TRACKING_KEY = "error-boundary-tracking";
|
||||
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@observer
|
||||
class ErrorBoundary extends React.Component<Props> {
|
||||
class ErrorBoundaryClass extends React.Component<Props> {
|
||||
@observable
|
||||
error: Error | null | undefined;
|
||||
|
||||
@@ -223,4 +229,9 @@ const Pre = styled.pre`
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
export default withTranslation()(ErrorBoundary);
|
||||
function ErrorBoundary(props: OwnProps) {
|
||||
const { t } = useTranslation();
|
||||
return <ErrorBoundaryClass t={t} {...props} />;
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
||||
@@ -18,7 +18,7 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps children in a <Fade> if loading is true on mount.
|
||||
* Wraps children in a <Fade> if animate is true on mount.
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = useState(animate);
|
||||
|
||||
@@ -88,6 +88,7 @@ function Header(
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
haptic="light"
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
@@ -115,16 +116,21 @@ function Header(
|
||||
const Breadcrumbs = styled("div")`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
padding-inline: 0 8px;
|
||||
display: flex;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
min-width: auto;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: auto;
|
||||
padding-left: 8px;
|
||||
padding-inline: 8px 0;
|
||||
gap: 12px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
const Heading = styled.h1<{ as?: string; centered?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
${(props) => (props.as ? "" : "margin-top: 6vh; font-weight: 600;")}
|
||||
${(props) => (props.as ? "" : "margin-top: 3vh; font-weight: 600;")}
|
||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: { as?: string }) => (props.as ? "" : "margin-top: 6vh;")}
|
||||
`};
|
||||
`;
|
||||
|
||||
export default Heading;
|
||||
|
||||
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Description = styled(StyledText)`
|
||||
export const Description = styled(StyledText)<{ $margin?: string }>`
|
||||
${sharedVars}
|
||||
margin-top: 0.5em;
|
||||
margin-top: ${(props) => props.$margin ?? "0.5em"};
|
||||
line-height: var(--line-height);
|
||||
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
|
||||
overflow: hidden;
|
||||
@@ -64,8 +64,6 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
width: fit-content;
|
||||
border-radius: 2em;
|
||||
padding: 1px 8px 1px 20px;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -75,8 +73,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) =>
|
||||
props.color || props.theme.backgroundSecondary};
|
||||
|
||||
@@ -17,6 +17,7 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
|
||||
import HoverPreviewIssue from "./HoverPreviewIssue";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
import HoverPreviewProject from "./HoverPreviewProject";
|
||||
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
|
||||
|
||||
const DELAY_CLOSE = 500;
|
||||
@@ -192,6 +193,18 @@ const HoverPreviewDesktop = observer(
|
||||
createdAt={data.createdAt}
|
||||
state={data.state}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Project ? (
|
||||
<HoverPreviewProject
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
name={data.name}
|
||||
color={data.color}
|
||||
lead={data.lead}
|
||||
labels={data.labels}
|
||||
description={data.description}
|
||||
state={data.state}
|
||||
targetDate={data.targetDate}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
|
||||
@@ -26,7 +26,7 @@ const HoverPreviewDocument = React.forwardRef(function HoverPreviewDocument_(
|
||||
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
|
||||
<Flex column gap={2}>
|
||||
<Title>{title}</Title>
|
||||
<Info>{lastActivityByViewer}</Info>
|
||||
{lastActivityByViewer && <Info>{lastActivityByViewer}</Info>}
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
|
||||
@@ -75,7 +75,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
</Description>
|
||||
)}
|
||||
|
||||
<Flex wrap>
|
||||
<Flex wrap gap={6} style={{ marginTop: 8 }}>
|
||||
{labels.map((label, index) => (
|
||||
<Label key={index} color={label.color}>
|
||||
{label.name}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import Editor from "~/components/Editor";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Card,
|
||||
CardContent,
|
||||
Label,
|
||||
Description,
|
||||
} from "./Components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
|
||||
type Props = Pick<
|
||||
UnfurlResponse[UnfurlResourceType.Project],
|
||||
| "url"
|
||||
| "name"
|
||||
| "color"
|
||||
| "lead"
|
||||
| "labels"
|
||||
| "state"
|
||||
| "targetDate"
|
||||
| "description"
|
||||
>;
|
||||
|
||||
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
|
||||
{ url, name, color, lead, labels, state, description, targetDate }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column ref={ref}>
|
||||
<Card fadeOut={false}>
|
||||
<CardContent>
|
||||
<Flex gap={4} column>
|
||||
<Title>
|
||||
<StyledSquircle color={color} size={16} />
|
||||
<span>
|
||||
<Backticks content={name} />
|
||||
</span>
|
||||
</Title>
|
||||
{description && (
|
||||
<Description as="div" $margin="0">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
<Text
|
||||
type="tertiary"
|
||||
size="small"
|
||||
style={{ textTransform: "capitalize" }}
|
||||
>
|
||||
{state.name}
|
||||
</Text>
|
||||
|
||||
{(lead || targetDate) && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{lead && (
|
||||
<MetadataRow>
|
||||
<MetadataLabel>{t("Lead")}</MetadataLabel>
|
||||
<Flex align="center" gap={6}>
|
||||
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
|
||||
<Text size="small">{lead.name}</Text>
|
||||
</Flex>
|
||||
</MetadataRow>
|
||||
)}
|
||||
|
||||
{targetDate && (
|
||||
<MetadataRow>
|
||||
<MetadataLabel>{t("Target date")}</MetadataLabel>
|
||||
<Text size="small">
|
||||
<Time dateTime={targetDate} addSuffix />
|
||||
</Text>
|
||||
</MetadataRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<MetadataRow>
|
||||
<MetadataLabel>{t("Labels")}</MetadataLabel>
|
||||
<Flex wrap gap={6}>
|
||||
{labels.map((label, index) => (
|
||||
<Label key={index} color={label.color}>
|
||||
{label.name}
|
||||
</Label>
|
||||
))}
|
||||
</Flex>
|
||||
</MetadataRow>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledSquircle = styled(Squircle)`
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
height: 1px;
|
||||
background: ${s("divider")};
|
||||
margin: 4px 0;
|
||||
`;
|
||||
|
||||
const MetadataRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 28px;
|
||||
`;
|
||||
|
||||
const MetadataLabel = styled(Text).attrs({
|
||||
type: "tertiary",
|
||||
size: "small",
|
||||
})`
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
`;
|
||||
|
||||
export default HoverPreviewProject;
|
||||
@@ -6,7 +6,7 @@ import type { EmojiSkinTone } from "@shared/types";
|
||||
import { EmojiCategory, IconType } from "@shared/types";
|
||||
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
|
||||
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
|
||||
import { DisplayCategory } from "../utils";
|
||||
import type { DataNode, EmojiNode } from "./GridTemplate";
|
||||
import GridTemplate from "./GridTemplate";
|
||||
|
||||
+100
-5
@@ -6,11 +6,15 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import Fade from "~/components/Fade";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
export const NativeTextarea = styled.textarea<{
|
||||
hasIcon?: boolean;
|
||||
hasPrefix?: boolean;
|
||||
$autoSize?: boolean;
|
||||
$minHeight?: string;
|
||||
$maxHeight?: string;
|
||||
}>`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
@@ -20,6 +24,10 @@ export const NativeTextarea = styled.textarea<{
|
||||
background: none;
|
||||
color: ${s("text")};
|
||||
|
||||
${(props) => props.$autoSize && `field-sizing: content;`}
|
||||
${(props) => props.$minHeight && `min-height: ${props.$minHeight};`}
|
||||
${(props) => props.$maxHeight && `max-height: ${props.$maxHeight};`}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
@@ -87,7 +95,7 @@ export const Wrapper = styled.div<{
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
position: relative;
|
||||
left: 4px;
|
||||
inset-inline-start: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
@@ -96,7 +104,9 @@ export const Outline = styled(Flex)<{
|
||||
margin?: string | number;
|
||||
hasError?: boolean;
|
||||
$focused?: boolean;
|
||||
$round?: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
flex: 1;
|
||||
margin: ${(props) =>
|
||||
props.margin !== undefined ? props.margin : "0 0 16px"};
|
||||
@@ -109,7 +119,7 @@ export const Outline = styled(Flex)<{
|
||||
: props.$focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: 4px;
|
||||
border-radius: ${(props) => (props.$round ? "16px" : "4px")};
|
||||
font-weight: normal;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
@@ -119,6 +129,24 @@ export const Outline = styled(Flex)<{
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const CharacterCount = styled.span<{ $warning?: boolean }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-end: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-start-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
border-end-start-radius: 2px;
|
||||
background: ${(props) =>
|
||||
props.$warning ? props.theme.warning : props.theme.inputBorder};
|
||||
color: ${(props) =>
|
||||
props.$warning ? props.theme.white : props.theme.textTertiary};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
export const LabelText = styled.div`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
@@ -141,6 +169,18 @@ export interface Props extends Omit<
|
||||
prefix?: React.ReactNode;
|
||||
/** Optional icon that appears inside the input before the textarea */
|
||||
icon?: React.ReactNode;
|
||||
/** Show a character count near the maxLength limit. Always shown for textareas, opt-in for other types. */
|
||||
showCharacterCount?: boolean;
|
||||
/** An optional soft limit below maxLength. When the value exceeds this, the character count is shown in a warning color. */
|
||||
warningLimit?: number;
|
||||
/** For textareas, grow the height to fit content. Use with `maxHeight` to cap the growth. */
|
||||
autoSize?: boolean;
|
||||
/** Minimum height of the textarea as a CSS length value (e.g. "3lh", "80px"). */
|
||||
minHeight?: string;
|
||||
/** Maximum height of the textarea as a CSS length value (e.g. "20lh", "400px"). */
|
||||
maxHeight?: string;
|
||||
/** Whether to use a round border-radius (16px) instead of the default (4px). */
|
||||
round?: boolean;
|
||||
/** Like autoFocus, but also select any text in the input */
|
||||
autoSelect?: boolean;
|
||||
/** Callback is triggered with the CMD+Enter keyboard combo */
|
||||
@@ -157,6 +197,21 @@ function Input(
|
||||
) {
|
||||
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const [charCount, setCharCount] = React.useState(() => {
|
||||
if (typeof props.value === "string") {
|
||||
return props.value.length;
|
||||
}
|
||||
if (typeof props.defaultValue === "string") {
|
||||
return props.defaultValue.length;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof props.value === "string") {
|
||||
setCharCount(props.value.length);
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
const handleBlur = (ev: React.SyntheticEvent) => {
|
||||
setFocused(false);
|
||||
@@ -174,6 +229,15 @@ function Input(
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setCharCount(ev.target.value.length);
|
||||
if (props.onChange) {
|
||||
props.onChange(ev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
@@ -205,14 +269,31 @@ function Input(
|
||||
short,
|
||||
flex,
|
||||
prefix,
|
||||
round,
|
||||
labelHidden,
|
||||
maxLength,
|
||||
showCharacterCount,
|
||||
warningLimit,
|
||||
autoSize,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChange,
|
||||
onRequestSubmit,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const showCharCount =
|
||||
(type === "textarea" || showCharacterCount) &&
|
||||
maxLength !== undefined &&
|
||||
(charCount >= maxLength * 0.9 ||
|
||||
(warningLimit !== undefined && charCount >= warningLimit));
|
||||
|
||||
const overWarningLimit =
|
||||
warningLimit !== undefined && charCount > warningLimit;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
@@ -224,7 +305,7 @@ function Input(
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline $focused={focused} margin={margin}>
|
||||
<Outline $focused={focused} $round={round} margin={margin}>
|
||||
{prefix}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
@@ -237,9 +318,14 @@ function Input(
|
||||
onFocus={handleFocus}
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
$autoSize={autoSize}
|
||||
$minHeight={minHeight}
|
||||
$maxHeight={maxHeight}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
// set it after "rest" to override props from spread.
|
||||
maxLength={maxLength}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<NativeInput
|
||||
@@ -253,10 +339,19 @@ function Input(
|
||||
hasPrefix={!!prefix}
|
||||
type={type}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
// set it after "rest" to override "onKeyDown" and "onChange" from prop.
|
||||
maxLength={maxLength}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{showCharCount && (
|
||||
<Fade>
|
||||
<CharacterCount $warning={overWarningLimit}>
|
||||
{charCount}/{maxLength}
|
||||
</CharacterCount>
|
||||
</Fade>
|
||||
)}
|
||||
{children}
|
||||
</Outline>
|
||||
</label>
|
||||
|
||||
@@ -36,7 +36,7 @@ const PositionedSwatchButton = styled(SwatchButton)`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
position: absolute;
|
||||
bottom: 21px;
|
||||
right: 6px;
|
||||
inset-inline-end: 6px;
|
||||
`;
|
||||
|
||||
export default InputColor;
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function InputMemberPermissionSelect(
|
||||
value={value || EmptySelectValue}
|
||||
onChange={onChange}
|
||||
label={t("Permissions")}
|
||||
hideLabel
|
||||
labelHidden
|
||||
nude
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -4,11 +4,17 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { s } from "@shared/styles";
|
||||
import {
|
||||
isModKey,
|
||||
metaDisplay,
|
||||
shortcutSeparator,
|
||||
} from "@shared/utils/keyboard";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input, { Outline } from "./Input";
|
||||
import Input from "./Input";
|
||||
|
||||
type Props = {
|
||||
/** A string representing where the search started, for tracking. */
|
||||
@@ -42,6 +48,7 @@ function InputSearchPage({
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
|
||||
|
||||
useKeyDown("f", (ev: KeyboardEvent) => {
|
||||
@@ -97,16 +104,29 @@ function InputSearchPage({
|
||||
onBlur={setUnfocused}
|
||||
margin={0}
|
||||
labelHidden
|
||||
/>
|
||||
>
|
||||
{!isMobile && (
|
||||
<Shortcut $visible={!isFocused && !value && !collectionId}>
|
||||
{metaDisplay}
|
||||
{shortcutSeparator}K
|
||||
</Shortcut>
|
||||
)}
|
||||
</InputMaxWidth>
|
||||
);
|
||||
}
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
const InputMaxWidth = styled(Input).attrs({ round: true })`
|
||||
max-width: min(calc(30vw + 20px), 100%);
|
||||
`;
|
||||
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
const Shortcut = styled.span<{ $visible: boolean }>`
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: ${s("textTertiary")};
|
||||
padding-inline: 0 10px;
|
||||
pointer-events: none;
|
||||
opacity: ${(props) => (props.$visible ? 1 : 0)};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default observer(InputSearchPage);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
InputSelectHeading,
|
||||
InputSelectTrigger,
|
||||
type TriggerButtonProps,
|
||||
} from "./primitives/InputSelect";
|
||||
@@ -35,6 +36,13 @@ type Separator = {
|
||||
type: "separator";
|
||||
};
|
||||
|
||||
type Heading = {
|
||||
/* Denotes a non-selectable heading rendered above a group of options. */
|
||||
type: "heading";
|
||||
/* Text shown as the heading label. */
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
/* Denotes a selectable option in the menu. */
|
||||
type: "item";
|
||||
@@ -48,7 +56,7 @@ export type Item = {
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type Option = Item | Separator;
|
||||
export type Option = Item | Separator | Heading;
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
|
||||
/* Options to display in the select menu. */
|
||||
@@ -60,13 +68,15 @@ type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
|
||||
/* Label for the select menu. */
|
||||
label: string;
|
||||
/* When true, label is hidden in an accessible manner. */
|
||||
hideLabel?: boolean;
|
||||
labelHidden?: boolean;
|
||||
/* When true, menu is disabled. */
|
||||
disabled?: boolean;
|
||||
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
|
||||
short?: boolean;
|
||||
/** Display a tooltip with the descriptive help text about the select menu. */
|
||||
help?: string;
|
||||
/** Render function to override the selected value shown in the trigger. Receives the currently selected option, or undefined when none is selected. */
|
||||
displayValue?: (selectedOption: Item | undefined) => React.ReactNode;
|
||||
} & TriggerButtonProps;
|
||||
|
||||
export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
@@ -76,9 +86,10 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
hideLabel,
|
||||
labelHidden,
|
||||
short,
|
||||
help,
|
||||
displayValue,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
@@ -95,12 +106,34 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
(opt) => opt.type === "item" && !!opt.icon
|
||||
);
|
||||
|
||||
const selectedOption = React.useMemo(
|
||||
() =>
|
||||
localValue
|
||||
? (options.find(
|
||||
(opt) => opt.type === "item" && opt.value === localValue
|
||||
) as Item | undefined)
|
||||
: undefined,
|
||||
[localValue, options]
|
||||
);
|
||||
|
||||
const resolvedDisplayValue = displayValue
|
||||
? displayValue(selectedOption)
|
||||
: undefined;
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option, idx: number) => {
|
||||
if (option.type === "separator") {
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
|
||||
if (option.type === "heading") {
|
||||
return (
|
||||
<InputSelectHeading key={`heading-${option.label}`}>
|
||||
{option.label}
|
||||
</InputSelectHeading>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelectItem key={option.value} value={option.value}>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
@@ -143,13 +176,14 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
onChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
resolvedDisplayValue={resolvedDisplayValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper short={short}>
|
||||
<Label text={label} hidden={hideLabel ?? false} help={help} />
|
||||
<Label text={label} hidden={labelHidden ?? false} help={help} />
|
||||
<InputSelectRoot
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
@@ -159,6 +193,7 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
<InputSelectTrigger
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
displayValue={resolvedDisplayValue}
|
||||
{...triggerProps}
|
||||
/>
|
||||
<InputSelectContent
|
||||
@@ -179,6 +214,7 @@ InputSelect.displayName = "InputSelect";
|
||||
type MobileSelectProps = Props & {
|
||||
placeholder: string;
|
||||
optionsHaveIcon: boolean;
|
||||
resolvedDisplayValue?: React.ReactNode;
|
||||
};
|
||||
|
||||
const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
@@ -188,11 +224,13 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
hideLabel,
|
||||
labelHidden,
|
||||
disabled,
|
||||
short,
|
||||
placeholder,
|
||||
optionsHaveIcon,
|
||||
displayValue: _displayValue,
|
||||
resolvedDisplayValue,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
@@ -222,6 +260,14 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
|
||||
if (option.type === "heading") {
|
||||
return (
|
||||
<InputSelectHeading key={`heading-${option.label}`}>
|
||||
{option.label}
|
||||
</InputSelectHeading>
|
||||
);
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
return (
|
||||
@@ -252,7 +298,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<Label text={label} hidden={labelHidden ?? false} />
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<SelectButton
|
||||
@@ -262,7 +308,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
disclosure
|
||||
data-placeholder={selectedOption ? false : ""}
|
||||
>
|
||||
{selectedOption ? (
|
||||
{resolvedDisplayValue !== undefined ? (
|
||||
resolvedDisplayValue
|
||||
) : selectedOption ? (
|
||||
<Option
|
||||
option={selectedOption as Item}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
@@ -365,8 +413,8 @@ const IconWrapper = styled.span`
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: -4px;
|
||||
margin-right: 4px;
|
||||
margin-inline-start: -4px;
|
||||
margin-inline-end: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
shrink?: boolean;
|
||||
} & Pick<
|
||||
React.ComponentProps<typeof InputSelect>,
|
||||
"value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
|
||||
"value" | "onChange" | "disabled" | "labelHidden" | "nude" | "help"
|
||||
>;
|
||||
|
||||
export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
|
||||
|
||||
@@ -48,6 +48,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
role="main"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
@@ -55,7 +56,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
marginInlineStart: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -85,21 +86,21 @@ type ContentProps = {
|
||||
const Content = styled(Flex)<ContentProps>`
|
||||
margin: 0;
|
||||
transition: ${(props) =>
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
props.$isResizing ? "none" : `margin-inline-start 100ms ease-out`};
|
||||
|
||||
@media print {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-left: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: ContentProps) =>
|
||||
props.$hasSidebar &&
|
||||
props.$sidebarCollapsed &&
|
||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
`margin-inline-start: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
+22
-17
@@ -9,39 +9,44 @@ export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
interface LazyLoadOptions {
|
||||
retries?: number;
|
||||
interval?: number;
|
||||
/** If provided, picks this named export from the module instead of `default`. */
|
||||
exportName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
|
||||
* Supports both default and named exports.
|
||||
*
|
||||
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
|
||||
* @param options Optional configuration for retry behavior
|
||||
* @returns An object containing the lazy Component and a preload function
|
||||
* @param factory A function that returns a promise of a module.
|
||||
* @param options Optional configuration for retry behavior and export name.
|
||||
* @returns An object containing the lazy Component and a preload function.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Default export
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <Suspense fallback={<div>Loading...</div>}>
|
||||
* <MyComponent.Component />
|
||||
* </Suspense>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Preload when needed:
|
||||
* MyComponent.preload();
|
||||
* // Named export
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
|
||||
* exportName: 'MyComponent',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
factory: () => Promise<Record<string, T>>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
const { retries, interval } = options;
|
||||
const { retries, interval, exportName } = options;
|
||||
|
||||
const wrappedFactory = exportName
|
||||
? () =>
|
||||
factory().then((m) => ({
|
||||
default: m[exportName],
|
||||
}))
|
||||
: (factory as () => Promise<{ default: T }>);
|
||||
|
||||
return {
|
||||
Component: lazyWithRetry(factory, retries, interval),
|
||||
preload: factory,
|
||||
Component: lazyWithRetry(wrappedFactory, retries, interval),
|
||||
preload: wrappedFactory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ const Wrapper = styled.a<{
|
||||
`;
|
||||
|
||||
const Image = styled(Flex)`
|
||||
padding: 0 8px 0 0;
|
||||
padding-inline-end: 8px;
|
||||
max-height: 32px;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
@@ -49,6 +49,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
tooltip={item.tooltip}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
shortcut={item.shortcut}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
);
|
||||
@@ -60,6 +61,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
shortcut={item.shortcut}
|
||||
to={item.to}
|
||||
/>
|
||||
);
|
||||
@@ -71,6 +73,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
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
|
||||
|
||||
+19
-10
@@ -17,6 +17,7 @@ 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;
|
||||
@@ -40,24 +41,27 @@ const Modal: React.FC<Props> = ({
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
const isMobile = useMobile();
|
||||
const { t } = useTranslation();
|
||||
const dialog = useDialogContext();
|
||||
|
||||
const onClose = React.useCallback(() => {
|
||||
dialog.setAnimating(false); // Reset
|
||||
onRequestClose();
|
||||
}, [dialog, onRequestClose]);
|
||||
|
||||
if (!isOpen && !wasOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onRequestClose()}
|
||||
>
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay />
|
||||
<Dialog.Title asChild>
|
||||
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
|
||||
</Dialog.Title>
|
||||
<StyledContent
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={onRequestClose}
|
||||
onEscapeKeyDown={onClose}
|
||||
onPointerDownOutside={onClose}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{isMobile ? (
|
||||
@@ -72,10 +76,10 @@ const Modal: React.FC<Props> = ({
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</MobileContent>
|
||||
<Close onClick={onRequestClose}>
|
||||
<Close onClick={onClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
<Back onClick={onRequestClose}>
|
||||
<Back onClick={onClose}>
|
||||
<BackIcon size={32} />
|
||||
<Text>{t("Back")} </Text>
|
||||
</Back>
|
||||
@@ -89,13 +93,18 @@ const Modal: React.FC<Props> = ({
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<DesktopContent style={style} topShadow>
|
||||
<DesktopContent
|
||||
style={style}
|
||||
topShadow
|
||||
overflow={dialog.animating ? "hidden" : undefined}
|
||||
onAnimationEnd={() => dialog.setAnimating(false)}
|
||||
>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</DesktopContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<NudeButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -117,8 +117,8 @@ const StyledAvatar = styled(Avatar).attrs({
|
||||
|
||||
const Container = styled(Flex)<{ $unread: boolean }>`
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
padding-right: 40px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px 40px;
|
||||
border-radius: 4px;
|
||||
|
||||
${StyledLink}[data-state=open] &,
|
||||
|
||||
@@ -110,8 +110,9 @@ function Notifications(
|
||||
<Flex
|
||||
style={{
|
||||
width: "100%",
|
||||
height:
|
||||
"min(300px, calc(var(--radix-popover-content-available-height) - 44px))",
|
||||
minHeight: "300px",
|
||||
maxHeight:
|
||||
"min(75vh, calc(var(--radix-popover-content-available-height) - 44px))",
|
||||
}}
|
||||
column
|
||||
>
|
||||
@@ -122,7 +123,7 @@ function Notifications(
|
||||
<HStack>
|
||||
<StyledInputSelect
|
||||
label={t("Filter")}
|
||||
hideLabel
|
||||
labelHidden
|
||||
options={filterOptions}
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as NotificationFilter)}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Suspense } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const PresentationMode = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/PresentationMode")
|
||||
);
|
||||
|
||||
function Presentation() {
|
||||
const { ui } = useStores();
|
||||
|
||||
if (!ui.presentationData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PresentationMode
|
||||
title={ui.presentationData.title}
|
||||
icon={ui.presentationData.icon}
|
||||
iconColor={ui.presentationData.color}
|
||||
data={ui.presentationData.data}
|
||||
onClose={() => {
|
||||
ui.setPresentingDocument(null);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Presentation);
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import { hideScrollbars } from "@shared/styles";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
/** Whether to show shadows at top and bottom when scrolled */
|
||||
@@ -45,41 +44,37 @@ function Scrollable(
|
||||
const fallbackRef = React.useRef<HTMLDivElement>();
|
||||
const [topShadowVisible, setTopShadow] = React.useState(false);
|
||||
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
|
||||
const { height } = useWindowSize();
|
||||
const updateShadows = React.useCallback(() => {
|
||||
const c = (ref || fallbackRef).current;
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
const scrollTop = c.scrollTop;
|
||||
const tsv = !!((shadow || topShadow || fadeTo) && scrollTop > 0);
|
||||
|
||||
if (tsv !== topShadowVisible) {
|
||||
setTopShadow(tsv);
|
||||
}
|
||||
setTopShadow(!!((shadow || topShadow || fadeTo) && scrollTop > 0));
|
||||
|
||||
const wrapperHeight = c.scrollHeight - c.clientHeight;
|
||||
const bsv = !!(
|
||||
(shadow || bottomShadow || fadeTo) &&
|
||||
wrapperHeight - scrollTop !== 0
|
||||
setBottomShadow(
|
||||
!!((shadow || bottomShadow || fadeTo) && wrapperHeight - scrollTop > 1)
|
||||
);
|
||||
|
||||
if (bsv !== bottomShadowVisible) {
|
||||
setBottomShadow(bsv);
|
||||
}
|
||||
}, [
|
||||
shadow,
|
||||
topShadow,
|
||||
bottomShadow,
|
||||
fadeTo,
|
||||
ref,
|
||||
topShadowVisible,
|
||||
bottomShadowVisible,
|
||||
]);
|
||||
}, [shadow, topShadow, bottomShadow, fadeTo, ref]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const c = (ref || fallbackRef).current;
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateShadows();
|
||||
}, [height, updateShadows]);
|
||||
|
||||
const observer = new ResizeObserver(updateShadows);
|
||||
observer.observe(c);
|
||||
|
||||
for (const child of Array.from(c.children)) {
|
||||
observer.observe(child);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [ref, updateShadows]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useKBar } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
|
||||
@@ -14,7 +15,7 @@ interface CacheEntry {
|
||||
// Cache configuration
|
||||
const cacheTTL = Minute.ms * 5;
|
||||
|
||||
export default function SearchActions() {
|
||||
function SearchActions() {
|
||||
const { searches, documents } = useStores();
|
||||
|
||||
// Cache structure: Map of search queries to timestamp of last search
|
||||
@@ -58,3 +59,5 @@ export default function SearchActions() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(SearchActions);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import {
|
||||
useFocusEffect,
|
||||
useRovingTabIndex,
|
||||
} from "@getoutline/react-roving-tabindex";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import type Document from "~/models/Document";
|
||||
import Highlight, { Mark } from "~/components/Highlight";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
highlight: string;
|
||||
context: string | undefined;
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
shareId?: string;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
};
|
||||
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 DocumentListItem(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { document, highlight, context, shareId, ...rest } = props;
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
if (ref) {
|
||||
itemRef = ref;
|
||||
}
|
||||
|
||||
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
|
||||
useFocusEffect(focused, itemRef);
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
to={{
|
||||
pathname: shareId
|
||||
? sharedModelPath(shareId, document.url)
|
||||
: document.url,
|
||||
search: highlight ? `?q=${encodeURIComponent(highlight)}` : undefined,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
onClick={(ev) => {
|
||||
if (rest.onClick) {
|
||||
rest.onClick(ev);
|
||||
}
|
||||
rovingTabIndex.onClick(ev);
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
</Heading>
|
||||
|
||||
{
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
}
|
||||
</Content>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
$isStarred?: boolean;
|
||||
$menuOpen?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
max-height: 50vh;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
&:${hover},
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${s("listItemHoverBackground")};
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h4<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
${ellipsis()}
|
||||
|
||||
${Mark} {
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0;
|
||||
${ellipsis()}
|
||||
|
||||
${Mark} {
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentListItem));
|
||||
@@ -1,289 +0,0 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Empty from "~/components/Empty";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type { SearchResult } from "~/types";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLInputElement> {
|
||||
shareId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SearchPopover({ shareId, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const focusRef = React.useRef<HTMLElement | null>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
>();
|
||||
|
||||
// Cache search results by query string to avoid redundant API calls
|
||||
const cacheRef = React.useRef(new Map<string, SearchResult[]>());
|
||||
const queryRef = React.useRef(query);
|
||||
queryRef.current = query;
|
||||
|
||||
// When the query changes, restore cached results (including empty) or keep
|
||||
// previous results visible until new results arrive to avoid layout shift
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
setSearchResults(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = cacheRef.current.get(query);
|
||||
if (cached !== undefined) {
|
||||
setSearchResults(cached);
|
||||
if (cached.length) {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({
|
||||
query: searchQuery,
|
||||
offset = 0,
|
||||
...options
|
||||
}: Record<string, any>) => {
|
||||
if (!searchQuery?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Return cached results for first-page lookups
|
||||
if (offset === 0 && cacheRef.current.has(searchQuery)) {
|
||||
return cacheRef.current.get(searchQuery)!;
|
||||
}
|
||||
|
||||
// Force offset to 0 for new queries — PaginatedList's reset() sets
|
||||
// offset via setState but fetchResults still uses the stale value
|
||||
// from its closure
|
||||
if (!cacheRef.current.has(searchQuery)) {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const response = await documents.search({
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
offset,
|
||||
...options,
|
||||
});
|
||||
|
||||
// Build complete result set in cache: replace for new queries, append
|
||||
// for pagination of an existing query
|
||||
const existing = cacheRef.current.get(searchQuery);
|
||||
cacheRef.current.set(
|
||||
searchQuery,
|
||||
existing ? [...existing, ...response] : response
|
||||
);
|
||||
|
||||
// Only update state if this query is still current to prevent stale
|
||||
// results from overwriting newer results after a race condition
|
||||
if (queryRef.current === searchQuery) {
|
||||
setSearchResults(cacheRef.current.get(searchQuery)!);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[documents, shareId]
|
||||
);
|
||||
|
||||
const debouncedSetQuery = React.useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setQuery(value);
|
||||
setOpen(!!value);
|
||||
}, 250),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debouncedSetQuery(event.target.value.trim());
|
||||
},
|
||||
[debouncedSetQuery]
|
||||
);
|
||||
|
||||
React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
|
||||
|
||||
const handleEscapeList = React.useCallback(
|
||||
() => searchInputRef.current?.focus(),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchInputFocus = React.useCallback(() => {
|
||||
focusRef.current = searchInputRef.current;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
if (searchResults) {
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||
if (ev.currentTarget.value.length) {
|
||||
const atEnd =
|
||||
ev.currentTarget.value.length === ev.currentTarget.selectionStart;
|
||||
|
||||
if (atEnd) {
|
||||
setOpen(true);
|
||||
}
|
||||
if (open || atEnd) {
|
||||
ev.preventDefault();
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
if (!ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
[open, searchResults]
|
||||
);
|
||||
|
||||
const handleSearchItemClick = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = "";
|
||||
focusRef.current = document.getElementById(bodyContentId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useKeyDown("/", (ev) => {
|
||||
if (
|
||||
searchInputRef.current &&
|
||||
searchInputRef.current !== document.activeElement
|
||||
) {
|
||||
searchInputRef.current.focus();
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
||||
<PopoverAnchor>
|
||||
<StyledInputSearch
|
||||
role="combobox"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
ref={searchInputRef}
|
||||
onChange={handleSearchInputChange}
|
||||
onFocus={handleSearchInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
label={t("Search")}
|
||||
labelHidden
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
id="search-results"
|
||||
aria-label={t("Results")}
|
||||
side="bottom"
|
||||
align="start"
|
||||
shrink
|
||||
onEscapeKeyDown={handleEscapeList}
|
||||
onOpenAutoFocus={preventDefault}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target === searchInputRef.current) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{
|
||||
query,
|
||||
snippetMinWords: 10,
|
||||
snippetMaxWords: 11,
|
||||
limit: 10,
|
||||
}}
|
||||
items={searchResults}
|
||||
fetch={performSearch}
|
||||
onEscape={handleEscapeList}
|
||||
empty={
|
||||
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
|
||||
}
|
||||
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
|
||||
renderItem={(item, index) => (
|
||||
<SearchListItem
|
||||
key={item.document.id}
|
||||
shareId={shareId}
|
||||
ref={index === 0 ? firstSearchItem : undefined}
|
||||
document={item.document}
|
||||
context={item.context}
|
||||
highlight={query}
|
||||
onClick={handleSearchItemClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const NoResults = styled(Empty)`
|
||||
padding: 0 12px;
|
||||
margin: 6px 0;
|
||||
`;
|
||||
|
||||
const PlaceholderList = styled(Placeholder)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
const StyledInputSearch = styled(InputSearch)`
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(SearchPopover);
|
||||
@@ -16,7 +16,6 @@ import Scrollable from "~/components/Scrollable";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Permission } from "~/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
@@ -38,10 +37,12 @@ type Props = {
|
||||
invitedInSession: string[];
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
/** Whether the share data is currently loading. */
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export const AccessControlList = observer(
|
||||
({ collection, share, invitedInSession, visible }: Props) => {
|
||||
({ collection, share, invitedInSession, visible, loading }: Props) => {
|
||||
const { memberships, groupMemberships } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(collection);
|
||||
@@ -49,35 +50,13 @@ export const AccessControlList = observer(
|
||||
const theme = useTheme();
|
||||
const collectionId = collection.id;
|
||||
|
||||
const { request: fetchMemberships, loading: membershipLoading } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => memberships.fetchAll({ id: collectionId }),
|
||||
[memberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
const { request: fetchGroupMemberships, loading: groupMembershipLoading } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => groupMemberships.fetchAll({ collectionId }),
|
||||
[groupMemberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
const groupMembershipsInCollection =
|
||||
groupMemberships.inCollection(collectionId);
|
||||
const membershipsInCollection = memberships.inCollection(collectionId);
|
||||
const hasMemberships =
|
||||
groupMembershipsInCollection.length > 0 ||
|
||||
membershipsInCollection.length > 0;
|
||||
const showLoading =
|
||||
!hasMemberships && (membershipLoading || groupMembershipLoading);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchMemberships();
|
||||
void fetchGroupMemberships();
|
||||
}, [fetchMemberships, fetchGroupMemberships]);
|
||||
const showLoading = !hasMemberships && loading;
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -146,7 +125,7 @@ export const AccessControlList = observer(
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={collection?.permission}
|
||||
hideLabel
|
||||
labelHidden
|
||||
nude
|
||||
shrink
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import { DomainPrefix, ShareLinkInput, StyledInfoIcon } from "../components";
|
||||
|
||||
@@ -35,13 +36,15 @@ function InnerPublicAccess(
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { shares } = useStores();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = React.useState("");
|
||||
const [urlId, setUrlId] = React.useState(share?.urlId);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const can = usePolicy(share);
|
||||
const collectionAbilities = usePolicy(collection);
|
||||
const canPublish = can.update && collectionAbilities.share;
|
||||
const canPublish = share ? can.update : collectionAbilities.share;
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setUrlId(share?.urlId);
|
||||
@@ -60,6 +63,19 @@ function InnerPublicAccess(
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleSubscriptionsChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowSubscriptions: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
@@ -89,14 +105,23 @@ function InnerPublicAccess(
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
published: checked,
|
||||
});
|
||||
if (checked && !share) {
|
||||
setCreating(true);
|
||||
await shares.create({
|
||||
type: "collection",
|
||||
collectionId: collection.id,
|
||||
published: true,
|
||||
});
|
||||
} else if (share) {
|
||||
await share.save({ published: checked });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
[share, shares, collection]
|
||||
);
|
||||
|
||||
const handleUrlChange = React.useMemo(
|
||||
@@ -159,7 +184,7 @@ function InnerPublicAccess(
|
||||
aria-label={t("Publish to internet")}
|
||||
checked={share?.published ?? false}
|
||||
onChange={handlePublishedChange}
|
||||
disabled={!canPublish}
|
||||
disabled={!canPublish || creating}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
@@ -194,6 +219,33 @@ function InnerPublicAccess(
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{env.EMAIL_ENABLED && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Email subscriptions")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Allow viewers to subscribe and receive email notifications when documents are updated"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Email subscriptions")}
|
||||
checked={share?.allowSubscriptions ?? true}
|
||||
onChange={handleSubscriptionsChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
|
||||
@@ -18,6 +18,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useShareDataLoader from "~/hooks/useShareDataLoader";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Permission } from "~/types";
|
||||
import { collectionPath, urlify } from "~/utils/routeHelpers";
|
||||
@@ -35,11 +36,22 @@ type Props = {
|
||||
onRequestClose: () => void;
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
/** Whether the share data is currently loading, managed externally. */
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
function SharePopover({
|
||||
collection,
|
||||
visible,
|
||||
onRequestClose,
|
||||
loading: externalLoading,
|
||||
}: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { groupMemberships, users, groups, memberships, shares } = useStores();
|
||||
const { preload, loading: internalLoading } = useShareDataLoader({
|
||||
collection,
|
||||
});
|
||||
const loading = externalLoading ?? internalLoading;
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection);
|
||||
const [query, setQuery] = React.useState("");
|
||||
@@ -94,10 +106,12 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
void collection.share();
|
||||
if (externalLoading === undefined) {
|
||||
preload();
|
||||
}
|
||||
setHasRendered(true);
|
||||
}
|
||||
}, [collection, visible]);
|
||||
}, [visible, externalLoading, preload]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
|
||||
@@ -368,6 +382,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
share={share}
|
||||
invitedInSession={invitedInSession}
|
||||
visible={visible}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</Wrapper>
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission, IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
@@ -43,6 +42,8 @@ type Props = {
|
||||
onRequestClose: () => void;
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
/** Whether the share data is currently loading. */
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export const AccessControlList = observer(
|
||||
@@ -53,13 +54,14 @@ export const AccessControlList = observer(
|
||||
sharedParent,
|
||||
onRequestClose,
|
||||
visible,
|
||||
loading,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const collection = document.collection;
|
||||
const usersInCollection = useUsersInCollection(collection);
|
||||
const user = useCurrentUser();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const { groupMemberships } = useStores();
|
||||
const collectionSharingDisabled = document.collection?.sharing === false;
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(document);
|
||||
@@ -75,36 +77,10 @@ export const AccessControlList = observer(
|
||||
margin: 24,
|
||||
});
|
||||
|
||||
const { loading: userMembershipLoading, request: fetchUserMemberships } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
userMemberships.fetchDocumentMemberships({
|
||||
id: documentId,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
[userMemberships, documentId]
|
||||
)
|
||||
);
|
||||
|
||||
const { loading: groupMembershipLoading, request: fetchGroupMemberships } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => groupMemberships.fetchAll({ documentId }),
|
||||
[groupMemberships, documentId]
|
||||
)
|
||||
);
|
||||
|
||||
const hasMemberships =
|
||||
groupMemberships.inDocument(documentId)?.length > 0 ||
|
||||
document.members.length > 0;
|
||||
const showLoading =
|
||||
!hasMemberships && (groupMembershipLoading || userMembershipLoading);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchUserMemberships();
|
||||
void fetchGroupMemberships();
|
||||
}, [fetchUserMemberships, fetchGroupMemberships]);
|
||||
const showLoading = !hasMemberships && loading;
|
||||
|
||||
React.useEffect(() => {
|
||||
calcMaxHeight();
|
||||
|
||||
@@ -14,6 +14,7 @@ import type Share from "~/models/Share";
|
||||
import Switch from "~/components/Switch";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AvatarSize } from "../../Avatar";
|
||||
import CopyToClipboard from "../../CopyToClipboard";
|
||||
import NudeButton from "../../NudeButton";
|
||||
@@ -45,13 +46,15 @@ function PublicAccess(
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { shares } = useStores();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = React.useState("");
|
||||
const [urlId, setUrlId] = React.useState(share?.urlId);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const can = usePolicy(share);
|
||||
const documentAbilities = usePolicy(document);
|
||||
const canPublish = can.update && documentAbilities.share;
|
||||
const canPublish = share ? can.update : documentAbilities.share;
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setUrlId(share?.urlId);
|
||||
@@ -70,6 +73,19 @@ function PublicAccess(
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleSubscriptionsChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowSubscriptions: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
@@ -99,14 +115,23 @@ function PublicAccess(
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
published: checked,
|
||||
});
|
||||
if (checked && !share) {
|
||||
setCreating(true);
|
||||
await shares.create({
|
||||
type: "document",
|
||||
documentId: document.id,
|
||||
published: true,
|
||||
});
|
||||
} else if (share) {
|
||||
await share.save({ published: checked });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
[share, shares, document]
|
||||
);
|
||||
|
||||
const handleUrlChange = React.useMemo(
|
||||
@@ -202,7 +227,7 @@ function PublicAccess(
|
||||
aria-label={t("Publish to internet")}
|
||||
checked={share?.published ?? false}
|
||||
onChange={handlePublishedChange}
|
||||
disabled={!canPublish}
|
||||
disabled={!canPublish || creating}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
@@ -238,6 +263,33 @@ function PublicAccess(
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{env.EMAIL_ENABLED && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Email subscriptions")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Allow viewers to subscribe and receive email notifications when this document is updated"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Email subscriptions")}
|
||||
checked={share?.allowSubscriptions ?? true}
|
||||
onChange={handleSubscriptionsChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
|
||||
@@ -18,6 +18,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useShareDataLoader from "~/hooks/useShareDataLoader";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Permission } from "~/types";
|
||||
import { documentPath, urlify } from "~/utils/routeHelpers";
|
||||
@@ -35,9 +36,16 @@ type Props = {
|
||||
onRequestClose: () => void;
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
/** Whether the share data is currently loading, managed externally. */
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
function SharePopover({
|
||||
document,
|
||||
onRequestClose,
|
||||
visible,
|
||||
loading: externalLoading,
|
||||
}: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(document);
|
||||
@@ -46,6 +54,10 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
const sharedParent = shares.getByDocumentParents(document);
|
||||
const [hasRendered, setHasRendered] = React.useState(visible);
|
||||
const { users, userMemberships, groups, groupMemberships } = useStores();
|
||||
const { preload, loading: internalLoading } = useShareDataLoader({
|
||||
document,
|
||||
});
|
||||
const loading = externalLoading ?? internalLoading;
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [picker, showPicker, hidePicker] = useBoolean();
|
||||
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
|
||||
@@ -79,13 +91,14 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch sharefocus the link button when the popover is opened
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
void document.share();
|
||||
if (externalLoading === undefined) {
|
||||
preload();
|
||||
}
|
||||
setHasRendered(true);
|
||||
}
|
||||
}, [document, hidePicker, visible]);
|
||||
}, [visible, externalLoading, preload]);
|
||||
|
||||
// Hide the picker when the popover is closed
|
||||
React.useEffect(() => {
|
||||
@@ -377,6 +390,7 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
visible={visible}
|
||||
loading={loading}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { MoonIcon, SunIcon } from "outline-icons";
|
||||
import { MoonIcon, SunIcon, SubscribeIcon } from "outline-icons";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { ShareSubscribeForm } from "./ShareSubscribeForm";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
|
||||
@@ -42,3 +49,34 @@ export const AppearanceAction = observer(() => {
|
||||
</Action>
|
||||
);
|
||||
});
|
||||
|
||||
export function SubscribeAction({
|
||||
shareId,
|
||||
documentId,
|
||||
}: {
|
||||
shareId: string;
|
||||
documentId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Action>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip content={t("Subscribe to updates")} placement="bottom">
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
icon={<SubscribeIcon />}
|
||||
aria-label={t("Subscribe to updates")}
|
||||
neutral
|
||||
borderOnHover
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</Tooltip>
|
||||
<PopoverContent side="bottom" align="end" width={340}>
|
||||
<ShareSubscribeForm shareId={shareId} documentId={documentId} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Action>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { FormEvent, ChangeEvent } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
/**
|
||||
* Subscribe form content displayed inside the popover.
|
||||
*/
|
||||
export function ShareSubscribeForm({
|
||||
shareId,
|
||||
documentId,
|
||||
}: {
|
||||
shareId: string;
|
||||
documentId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "loading" | "success" | "error"
|
||||
>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (ev: FormEvent) => {
|
||||
ev.preventDefault();
|
||||
setStatus("loading");
|
||||
try {
|
||||
await client.post("/shares.subscribe", { shareId, documentId, email });
|
||||
setStatus("success");
|
||||
} catch (err) {
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : t("Something went wrong")
|
||||
);
|
||||
setStatus("error");
|
||||
}
|
||||
},
|
||||
[shareId, documentId, email]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(ev: ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(ev.target.value);
|
||||
if (status === "error") {
|
||||
setErrorMessage("");
|
||||
setStatus("idle");
|
||||
}
|
||||
},
|
||||
[status]
|
||||
);
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<FormContainer>
|
||||
<Text type="tertiary" size="small">
|
||||
{t("Check your email to confirm your subscription")}.
|
||||
</Text>
|
||||
</FormContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormContainer>
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<Text as="label" type="tertiary" size="small">
|
||||
{t("Get notified when this document is updated")}
|
||||
</Text>
|
||||
<Flex align="center" gap={8}>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
placeholder={t("Email address")}
|
||||
required
|
||||
margin={0}
|
||||
flex
|
||||
/>
|
||||
<Button type="submit" disabled={status === "loading"} neutral>
|
||||
{t("Subscribe")}
|
||||
</Button>
|
||||
</Flex>
|
||||
{status === "error" && <ErrorText>{errorMessage}</ErrorText>}
|
||||
</StyledForm>
|
||||
</FormContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const FormContainer = styled.div`
|
||||
padding: 4px 0;
|
||||
`;
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ErrorText = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${s("danger")};
|
||||
margin: 0;
|
||||
`;
|
||||
@@ -14,6 +14,7 @@ import type User from "~/models/User";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import type { IAvatar } from "~/components/Avatar";
|
||||
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Empty from "~/components/Empty";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -21,6 +22,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useThrottledCallback from "~/hooks/useThrottledCallback";
|
||||
import { GroupMembersPopover } from "./GroupMembersPopover";
|
||||
import { InviteIcon, ListItem } from "./ListItem";
|
||||
|
||||
type Suggestion = IAvatar & {
|
||||
@@ -148,9 +150,18 @@ export const Suggestions = observer(
|
||||
if (suggestion instanceof Group) {
|
||||
return {
|
||||
title: suggestion.name,
|
||||
subtitle: t("{{ count }} member", {
|
||||
count: suggestion.memberCount,
|
||||
}),
|
||||
subtitle: (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<span onClick={(ev) => ev.stopPropagation()}>
|
||||
<GroupMembersPopover group={suggestion}>
|
||||
<StyledButtonLink>
|
||||
{t("{{ count }} member", {
|
||||
count: suggestion.memberCount,
|
||||
})}
|
||||
</StyledButtonLink>
|
||||
</GroupMembersPopover>
|
||||
</span>
|
||||
),
|
||||
image: <GroupAvatar group={suggestion} />,
|
||||
};
|
||||
}
|
||||
@@ -268,6 +279,13 @@ const Separator = styled.div`
|
||||
margin: 12px 0;
|
||||
`;
|
||||
|
||||
const StyledButtonLink = styled(ButtonLink)`
|
||||
color: ${s("textTertiary")};
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const ScrollableContainer = styled(Scrollable)`
|
||||
padding: 12px 24px;
|
||||
margin: -12px -24px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -37,6 +38,15 @@ function AppSidebar() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team);
|
||||
const history = useHistory();
|
||||
|
||||
const handleSearchClick = useCallback(() => {
|
||||
const basePath = searchPath();
|
||||
const { pathname, search } = history.location;
|
||||
if (pathname.startsWith(basePath) && (search || pathname !== basePath)) {
|
||||
history.push(basePath);
|
||||
}
|
||||
}, [history]);
|
||||
|
||||
useEffect(() => {
|
||||
void collections.fetchAll();
|
||||
@@ -57,7 +67,6 @@ function AppSidebar() {
|
||||
|
||||
return (
|
||||
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
|
||||
<HistoryNavigation />
|
||||
{dndArea && (
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragPlaceholder />
|
||||
@@ -70,7 +79,7 @@ function AppSidebar() {
|
||||
model={team}
|
||||
size={24}
|
||||
alt={t("Logo")}
|
||||
style={{ marginLeft: 4 }}
|
||||
style={{ insetInlineStart: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -107,6 +116,7 @@ function AppSidebar() {
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
onClick={handleSearchClick}
|
||||
/>
|
||||
{can.createDocument && <DraftsLink />}
|
||||
</Section>
|
||||
@@ -133,6 +143,7 @@ function AppSidebar() {
|
||||
</Scrollable>
|
||||
</DndProvider>
|
||||
)}
|
||||
<HistoryNavigation />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useWindowScrollbarWidth from "~/hooks/useWindowScrollbarWidth";
|
||||
import { sidebarAppearDuration } from "~/styles/animations";
|
||||
import { useDirection } from "@radix-ui/react-direction";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
@@ -18,25 +19,25 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
skipInitialAnimation?: boolean;
|
||||
}
|
||||
|
||||
function Right({ children, border, className, skipInitialAnimation }: Props) {
|
||||
function Aside({ children, border, className, skipInitialAnimation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const windowScrollbarWidth = useWindowScrollbarWidth();
|
||||
const direction = useDirection();
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
const width = Math.max(
|
||||
Math.min(window.innerWidth - event.pageX, maxWidth),
|
||||
minWidth
|
||||
);
|
||||
const distance =
|
||||
direction === "rtl" ? event.pageX : window.innerWidth - event.pageX;
|
||||
const width = Math.max(Math.min(distance, maxWidth), minWidth);
|
||||
ui.set({ sidebarRightWidth: width });
|
||||
},
|
||||
[minWidth, maxWidth, ui]
|
||||
[minWidth, maxWidth, direction, ui]
|
||||
);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
@@ -103,7 +104,13 @@ function Right({ children, border, className, skipInitialAnimation }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar {...animationProps} $border={border} className={className}>
|
||||
<Sidebar
|
||||
{...animationProps}
|
||||
$border={border}
|
||||
className={className}
|
||||
role="complementary"
|
||||
aria-label="Aside"
|
||||
>
|
||||
<Position style={style} column>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
<ResizeBorder
|
||||
@@ -130,15 +137,15 @@ const Sidebar = styled(m.div)<{
|
||||
flex-shrink: 0;
|
||||
background: ${s("background")};
|
||||
max-width: 80%;
|
||||
border-left: 1px solid ${s("divider")};
|
||||
transition: border-left 100ms ease-in-out;
|
||||
border-inline-start: 1px solid ${s("divider")};
|
||||
transition: border-inline-start 100ms ease-in-out;
|
||||
z-index: ${depths.sidebar};
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
z-index: ${depths.mobileSidebar};
|
||||
`}
|
||||
@@ -148,4 +155,4 @@ const Sidebar = styled(m.div)<{
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(Right);
|
||||
export default observer(Aside);
|
||||
@@ -31,7 +31,7 @@ function SettingsSidebar() {
|
||||
|
||||
const groupedConfig = groupBy(
|
||||
configs.filter((item) =>
|
||||
item.group === "Integrations" && item.pluginId
|
||||
item.group === t("Integrations") && item.pluginId
|
||||
? integrations.findByService(item.pluginId)
|
||||
: true
|
||||
),
|
||||
@@ -44,7 +44,6 @@ function SettingsSidebar() {
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<HistoryNavigation />
|
||||
<SidebarButton
|
||||
title={t("Return to App")}
|
||||
image={<StyledBackIcon />}
|
||||
@@ -76,7 +75,8 @@ function SettingsSidebar() {
|
||||
to={item.path}
|
||||
onClickIntent={item.preload}
|
||||
active={
|
||||
item.path.startsWith(settingsPath("templates"))
|
||||
item.path.startsWith(settingsPath("templates")) ||
|
||||
item.path.startsWith(settingsPath("groups"))
|
||||
? location.pathname.startsWith(item.path)
|
||||
: undefined
|
||||
}
|
||||
@@ -95,12 +95,17 @@ function SettingsSidebar() {
|
||||
)}
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
<HistoryNavigation />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledBackIcon = styled(BackIcon)`
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(SettingsSidebar);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useKBar } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { metaDisplay, shortcutSeparator } from "@shared/utils/keyboard";
|
||||
import type Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -17,8 +22,6 @@ import Section from "./components/Section";
|
||||
import { SharedCollectionLink } from "./components/SharedCollectionLink";
|
||||
import { SharedDocumentLink } from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import { useEffect } from "react";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
@@ -29,6 +32,7 @@ function SharedSidebar({ share }: Props) {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { ui, documents, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { query } = useKBar();
|
||||
|
||||
const teamAvailable = !!team?.name;
|
||||
const rootNode = share.tree;
|
||||
@@ -38,6 +42,10 @@ function SharedSidebar({ share }: Props) {
|
||||
? ProsemirrorHelper.isEmptyData(collection?.data)
|
||||
: false;
|
||||
|
||||
const handleOpenSearch = useCallback(() => {
|
||||
query.toggle();
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
ui.tocVisible = share.showTOC;
|
||||
}, []);
|
||||
@@ -64,11 +72,16 @@ function SharedSidebar({ share }: Props) {
|
||||
)}
|
||||
<ScrollContainer topShadow flex>
|
||||
<TopSection>
|
||||
<SearchWrapper>
|
||||
<StyledSearchPopover shareId={shareId} />
|
||||
</SearchWrapper>
|
||||
<SearchButton onClick={handleOpenSearch}>
|
||||
<SearchIcon size={20} />
|
||||
<SearchLabel>{t("Search")}</SearchLabel>
|
||||
<Shortcut>
|
||||
{metaDisplay}
|
||||
{shortcutSeparator}K
|
||||
</Shortcut>
|
||||
</SearchButton>
|
||||
</TopSection>
|
||||
<Section>
|
||||
<Section as="nav" aria-label={t("Documents")}>
|
||||
{share.collectionId ? (
|
||||
<SharedCollectionLink
|
||||
node={rootNode}
|
||||
@@ -102,14 +115,34 @@ const TopSection = styled(Flex)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const SearchWrapper = styled.div`
|
||||
const SearchButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
border-radius: 16px;
|
||||
background: ${s("background")};
|
||||
color: ${s("textTertiary")};
|
||||
cursor: var(--pointer);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
border-color: ${s("inputBorderFocused")};
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSearchPopover = styled(SearchPopover)`
|
||||
width: 100%;
|
||||
transition: width 100ms ease-out;
|
||||
margin: 8px 0;
|
||||
const SearchLabel = styled.span`
|
||||
flex-grow: 1;
|
||||
text-align: start;
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span`
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
export default observer(SharedSidebar);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -21,8 +22,7 @@ import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { useDirection } from "@radix-ui/react-direction";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
|
||||
@@ -53,6 +53,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
const collapsed = ui.sidebarIsClosed && canCollapse;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const { trigger } = useWebHaptics();
|
||||
const direction = useDirection();
|
||||
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
@@ -62,18 +64,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useKeyDown(".", (event) => {
|
||||
if (isModKey(event)) {
|
||||
ui.toggleCollapsedSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const newWidth = Math.min(event.pageX - offset, maxWidth);
|
||||
const rawWidth =
|
||||
direction === "rtl" ? offset - event.pageX : event.pageX - offset;
|
||||
const newWidth = Math.min(rawWidth, maxWidth);
|
||||
const isSmallerThanCollapsePoint = newWidth < minWidth / 2;
|
||||
|
||||
if (canCollapse) {
|
||||
@@ -86,7 +83,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
ui.set({ sidebarWidth: Math.max(newWidth, minWidth) });
|
||||
}
|
||||
},
|
||||
[ui, theme, offset, minWidth, maxWidth]
|
||||
[ui, theme, offset, minWidth, maxWidth, direction]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
@@ -123,11 +120,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
return;
|
||||
}
|
||||
|
||||
setOffset(event.pageX - width);
|
||||
setOffset(
|
||||
direction === "rtl" ? event.pageX + width : event.pageX - width
|
||||
);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
[width, direction]
|
||||
);
|
||||
|
||||
const handlePointerActivity = React.useCallback(() => {
|
||||
@@ -151,16 +150,21 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
|
||||
// add a short delay when mouse exits the sidebar before closing
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
const withinSidebar =
|
||||
direction === "rtl"
|
||||
? ev.pageX > window.innerWidth - width
|
||||
: ev.pageX < width;
|
||||
|
||||
setHovering(
|
||||
document.hasFocus() &&
|
||||
ev.pageX < width &&
|
||||
withinSidebar &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
[width, hasPointerMoved]
|
||||
[width, direction, hasPointerMoved]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -224,6 +228,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
[width]
|
||||
);
|
||||
|
||||
const handleCloseSidebar = () => {
|
||||
trigger("light");
|
||||
ui.toggleMobileSidebar();
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Container
|
||||
@@ -256,7 +265,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
alt={t("Avatar of {{ name }}", { name: user.name })}
|
||||
model={user}
|
||||
size={24}
|
||||
style={{ marginLeft: 4 }}
|
||||
style={{ marginInlineStart: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -275,7 +284,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={handleCloseSidebar} />}
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
@@ -303,7 +312,7 @@ type ContainerProps = {
|
||||
};
|
||||
|
||||
const hoverStyles = (props: ContainerProps) => `
|
||||
transform: none;
|
||||
transform: none !important;
|
||||
box-shadow: ${
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
@@ -321,22 +330,29 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition:
|
||||
box-shadow 150ms ease-in-out,
|
||||
transform 150ms ease-out,
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
transform 250ms cubic-bezier(0.34, 1.15, 0.64, 1)
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `, width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
transform: translateX(
|
||||
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
|
||||
);
|
||||
z-index: ${depths.mobileSidebar};
|
||||
max-width: 80%;
|
||||
min-width: 280px;
|
||||
padding-left: var(--sal);
|
||||
padding-inline-start: var(--sal);
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: translateX(
|
||||
${(props) => (props.$mobileSidebarVisible ? 0 : "100%")}
|
||||
);
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
transform: none;
|
||||
@@ -363,11 +379,20 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
z-index: ${depths.sidebar};
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
transition:
|
||||
box-shadow 150ms ease-in-out,
|
||||
transform 150ms ease-out${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `, width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
transform: translateX(${(props: ContainerProps) =>
|
||||
props.$collapsed
|
||||
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
|
||||
: 0});
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: translateX(${(props: ContainerProps) =>
|
||||
props.$collapsed ? `calc(100% - 8px)` : 0});
|
||||
}
|
||||
|
||||
${(props: ContainerProps) => props.$isHovering && css(hoverStyles)}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
import type { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import type { RefHandle } from "~/components/EditableTitle";
|
||||
import EditableTitle from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DropToImport from "./DropToImport";
|
||||
import Relative from "./Relative";
|
||||
import type { SidebarContextType } from "./SidebarContext";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import CollectionRow from "./CollectionRow";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -51,20 +37,16 @@ const CollectionLink: React.FC<Props> = ({
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const { documents } = useStores();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (name: string) => {
|
||||
await collection.save({
|
||||
name,
|
||||
});
|
||||
await collection.save({ name });
|
||||
},
|
||||
[collection]
|
||||
);
|
||||
@@ -88,37 +70,26 @@ const CollectionLink: React.FC<Props> = ({
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
const newChildTitleRef = React.useRef<RefHandle>(null);
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
}, []);
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
try {
|
||||
newChildTitleRef.current?.setIsEditing(false);
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
} catch (_err) {
|
||||
newChildTitleRef.current?.setIsEditing(true);
|
||||
}
|
||||
async (input: string) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument);
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
|
||||
[user, sidebarContext, history, collection, documents]
|
||||
);
|
||||
|
||||
const contextMenuAction = useCollectionMenuAction({
|
||||
@@ -126,98 +97,44 @@ const CollectionLink: React.FC<Props> = ({
|
||||
onRename: handleRename,
|
||||
});
|
||||
|
||||
const menu = !isDraggingAnyCollection ? (
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
<Relative ref={mergeRefs([parentRef, dropRef])}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
contextAction={contextMenuAction}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
$showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
ellipsis={!isEditing}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
{can.createDocument && (
|
||||
<Tooltip content={t("New doc")} delay={500}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
<CollectionRow
|
||||
collection={collection}
|
||||
depth={depth}
|
||||
to={{ pathname: collection.path, state: { sidebarContext } }}
|
||||
onClick={onClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onExpand={handleExpand}
|
||||
canEdit={can.update}
|
||||
labelText={collection.name}
|
||||
onTitleChange={handleTitleChange}
|
||||
editableTitleRef={editableTitleRef}
|
||||
contextAction={contextMenuAction}
|
||||
menu={menu}
|
||||
menuOpen={menuOpen}
|
||||
canCreateChild={!isDraggingAnyCollection && can.createDocument}
|
||||
onCreateChild={handleNewDoc}
|
||||
parentRef={parentRef}
|
||||
dropRef={dropRef}
|
||||
isActiveDropTarget={isOver && canDrop}
|
||||
>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={!!expanded}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
>
|
||||
{isAddingNewChild ? (
|
||||
<SidebarLink
|
||||
depth={2}
|
||||
isActive={() => true}
|
||||
ellipsis={false}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={newChildTitleRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : undefined}
|
||||
</CollectionLinkChildren>
|
||||
</ActionContextProvider>
|
||||
/>
|
||||
</CollectionRow>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ const DynamicDropCursor = observer(
|
||||
);
|
||||
|
||||
const Loading = styled(PlaceholderCollections)`
|
||||
margin-left: 44px;
|
||||
margin-inline-start: 44px;
|
||||
min-height: 90px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { Location, LocationDescriptor } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import type { ConnectDropTarget } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import type { match } from "react-router";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import type Collection from "~/models/Collection";
|
||||
import EditableTitle, { type RefHandle } from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import DropToImport from "./DropToImport";
|
||||
import Relative from "./Relative";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import type { SidebarContextType } from "./SidebarContext";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import type { ActionWithChildren } from "~/types";
|
||||
|
||||
export type CollectionRowProps = {
|
||||
/** Collection model for the row. */
|
||||
collection: Collection;
|
||||
/** Indentation depth of the row. */
|
||||
depth?: number;
|
||||
|
||||
/** Navigation target for the row. */
|
||||
to: LocationDescriptor;
|
||||
/** Click handler for the row. */
|
||||
onClick?: () => void;
|
||||
/** Called on click intent for prefetching. */
|
||||
onClickIntent?: () => void;
|
||||
/** Optional override for the active-match function. */
|
||||
isActiveOverride?: (
|
||||
match: match | null,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => boolean;
|
||||
|
||||
/** Icon displayed to the left of the label. Defaults to CollectionIcon. */
|
||||
icon?: React.ReactNode;
|
||||
|
||||
/** Whether the row is expanded. Pass undefined to hide the disclosure. */
|
||||
expanded?: boolean;
|
||||
/** Called when the disclosure caret toggles expansion. */
|
||||
onDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void;
|
||||
/** Imperative expand, used by the "+" button to auto-expand. */
|
||||
onExpand?: () => void;
|
||||
|
||||
/** When true, the name renders as an EditableTitle. */
|
||||
canEdit?: boolean;
|
||||
/** Title displayed and edited when canEdit is true. */
|
||||
labelText?: string;
|
||||
/** Submit handler for the edited title. */
|
||||
onTitleChange?: (value: string) => Promise<void>;
|
||||
/** Forwarded ref to the EditableTitle so the container can trigger rename. */
|
||||
editableTitleRef?: React.Ref<RefHandle>;
|
||||
/** Notifies the container when the inline title's editing state changes. */
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
|
||||
/** Context menu action for the row. */
|
||||
contextAction?: ActionWithChildren;
|
||||
/** Menu content rendered by the container; wrapped in Fade. */
|
||||
menu?: React.ReactNode;
|
||||
/** Whether the menu's action slot is visible (e.g. while the menu is open). */
|
||||
menuOpen?: boolean;
|
||||
|
||||
/** When true, the "+" new-child button is rendered in the menu slot. */
|
||||
canCreateChild?: boolean;
|
||||
/** Submit handler for the inline new-child title input. */
|
||||
onCreateChild?: (title: string) => Promise<void>;
|
||||
/** Depth of the inline new-child SidebarLink. Defaults to 2. */
|
||||
newChildDepth?: number;
|
||||
|
||||
/** Ref forwarded to the outer Relative; for drag hover timers. */
|
||||
parentRef?: React.Ref<HTMLDivElement>;
|
||||
/** Drop target connector for "change collection" / reorder. */
|
||||
dropRef?: ConnectDropTarget;
|
||||
/** Whether the row is an active drop target (visual highlight). */
|
||||
isActiveDropTarget?: boolean;
|
||||
|
||||
/** Content rendered after the row (e.g. CollectionLinkChildren). */
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function CollectionRow({
|
||||
collection,
|
||||
depth = 0,
|
||||
to,
|
||||
onClick,
|
||||
onClickIntent,
|
||||
isActiveOverride,
|
||||
icon,
|
||||
expanded,
|
||||
onDisclosureClick,
|
||||
onExpand,
|
||||
canEdit,
|
||||
labelText,
|
||||
onTitleChange,
|
||||
editableTitleRef,
|
||||
onEditingChange,
|
||||
contextAction,
|
||||
menu,
|
||||
menuOpen,
|
||||
canCreateChild,
|
||||
onCreateChild,
|
||||
newChildDepth = 2,
|
||||
parentRef,
|
||||
dropRef,
|
||||
isActiveDropTarget,
|
||||
children,
|
||||
}: CollectionRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const [isEditing, setIsEditingState] = React.useState(false);
|
||||
const setIsEditing = React.useCallback(
|
||||
(editing: boolean) => {
|
||||
setIsEditingState(editing);
|
||||
onEditingChange?.(editing);
|
||||
},
|
||||
[onEditingChange]
|
||||
);
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
const newChildTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
const handleAddChild = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
onExpand?.();
|
||||
},
|
||||
[setIsAddingNewChild, onExpand]
|
||||
);
|
||||
|
||||
const handleNewChildSubmit = React.useCallback(
|
||||
async (value: string) => {
|
||||
if (!onCreateChild) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
newChildTitleRef.current?.setIsEditing(false);
|
||||
await onCreateChild(value);
|
||||
closeAddingNewChild();
|
||||
} catch (_err) {
|
||||
newChildTitleRef.current?.setIsEditing(true);
|
||||
}
|
||||
},
|
||||
[onCreateChild, closeAddingNewChild]
|
||||
);
|
||||
|
||||
const defaultIsActive = React.useCallback(
|
||||
(
|
||||
_m: match | null,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!_m && location.state?.sidebarContext === sidebarContext,
|
||||
[sidebarContext]
|
||||
);
|
||||
|
||||
const labelElement = canEdit ? (
|
||||
<EditableTitle
|
||||
title={labelText ?? collection.name}
|
||||
onSubmit={onTitleChange ?? (async () => undefined)}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canEdit}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
) : (
|
||||
collection.name
|
||||
);
|
||||
|
||||
const iconElement = icon ?? (
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
);
|
||||
|
||||
const hasMenuContent = Boolean(menu) || canCreateChild;
|
||||
const menuVisible = hasMenuContent && !isEditing;
|
||||
const menuElement = menuVisible ? (
|
||||
<Fade>
|
||||
{canCreateChild && (
|
||||
<Tooltip content={t("New doc")} delay={500}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={handleAddChild}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{menu}
|
||||
</Fade>
|
||||
) : undefined;
|
||||
|
||||
const mergedRef = React.useMemo(
|
||||
() =>
|
||||
mergeRefs<HTMLDivElement>(
|
||||
[parentRef, dropRef].filter(Boolean) as React.Ref<HTMLDivElement>[]
|
||||
),
|
||||
[parentRef, dropRef]
|
||||
);
|
||||
|
||||
const sidebarLinkElement = (
|
||||
<SidebarLink
|
||||
// @ts-expect-error react-router type is wrong, string component is fine.
|
||||
component={isEditing ? "div" : undefined}
|
||||
depth={depth}
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
onClickIntent={onClickIntent}
|
||||
contextAction={contextAction}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
icon={iconElement}
|
||||
isActive={isActiveOverride ?? defaultIsActive}
|
||||
isActiveDrop={isActiveDropTarget}
|
||||
label={labelElement}
|
||||
ellipsis={!isEditing}
|
||||
exact={false}
|
||||
$showActions={menuOpen}
|
||||
menu={menuElement}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
<Relative ref={mergedRef}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
{sidebarLinkElement}
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
{isAddingNewChild && onCreateChild && (
|
||||
<SidebarLink
|
||||
isActive={() => true}
|
||||
depth={newChildDepth}
|
||||
ellipsis={false}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewChildSubmit}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={newChildTitleRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CollectionRow);
|
||||
@@ -18,6 +18,7 @@ function Disclosure({ onClick, expanded, ...rest }: Props) {
|
||||
size={20}
|
||||
onClick={onClick}
|
||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={expanded}
|
||||
{...rest}
|
||||
>
|
||||
<StyledCollapsedIcon $expanded={expanded} size={20} />
|
||||
@@ -27,7 +28,7 @@ function Disclosure({ onClick, expanded, ...rest }: Props) {
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
inset-inline-start: -24px;
|
||||
flex-shrink: 0;
|
||||
color: ${s("textSecondary")};
|
||||
margin: 2px;
|
||||
@@ -46,7 +47,14 @@ const StyledCollapsedIcon = styled(CollapsedIcon)<{
|
||||
opacity 100ms ease,
|
||||
transform 100ms ease,
|
||||
fill 50ms !important;
|
||||
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
|
||||
|
||||
[aria-expanded="false"] & {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
[dir="rtl"] [aria-expanded="false"] & {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
`;
|
||||
|
||||
// Enables identifying this component within styled components
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import type { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import type GroupMembership from "~/models/GroupMembership";
|
||||
import type UserMembership from "~/models/UserMembership";
|
||||
import type { RefHandle } from "~/components/EditableTitle";
|
||||
import EditableTitle from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
@@ -29,17 +25,11 @@ import {
|
||||
useDropToReorderDocument,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import DocumentRow from "./DocumentRow";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import type { SidebarContextType } from "./SidebarContext";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import type UserMembership from "~/models/UserMembership";
|
||||
import type GroupMembership from "~/models/GroupMembership";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
@@ -57,20 +47,17 @@ type Props = {
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
function InnerDocumentLink(
|
||||
{
|
||||
node,
|
||||
collection,
|
||||
membership,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
isDraft,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const DocumentLink = observer(function DocumentLinkInner({
|
||||
node,
|
||||
collection,
|
||||
membership,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
isDraft,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
@@ -123,11 +110,9 @@ function InnerDocumentLink(
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
|
||||
|
||||
// Context-based recursive expand/collapse for descendant DocumentLinks
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Subscribe to recursive expand/collapse events from an ancestor
|
||||
useSidebarDisclosure(setExpanded, setCollapsed);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -136,7 +121,6 @@ function InnerDocumentLink(
|
||||
}
|
||||
}, [setExpanded, showChildren]);
|
||||
|
||||
// when the last child document is removed auto-close the local folder state
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
setCollapsed();
|
||||
@@ -144,14 +128,14 @@ function InnerDocumentLink(
|
||||
}, [setCollapsed, expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLElement>) => {
|
||||
(ev?: React.MouseEvent<HTMLElement>) => {
|
||||
const willExpand = !expanded;
|
||||
if (willExpand) {
|
||||
setExpanded();
|
||||
} else {
|
||||
setCollapsed();
|
||||
}
|
||||
onDisclosureClick(willExpand, ev.altKey);
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
},
|
||||
[setCollapsed, setExpanded, expanded, onDisclosureClick]
|
||||
);
|
||||
@@ -172,6 +156,7 @@ function InnerDocumentLink(
|
||||
},
|
||||
[documents, document]
|
||||
);
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, []);
|
||||
@@ -214,10 +199,9 @@ function InnerDocumentLink(
|
||||
const iconElement = React.useMemo(
|
||||
() =>
|
||||
icon ? <Icon value={icon} color={color} initial={initial} /> : undefined,
|
||||
[icon, color]
|
||||
[icon, color, initial]
|
||||
);
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDragDocument(
|
||||
node,
|
||||
depth,
|
||||
@@ -225,12 +209,10 @@ function InnerDocumentLink(
|
||||
isEditing
|
||||
);
|
||||
|
||||
// Drop to re-parent
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, setExpanded, parentRef);
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder: isOverReorderAbove }, dropToReorderAbove] =
|
||||
useDropToReorderDocument(node, collection, (item) => {
|
||||
if (!collection) {
|
||||
@@ -265,90 +247,62 @@ function InnerDocumentLink(
|
||||
};
|
||||
});
|
||||
|
||||
const nodeChildren = React.useMemo(() => {
|
||||
const insertDraftDocument =
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.parentDocumentId === node.id;
|
||||
const insertDraftChild = !!(
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.parentDocumentId === node.id
|
||||
);
|
||||
|
||||
return collection && insertDraftDocument
|
||||
? sortNavigationNodes(
|
||||
[activeDocument?.asNavigationNode, ...node.children],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: node.children;
|
||||
}, [
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
activeDocument?.parentDocumentId,
|
||||
activeDocument?.asNavigationNode,
|
||||
collection,
|
||||
node,
|
||||
]);
|
||||
const draftNavNode = insertDraftChild
|
||||
? activeDocument?.asNavigationNode
|
||||
: undefined;
|
||||
|
||||
const nodeChildren = React.useMemo(
|
||||
() =>
|
||||
collection && draftNavNode
|
||||
? sortNavigationNodes(
|
||||
[draftNavNode, ...node.children],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: node.children,
|
||||
[draftNavNode, collection, node]
|
||||
);
|
||||
|
||||
const doc = documents.get(node.id);
|
||||
const title = doc?.title || node.title || t("Untitled");
|
||||
|
||||
const isExpanded = expanded && !isDragging;
|
||||
const hasChildren = nodeChildren.length > 0;
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
if (!hasChildren) {
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowRight" && !expanded) {
|
||||
setExpanded();
|
||||
}
|
||||
if (ev.key === "ArrowLeft" && expanded) {
|
||||
setCollapsed();
|
||||
}
|
||||
},
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
);
|
||||
|
||||
const newChildTitleRef = React.useRef<RefHandle>(null);
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
try {
|
||||
newChildTitleRef.current?.setIsEditing(false);
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
membership?.addDocument(newDocument, node.id);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
} catch (_err) {
|
||||
newChildTitleRef.current?.setIsEditing(true);
|
||||
}
|
||||
async (input: string) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
membership?.addDocument(newDocument, node.id);
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[
|
||||
documents,
|
||||
collection,
|
||||
membership,
|
||||
sidebarContext,
|
||||
user,
|
||||
node,
|
||||
doc,
|
||||
history,
|
||||
closeAddingNewChild,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -357,132 +311,66 @@ function InnerDocumentLink(
|
||||
onRename: handleRename,
|
||||
});
|
||||
|
||||
const labelElement = React.useMemo(
|
||||
() => (
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={editableTitleRef}
|
||||
const showMenuActions = !isDraggingAnyDocument;
|
||||
const menu =
|
||||
showMenuActions && document ? (
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
),
|
||||
[title, handleTitleChange, isEditing, setIsEditing, canUpdate]
|
||||
);
|
||||
) : undefined;
|
||||
|
||||
const menuElement = React.useMemo(
|
||||
() =>
|
||||
document && !isMoving && !isEditing && !isDraggingAnyDocument ? (
|
||||
<Fade>
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
setExpanded();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined,
|
||||
[
|
||||
document,
|
||||
isMoving,
|
||||
isEditing,
|
||||
isDraggingAnyDocument,
|
||||
can.createChildDocument,
|
||||
t,
|
||||
setIsAddingNewChild,
|
||||
setExpanded,
|
||||
handleRename,
|
||||
handleMenuOpen,
|
||||
handleMenuClose,
|
||||
]
|
||||
);
|
||||
const cursorBefore =
|
||||
isDraggingAnyDocument && collection?.isManualSort && index === 0 ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorderAbove}
|
||||
innerRef={dropToReorderAbove}
|
||||
position="top"
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
const cursorAfter =
|
||||
isDraggingAnyDocument && collection?.isManualSort ? (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeModels: document ? [document] : [],
|
||||
}}
|
||||
<DocumentRow
|
||||
documentId={node.id}
|
||||
document={document}
|
||||
to={toPath}
|
||||
depth={depth}
|
||||
isDraft={isDraft}
|
||||
scrollIntoViewIfNeeded={sidebarContext === "collections"}
|
||||
icon={iconElement}
|
||||
canEdit={canUpdate}
|
||||
labelText={title}
|
||||
onTitleChange={handleTitleChange}
|
||||
editableTitleRef={editableTitleRef}
|
||||
onEditingChange={setIsEditing}
|
||||
expanded={expanded && !isDragging}
|
||||
hasChildren={hasChildren}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onExpand={setExpanded}
|
||||
onCollapse={setCollapsed}
|
||||
dragRef={drag}
|
||||
isDragging={isDragging}
|
||||
isMoving={isMoving}
|
||||
parentRef={parentRef}
|
||||
dropToReparentRef={dropToReparent}
|
||||
isActiveDropTarget={isOverReparent && canDropToReparent}
|
||||
cursorBefore={cursorBefore}
|
||||
cursorAfter={cursorAfter}
|
||||
menu={menu}
|
||||
menuOpen={menuOpen}
|
||||
canCreateChild={showMenuActions && can.createChildDocument}
|
||||
onCreateChild={handleNewDoc}
|
||||
contextAction={contextMenuAction}
|
||||
isActiveOverride={isActiveCheck}
|
||||
onClickIntent={handlePrefetch}
|
||||
>
|
||||
<Relative ref={parentRef}>
|
||||
{isDraggingAnyDocument && collection?.isManualSort && index === 0 && (
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorderAbove}
|
||||
innerRef={dropToReorderAbove}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
$isDragging={isDragging}
|
||||
$isMoving={isMoving}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id}>
|
||||
<SidebarLink
|
||||
// @ts-expect-error react-router type is wrong, string component is fine.
|
||||
component={isEditing ? "div" : undefined}
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
contextAction={contextMenuAction}
|
||||
to={toPath}
|
||||
icon={iconElement}
|
||||
label={labelElement}
|
||||
ellipsis={!isEditing}
|
||||
isActive={isActiveCheck}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
$showActions={menuOpen}
|
||||
scrollIntoViewIfNeeded={sidebarContext === "collections"}
|
||||
isDraft={isDraft}
|
||||
ref={ref}
|
||||
menu={menuElement}
|
||||
/>
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
{isDraggingAnyDocument && collection?.isManualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Relative>
|
||||
{isAddingNewChild && (
|
||||
<SidebarLink
|
||||
isActive={() => true}
|
||||
depth={depth + 1}
|
||||
ellipsis={false}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={newChildTitleRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
@@ -501,16 +389,8 @@ function InnerDocumentLink(
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</ActionContextProvider>
|
||||
</DocumentRow>
|
||||
);
|
||||
}
|
||||
|
||||
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "inherit")};
|
||||
`;
|
||||
|
||||
const DocumentLink = observer(React.forwardRef(InnerDocumentLink));
|
||||
});
|
||||
|
||||
export default DocumentLink;
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { Location, LocationDescriptor } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import type { ConnectDragSource } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { match } from "react-router";
|
||||
import styled from "styled-components";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import type Document from "~/models/Document";
|
||||
import EditableTitle, { type RefHandle } from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import DropToImport from "./DropToImport";
|
||||
import Relative from "./Relative";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import type { SidebarContextType } from "./SidebarContext";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import type { ActionWithChildren } from "~/types";
|
||||
|
||||
export type DocumentRowProps = {
|
||||
/** Document identifier for policy, prefetch and import. */
|
||||
documentId: string;
|
||||
/** Loaded document; used for editing title and active matching. */
|
||||
document?: Document;
|
||||
|
||||
/** Navigation target for the row. */
|
||||
to: LocationDescriptor;
|
||||
|
||||
/** Indentation depth of the row. */
|
||||
depth: number;
|
||||
/** Applies draft styling around the row. */
|
||||
isDraft?: boolean;
|
||||
/** Scroll this row into view when it becomes the active route. */
|
||||
scrollIntoViewIfNeeded?: boolean;
|
||||
|
||||
/** Icon displayed to the left of the label. */
|
||||
icon?: React.ReactNode;
|
||||
/** Displays a small unread badge to the right of the label. */
|
||||
unreadBadge?: boolean;
|
||||
|
||||
/** Whether inline title updates are allowed. */
|
||||
canEdit?: boolean;
|
||||
/** Static label content; when provided, it is rendered in preference to `labelText`. */
|
||||
label?: React.ReactNode;
|
||||
/** Label as a text string, for editing. */
|
||||
labelText?: string;
|
||||
/** Submit handler when title updates are allowed. */
|
||||
onTitleChange?: (value: string) => Promise<void>;
|
||||
/** Forwarded ref to the `EditableTitle` instance when it is rendered. */
|
||||
editableTitleRef?: React.Ref<RefHandle>;
|
||||
/** Notifies the container when the rendered inline title enters or exits editing mode. */
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
|
||||
/** Whether the row is expanded. */
|
||||
expanded: boolean;
|
||||
/** Whether the row has any descendants (controls whether the disclosure renders). */
|
||||
hasChildren: boolean;
|
||||
/** Called when the disclosure caret or Alt+click toggles expansion. */
|
||||
onDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void;
|
||||
/** Imperative expand, used by the "+" button and ArrowRight keydown. */
|
||||
onExpand?: () => void;
|
||||
/** Imperative collapse, used by ArrowLeft keydown. */
|
||||
onCollapse?: () => void;
|
||||
|
||||
/** Drag source ref from the container's drag hook. */
|
||||
dragRef?: ConnectDragSource;
|
||||
/** Whether the row is being dragged. */
|
||||
isDragging?: boolean;
|
||||
/** Whether the row's document is being moved. */
|
||||
isMoving?: boolean;
|
||||
|
||||
/** Ref to the outer Relative element; some drop hooks need to read it. */
|
||||
parentRef?: React.Ref<HTMLDivElement>;
|
||||
/** Ref for the row's reparent drop target. */
|
||||
dropToReparentRef?: React.Ref<HTMLDivElement>;
|
||||
/** Whether the row is an active drop target (visual highlight). */
|
||||
isActiveDropTarget?: boolean;
|
||||
|
||||
/** Cursor element rendered above the row. */
|
||||
cursorBefore?: React.ReactNode;
|
||||
/** Cursor element rendered below the row. */
|
||||
cursorAfter?: React.ReactNode;
|
||||
|
||||
/** Menu content rendered by the container. */
|
||||
menu?: React.ReactNode;
|
||||
/** Whether the menu's action slot is visible (e.g. while the menu is open). */
|
||||
menuOpen?: boolean;
|
||||
|
||||
/** When true, the "+" new-child button is rendered in the menu slot. */
|
||||
canCreateChild?: boolean;
|
||||
/** Submit handler for the inline new-child title input. */
|
||||
onCreateChild?: (title: string) => Promise<void>;
|
||||
/** Depth of the inline new-child SidebarLink. Defaults to `depth + 1`. */
|
||||
newChildDepth?: number;
|
||||
|
||||
/** Context menu action for the row. */
|
||||
contextAction?: ActionWithChildren;
|
||||
|
||||
/** Optional override for the active-match function. */
|
||||
isActiveOverride?: (
|
||||
match: match | null,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => boolean;
|
||||
|
||||
/** Content rendered after the row (e.g. a Folder of nested child rows). */
|
||||
children?: React.ReactNode;
|
||||
|
||||
/** Called on click intent for prefetching. */
|
||||
onClickIntent?: () => void;
|
||||
};
|
||||
|
||||
function DocumentRow({
|
||||
documentId,
|
||||
document,
|
||||
to,
|
||||
depth,
|
||||
isDraft,
|
||||
scrollIntoViewIfNeeded,
|
||||
icon,
|
||||
unreadBadge,
|
||||
label,
|
||||
canEdit,
|
||||
labelText,
|
||||
onTitleChange,
|
||||
editableTitleRef,
|
||||
onEditingChange,
|
||||
expanded,
|
||||
hasChildren,
|
||||
onDisclosureClick,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
dragRef,
|
||||
isDragging,
|
||||
isMoving,
|
||||
parentRef,
|
||||
dropToReparentRef,
|
||||
isActiveDropTarget,
|
||||
cursorBefore,
|
||||
cursorAfter,
|
||||
menu,
|
||||
menuOpen,
|
||||
canCreateChild,
|
||||
onCreateChild,
|
||||
newChildDepth,
|
||||
contextAction,
|
||||
isActiveOverride,
|
||||
children,
|
||||
onClickIntent,
|
||||
}: DocumentRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const [isEditing, setIsEditingState] = React.useState(false);
|
||||
const setIsEditing = React.useCallback(
|
||||
(editing: boolean) => {
|
||||
setIsEditingState(editing);
|
||||
onEditingChange?.(editing);
|
||||
},
|
||||
[onEditingChange]
|
||||
);
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
const newChildTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
if (!hasChildren) {
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowRight" && !expanded) {
|
||||
onExpand?.();
|
||||
}
|
||||
if (ev.key === "ArrowLeft" && expanded) {
|
||||
onCollapse?.();
|
||||
}
|
||||
},
|
||||
[hasChildren, expanded, onExpand, onCollapse]
|
||||
);
|
||||
|
||||
const handleAddChild = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
onExpand?.();
|
||||
},
|
||||
[setIsAddingNewChild, onExpand]
|
||||
);
|
||||
|
||||
const handleNewChildSubmit = React.useCallback(
|
||||
async (value: string) => {
|
||||
if (!onCreateChild) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
newChildTitleRef.current?.setIsEditing(false);
|
||||
await onCreateChild(value);
|
||||
closeAddingNewChild();
|
||||
} catch (_err) {
|
||||
newChildTitleRef.current?.setIsEditing(true);
|
||||
}
|
||||
},
|
||||
[onCreateChild, closeAddingNewChild]
|
||||
);
|
||||
|
||||
const labelElement =
|
||||
label ??
|
||||
(labelText !== undefined ? (
|
||||
<EditableTitle
|
||||
title={labelText}
|
||||
onSubmit={onTitleChange ?? (async () => undefined)}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={!!canEdit}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
) : null);
|
||||
|
||||
const hasMenuContent = Boolean(menu) || canCreateChild;
|
||||
const menuVisible = hasMenuContent && !isEditing && !isDragging && !isMoving;
|
||||
const menuElement = menuVisible ? (
|
||||
<Fade>
|
||||
{canCreateChild && (
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={handleAddChild}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{menu}
|
||||
</Fade>
|
||||
) : undefined;
|
||||
|
||||
const defaultIsActive = React.useCallback(
|
||||
(
|
||||
m: match | null,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => {
|
||||
if (sidebarContext !== location.state?.sidebarContext) {
|
||||
return false;
|
||||
}
|
||||
return (document && location.pathname.endsWith(document.urlId)) || !!m;
|
||||
},
|
||||
[sidebarContext, document]
|
||||
);
|
||||
|
||||
const sidebarLinkElement = (
|
||||
<SidebarLink
|
||||
// @ts-expect-error react-router type is wrong, string component is fine.
|
||||
component={isEditing ? "div" : undefined}
|
||||
depth={depth}
|
||||
to={to}
|
||||
expanded={hasChildren && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={onClickIntent}
|
||||
contextAction={contextAction}
|
||||
icon={icon}
|
||||
isActive={isActiveOverride ?? defaultIsActive}
|
||||
isActiveDrop={isActiveDropTarget}
|
||||
label={labelElement}
|
||||
ellipsis={!isEditing}
|
||||
exact={false}
|
||||
scrollIntoViewIfNeeded={scrollIntoViewIfNeeded}
|
||||
isDraft={isDraft}
|
||||
unreadBadge={unreadBadge}
|
||||
$showActions={menuOpen}
|
||||
menu={menuElement}
|
||||
/>
|
||||
);
|
||||
|
||||
const withImport = documentId ? (
|
||||
<DropToImport documentId={documentId}>{sidebarLinkElement}</DropToImport>
|
||||
) : (
|
||||
sidebarLinkElement
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeModels: document ? [document] : [],
|
||||
}}
|
||||
>
|
||||
<Relative ref={parentRef}>
|
||||
{cursorBefore}
|
||||
<Draggable
|
||||
key={documentId}
|
||||
ref={dragRef}
|
||||
$isDragging={isDragging}
|
||||
$isMoving={isMoving}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{dropToReparentRef ? (
|
||||
<div ref={dropToReparentRef}>{withImport}</div>
|
||||
) : (
|
||||
withImport
|
||||
)}
|
||||
</Draggable>
|
||||
{cursorAfter}
|
||||
</Relative>
|
||||
{isAddingNewChild && onCreateChild && (
|
||||
<SidebarLink
|
||||
isActive={() => true}
|
||||
depth={newChildDepth ?? depth + 1}
|
||||
ellipsis={false}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewChildSubmit}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={newChildTitleRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "inherit")};
|
||||
`;
|
||||
|
||||
export default observer(DocumentRow);
|
||||
@@ -75,7 +75,8 @@ const Button = styled.button`
|
||||
position: relative;
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0;
|
||||
padding: 4px 2px 4px 12px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px 2px;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
@@ -98,6 +99,10 @@ const Disclosure = styled(CollapsedIcon)<{ $expanded?: boolean }>`
|
||||
fill 50ms !important;
|
||||
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
|
||||
opacity: 0;
|
||||
|
||||
[dir="rtl"] & {
|
||||
${(props) => !props.$expanded && "transform: rotate(90deg);"};
|
||||
}
|
||||
`;
|
||||
|
||||
const H3 = styled.h3`
|
||||
|
||||
@@ -1,82 +1,138 @@
|
||||
import { ArrowIcon } from "outline-icons";
|
||||
import { ArrowIcon, ClockIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import { createActionGroup } from "~/actions";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useRecentDocumentActions from "~/components/CommandBar/useRecentDocumentActions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
const RECENT_DOCUMENTS_LIMIT = 10;
|
||||
|
||||
function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
const { t } = useTranslation();
|
||||
const [back, setBack] = React.useState(false);
|
||||
const [forward, setForward] = React.useState(false);
|
||||
const { documents } = useStores();
|
||||
const [canGoBack, setCanGoBack] = React.useState(false);
|
||||
const [canGoForward, setCanGoForward] = React.useState(false);
|
||||
const [supported, setSupported] = React.useState(false);
|
||||
|
||||
useKeyDown(
|
||||
(event) =>
|
||||
isMac
|
||||
? event.metaKey && event.key === "["
|
||||
: event.altKey && event.key === "ArrowLeft",
|
||||
() => {
|
||||
setBack(true);
|
||||
setTimeout(() => setBack(false), 100);
|
||||
}
|
||||
const recentActions = useRecentDocumentActions(RECENT_DOCUMENTS_LIMIT);
|
||||
const menuActions = React.useMemo(
|
||||
() => [
|
||||
createActionGroup({
|
||||
name: t("Recent"),
|
||||
actions: recentActions,
|
||||
}),
|
||||
],
|
||||
[t, recentActions]
|
||||
);
|
||||
const menuAction = useMenuAction(menuActions);
|
||||
|
||||
useKeyDown(
|
||||
(event) =>
|
||||
isMac
|
||||
? event.metaKey && event.key === "]"
|
||||
: event.altKey && event.key === "ArrowRight",
|
||||
() => {
|
||||
setForward(true);
|
||||
setTimeout(() => setForward(false), 100);
|
||||
const handleOpen = React.useCallback(() => {
|
||||
void documents.fetchRecentlyViewed({ limit: RECENT_DOCUMENTS_LIMIT });
|
||||
}, [documents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!(Desktop.bridge && "onNavigationStateChanged" in Desktop.bridge)) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
setSupported(true);
|
||||
return Desktop.bridge.onNavigationStateChanged((state) => {
|
||||
setCanGoBack(state.canGoBack);
|
||||
setCanGoForward(state.canGoForward);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!Desktop.isMacApp()) {
|
||||
if (!Desktop.isMacApp() || !supported) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Navigation gap={4} {...props}>
|
||||
<Tooltip content={t("Go back")}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goBack()}>
|
||||
<Back $active={back} />
|
||||
<Tooltip content={t("Go back")} disabled={!canGoBack}>
|
||||
<NudeButton
|
||||
aria-label={t("Go back")}
|
||||
disabled={!canGoBack}
|
||||
onClick={() => Desktop.bridge?.goBack()}
|
||||
>
|
||||
<Back $enabled={canGoBack} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Go forward")}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goForward()}>
|
||||
<Forward $active={forward} />
|
||||
<Tooltip content={t("Go forward")} disabled={!canGoForward}>
|
||||
<NudeButton
|
||||
aria-label={t("Go forward")}
|
||||
disabled={!canGoForward}
|
||||
onClick={() => Desktop.bridge?.goForward()}
|
||||
>
|
||||
<Forward $enabled={canGoForward} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("History")}>
|
||||
<DropdownMenu
|
||||
action={menuAction}
|
||||
ariaLabel={t("History")}
|
||||
onOpen={handleOpen}
|
||||
>
|
||||
<NudeButton aria-label={t("History")}>
|
||||
<StyledClockIcon />
|
||||
</NudeButton>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</Navigation>
|
||||
);
|
||||
}
|
||||
|
||||
const Navigation = styled(Flex)`
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
inset-inline-end: 12px;
|
||||
top: 14px;
|
||||
|
||||
button {
|
||||
cursor: default;
|
||||
}
|
||||
`;
|
||||
|
||||
const Forward = styled(ArrowIcon)<{ $active: boolean }>`
|
||||
const Forward = styled(ArrowIcon)<{ $enabled: boolean }>`
|
||||
color: ${s("textTertiary")};
|
||||
opacity: ${(props) => (props.$active ? 1 : 0.5)};
|
||||
opacity: ${(props) => (props.$enabled ? 0.5 : 0.15)};
|
||||
transition: color 100ms ease-in-out;
|
||||
|
||||
&:active,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
opacity: ${(props) => (props.$enabled ? 1 : 0.15)};
|
||||
}
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
`;
|
||||
|
||||
const Back = styled(Forward)`
|
||||
transform: rotate(180deg);
|
||||
flex-shrink: 0;
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
`;
|
||||
|
||||
export default HistoryNavigation;
|
||||
const StyledClockIcon = styled(ClockIcon)`
|
||||
color: ${s("textTertiary")};
|
||||
opacity: 0.5;
|
||||
transition: color 100ms ease-in-out;
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
[data-state="open"] & {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(HistoryNavigation);
|
||||
|
||||
@@ -141,10 +141,6 @@ const NavLink = ({
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event);
|
||||
|
||||
if (isActive && !event.defaultPrevented) {
|
||||
onActiveClick?.(event);
|
||||
}
|
||||
|
||||
if (shouldFastClick(event)) {
|
||||
event.currentTarget.focus();
|
||||
|
||||
@@ -157,7 +153,7 @@ const NavLink = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[onClick, navigateTo, isActive, shouldFastClick]
|
||||
[onClick, navigateTo, shouldFastClick]
|
||||
);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
@@ -170,8 +166,15 @@ const NavLink = ({
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Fire onActiveClick on click rather than mousedown so that the native
|
||||
// HTML5 drag gesture can initiate from an active row without being
|
||||
// blocked by a preventDefault on mousedown.
|
||||
if (isActive) {
|
||||
onActiveClick?.(event);
|
||||
}
|
||||
},
|
||||
[isActive]
|
||||
[isActive, onActiveClick]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user