mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25af007111 | |||
| 5c46bd13ed | |||
| e51f93f9a0 | |||
| d4fe240808 | |||
| 61c76e62ef | |||
| 499392c114 | |||
| af0651f243 | |||
| 7a54d5bb84 | |||
| cb4a978a87 | |||
| 66a5055e73 | |||
| a9ddbde02c | |||
| 6b25000adb | |||
| 0fb8971628 | |||
| 9f277a8f7b | |||
| 97e91eb06b | |||
| a87bda82b1 | |||
| 36a92d5393 | |||
| 50f9f414bf | |||
| 0d6026c21f | |||
| eea0e28630 | |||
| 4ad58b4ccd | |||
| 17f4dc58f1 | |||
| fe839acaeb | |||
| 210aefaf8f | |||
| 90f7a4272e | |||
| 978773ee27 | |||
| 59abc3355c | |||
| 72aa4cde3f | |||
| 42dfe7027f | |||
| 3d4299bc60 | |||
| 6a1f2399db | |||
| ae2fafac0e | |||
| 0efe6393a3 | |||
| 2e3c19ff88 | |||
| 87fcf35956 | |||
| 540683d896 | |||
| a8cbdf061d | |||
| 171433e984 | |||
| f61046ec22 | |||
| 78ff3af801 | |||
| 83da38afd5 | |||
| b2da166dd6 | |||
| 33c7560b3d | |||
| db78fb7111 | |||
| cbca7f60fe | |||
| c89589e86c | |||
| 878a27b7c6 | |||
| a05c965be2 | |||
| 2116041cd5 | |||
| 7144536eb3 | |||
| 4cd2ee6291 | |||
| 1749ffe20d | |||
| b9c6f9c9e6 | |||
| 14777145e9 | |||
| c6ae6e0c36 | |||
| 84542874c4 | |||
| e90a86737f | |||
| 4373dad309 | |||
| 839ce889ad | |||
| 27322d62f8 | |||
| a9b41b3f17 | |||
| f46921275d | |||
| 433c3b299d | |||
| c44872b4cc | |||
| 5132c5814b | |||
| acc825b554 | |||
| bef4292146 | |||
| 0b13698998 | |||
| ac45e3c0db | |||
| 6a633f5a4c | |||
| 67c114e6ed | |||
| 483fe95856 | |||
| 06f1f0431f | |||
| ca38523d9b | |||
| c725302701 | |||
| 7bc687b6bf | |||
| bb397b8625 | |||
| edd413fba3 | |||
| 3f8fb66be1 | |||
| 4b379a4dc4 | |||
| 0bcff545e7 | |||
| 93e8cbb541 | |||
| 9e8b4a3269 | |||
| d48386797e | |||
| 898e11b424 | |||
| ac48767132 | |||
| 854fbca420 | |||
| 82539cc348 | |||
| 027522350f | |||
| eb92a206fb | |||
| dfc3c05c40 | |||
| 59fa91413d | |||
| 205ca03ced | |||
| 0432144d1e | |||
| c81802b3bb | |||
| 6ecf9ca9c3 | |||
| b788b95880 | |||
| ff0bebaf63 | |||
| f53c2828ef | |||
| 926a4e2224 | |||
| 12efdf4e50 | |||
| 49b2fad6ce | |||
| 2edd48ab84 | |||
| 146cf56bce | |||
| d37e21645c | |||
| fe2c9b5817 | |||
| ec86e80edb | |||
| fa19b278a4 | |||
| 6a4b99ca43 | |||
| 9bf8c5c633 | |||
| fe3e712555 | |||
| 6e85e99f78 | |||
| 0e07d06a91 | |||
| cc38c4fedb | |||
| 749b9cc6b8 | |||
| be4ce4ba2e | |||
| 7afcce47ae | |||
| 7eb2bc9a16 | |||
| 67adb66c8b | |||
| 247a50be62 | |||
| 225449796a | |||
| 7e1adab035 | |||
| aca6f55ea0 | |||
| ce51fa9957 | |||
| 676e89a58e | |||
| c1d4a8e373 | |||
| 7801bcb8e7 | |||
| d4cdf4288e | |||
| efcea0a7f2 | |||
| 5004281077 | |||
| 2443be9329 | |||
| 6f49cb62c3 | |||
| 6f50ea1d60 | |||
| 52679db853 | |||
| 9a94e2dcf2 | |||
| c990ace2e2 | |||
| 7a7912b07e | |||
| 05a7627148 | |||
| 5ba613ac27 | |||
| c717e8e3eb | |||
| 144d83e68c | |||
| abd6518854 | |||
| 9c12498162 | |||
| aa879d8fab | |||
| 28aebc9fbf | |||
| abaeba5952 | |||
| b666d8f13d | |||
| 8e4844fd84 | |||
| 15892a9364 | |||
| 23a89c4d7b | |||
| f1c5b145a4 | |||
| 4c7b36dfca | |||
| e1d0d4717c | |||
| e3f836c22b | |||
| e9602ada24 | |||
| 0ff4bed18f | |||
| 6b49d91f2f | |||
| 77f0572445 | |||
| 5b11a0cc16 | |||
| dfe97bee50 | |||
| 500730b243 | |||
| ec6ed809a4 | |||
| 08385b8a9e | |||
| 9929020b44 | |||
| 48a330347f | |||
| 5b6bebc308 | |||
| c831c71c51 | |||
| 90350e82fe | |||
| b7bbaac2eb | |||
| 5a45b95a48 | |||
| 9deb9268b5 | |||
| 53f4c724bb | |||
| 184e56264c | |||
| ffa7043cf0 | |||
| ff3c157554 | |||
| 13f23d19fc | |||
| b527048b76 | |||
| e1b0cfb6a0 | |||
| 2205b9ee87 | |||
| 1122f030a9 | |||
| 4cc0beb90d | |||
| 16084322ca |
@@ -1,183 +0,0 @@
|
||||
version: 2.1
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
resource_class: large
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
|
||||
URL: http://localhost:3000
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
|
||||
executors:
|
||||
docker-publisher:
|
||||
environment:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:stretch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: install-deps
|
||||
command: yarn install --frozen-lockfile
|
||||
- save_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
paths:
|
||||
- ./node_modules
|
||||
lint:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
types:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: typescript
|
||||
command: yarn tsc
|
||||
test-app:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:app
|
||||
test-shared:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:shared
|
||||
test-server:
|
||||
<<: *defaults
|
||||
parallelism: 3
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: circle_test
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: migrate
|
||||
command: ./node_modules/.bin/sequelize db:migrate
|
||||
- run:
|
||||
name: test
|
||||
command: |
|
||||
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: build-vite
|
||||
command: yarn vite:build
|
||||
- run:
|
||||
name: Send bundle stats to RelativeCI
|
||||
command: npx relative-ci-agent
|
||||
build-image:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- checkout
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Install Docker buildx
|
||||
command: |
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
|
||||
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
|
||||
chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
- run:
|
||||
name: Enable Docker buildx
|
||||
command: export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
- run:
|
||||
name: Initialize Docker buildx
|
||||
command: |
|
||||
docker buildx install
|
||||
docker context create docker-multiarch
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
|
||||
docker buildx inspect --builder docker-multiarch --bootstrap
|
||||
docker buildx use docker-multiarch
|
||||
- run:
|
||||
name: Build base image
|
||||
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
|
||||
- run:
|
||||
name: Login to Docker Hub
|
||||
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
||||
- run:
|
||||
name: Publish base Docker Image to Docker Hub
|
||||
command: docker push $BASE_IMAGE_NAME:latest
|
||||
- run:
|
||||
name: Build and push Docker image
|
||||
command: |
|
||||
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
|
||||
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
else
|
||||
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
fi
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
all:
|
||||
jobs:
|
||||
- build
|
||||
- lint:
|
||||
requires:
|
||||
- build
|
||||
- test-server:
|
||||
requires:
|
||||
- build
|
||||
- test-shared:
|
||||
requires:
|
||||
- build
|
||||
- test-app:
|
||||
requires:
|
||||
- build
|
||||
- types:
|
||||
requires:
|
||||
- build
|
||||
- bundle-size:
|
||||
requires:
|
||||
- build
|
||||
- types
|
||||
|
||||
build-docker:
|
||||
jobs:
|
||||
- build-image:
|
||||
filters:
|
||||
tags:
|
||||
only: /^v.*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
curl --user ${CIRCLE_TOKEN}: \
|
||||
--request POST \
|
||||
--form revision=<ENTER COMMIT SHA HERE>\
|
||||
--form config=@config.yml \
|
||||
--form notify=false \
|
||||
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
|
||||
@@ -1,5 +1,8 @@
|
||||
URL=https://local.outline.dev:3000
|
||||
|
||||
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
SMTP_FROM_EMAIL=hello@example.com
|
||||
|
||||
# Enable unsafe-inline in script-src CSP directive
|
||||
|
||||
+2
-2
@@ -12,14 +12,14 @@ UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# For production point these at your databases, in development the default
|
||||
# should work out of the box.
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/outline
|
||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
||||
DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# For redis you can either specify an ioredis compatible url like this
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_URL=redis://redis:6379
|
||||
# or alternatively, if you would like to provide additional connection options,
|
||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||
# for a list of available options.
|
||||
|
||||
@@ -13,3 +13,16 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
fortawesome:
|
||||
patterns:
|
||||
- "@fortawesome/*"
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
|
||||
REDIS_URL: redis://127.0.0.1:6379
|
||||
URL: http://localhost:3000
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
UTILS_SECRET: 123456
|
||||
SLACK_VERIFICATION_TOKEN: 123456
|
||||
SMTP_USERNAME: localhost
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
lint:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn lint
|
||||
|
||||
types:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
app:
|
||||
- 'app/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: build
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.server == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14.2
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: outline_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: redis:5.0
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: |
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- name: Set environment to production
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
push: true
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push main image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
@@ -24,6 +24,6 @@ jobs:
|
||||
operations-per-run: 60
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: "security,pinned"
|
||||
exempt-issue-labels: "security,pinned,A1"
|
||||
- name: Print outputs
|
||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||
|
||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.81.0
|
||||
The Licensed Work is (c) 2024 General Outline, Inc.
|
||||
Licensed Work: Outline 0.82.0
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
Service.
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2028-11-11
|
||||
Change Date: 2029-02-15
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
SubscribeIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
UnsubscribeIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
@@ -60,7 +61,7 @@ export const createCollection = createAction({
|
||||
keywords: "create",
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
|
||||
perform: ({ t, event }) => {
|
||||
perform: ({ t, event, stores }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
stores.dialogs.openModal({
|
||||
@@ -76,10 +77,10 @@ export const editCollection = createAction({
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -102,10 +103,10 @@ export const editCollectionPermissions = createAction({
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -133,7 +134,7 @@ export const searchInCollection = createAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -158,7 +159,7 @@ export const starCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -168,7 +169,7 @@ export const starCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -185,7 +186,7 @@ export const unstarCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -195,7 +196,7 @@ export const unstarCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -205,6 +206,66 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeCollection = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).subscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.subscribe();
|
||||
|
||||
toast.success(t("Subscribed to document notifications"));
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeCollection = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
|
||||
if (!activeCollectionId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.unsubscribe();
|
||||
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
@@ -277,13 +338,13 @@ export const deleteCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, t }) => {
|
||||
perform: ({ activeCollectionId, t, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -311,7 +372,7 @@ export const createTemplate = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
@@ -331,5 +392,7 @@ export const rootCollectionActions = [
|
||||
createCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
deleteCollection,
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import copy from "copy-to-clipboard";
|
||||
import {
|
||||
BeakerIcon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
ToolsIcon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
@@ -83,6 +84,38 @@ export const copyId = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
function generateRandomText() {
|
||||
const characters =
|
||||
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
|
||||
let text = "";
|
||||
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
|
||||
text += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export const startTyping = createAction({
|
||||
name: "Start automatic typing",
|
||||
icon: <EditIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId && env.ENVIRONMENT === "development",
|
||||
perform: () => {
|
||||
const intervalId = setInterval(() => {
|
||||
const text = generateRandomText();
|
||||
document.execCommand("insertText", false, text);
|
||||
}, 250);
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
intervalId && clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
toast.info("Automatic typing started, press Escape to stop");
|
||||
},
|
||||
});
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Clear IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
@@ -169,6 +202,7 @@ export const developer = createAction({
|
||||
createToast,
|
||||
createTestUsers,
|
||||
clearIndexedDB,
|
||||
startTyping,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -125,6 +125,20 @@ export const createDocument = createAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDraftDocument = createAction({
|
||||
name: ({ t }) => t("New draft"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create document",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
perform: ({ sidebarContext }) =>
|
||||
history.push(newDocumentPath(), {
|
||||
sidebarContext,
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
@@ -319,6 +333,7 @@ export const subscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!document?.collection?.isSubscribed &&
|
||||
!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
);
|
||||
@@ -347,8 +362,9 @@ export const unsubscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe
|
||||
!!document?.collection?.isSubscribed ||
|
||||
(!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe)
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -358,7 +374,7 @@ export const unsubscribeDocument = createAction({
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.unsubscribe(currentUserId);
|
||||
await document?.unsubscribe();
|
||||
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
@@ -1179,6 +1195,8 @@ export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
|
||||
@@ -7,9 +7,10 @@ export enum AvatarSize {
|
||||
Small = 16,
|
||||
Toast = 18,
|
||||
Medium = 24,
|
||||
Large = 32,
|
||||
XLarge = 48,
|
||||
XXLarge = 64,
|
||||
Large = 28,
|
||||
XLarge = 32,
|
||||
XXLarge = 48,
|
||||
Upload = 64,
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
@@ -20,36 +21,37 @@ export interface IAvatar {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** The size of the avatar */
|
||||
size: AvatarSize;
|
||||
/** The source of the avatar image, if not passing a model. */
|
||||
src?: string;
|
||||
/** The avatar model, if not passing a source. */
|
||||
model?: IAvatar;
|
||||
/** The alt text for the image */
|
||||
alt?: string;
|
||||
showBorder?: boolean;
|
||||
/** Optional click handler */
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Optional class name */
|
||||
className?: string;
|
||||
/** Optional style */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { showBorder, model, style, ...rest } = props;
|
||||
const { model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative style={style}>
|
||||
{src && !error ? (
|
||||
<CircleImg
|
||||
onError={handleError}
|
||||
src={src}
|
||||
$showBorder={showBorder}
|
||||
{...rest}
|
||||
/>
|
||||
<CircleImg onError={handleError} src={src} {...rest} />
|
||||
) : model ? (
|
||||
<Initials color={model.color} $showBorder={showBorder} {...rest}>
|
||||
<Initials color={model.color} {...rest}>
|
||||
{model.initial}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
<Initials {...rest} />
|
||||
)}
|
||||
</Relative>
|
||||
);
|
||||
@@ -65,15 +67,11 @@ const Relative = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
const CircleImg = styled.img<{ size: number }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: ${(props) =>
|
||||
props.$showBorder === false
|
||||
? "none"
|
||||
: `2px solid ${props.theme.background}`};
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
@@ -5,7 +5,7 @@ import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -14,6 +14,8 @@ type Props = {
|
||||
isObserving: boolean;
|
||||
isCurrentUser: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
size?: AvatarSize;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function AvatarWithPresence({
|
||||
@@ -23,6 +25,8 @@ function AvatarWithPresence({
|
||||
isEditing,
|
||||
isObserving,
|
||||
isCurrentUser,
|
||||
size = AvatarSize.Large,
|
||||
style,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const status = isPresent
|
||||
@@ -47,13 +51,14 @@ function AvatarWithPresence({
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<AvatarWrapper
|
||||
<AvatarPresence
|
||||
$isPresent={isPresent}
|
||||
$isObserving={isObserving}
|
||||
$color={user.color}
|
||||
style={style}
|
||||
>
|
||||
<Avatar model={user} onClick={onClick} size={32} />
|
||||
</AvatarWrapper>
|
||||
<Avatar model={user} onClick={onClick} size={size} />
|
||||
</AvatarPresence>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
@@ -69,7 +74,7 @@ type AvatarWrapperProps = {
|
||||
$color: string;
|
||||
};
|
||||
|
||||
const AvatarWrapper = styled.div<AvatarWrapperProps>`
|
||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { getLuminance } from "polished";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
const Initials = styled(Flex)<{
|
||||
/** The color of the background, defaults to textTertiary. */
|
||||
color?: string;
|
||||
/** Content is only used to calculate font size, use children to render. */
|
||||
content?: string;
|
||||
/** The size of the avatar */
|
||||
size: number;
|
||||
$showBorder?: boolean;
|
||||
}>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${s("white75")};
|
||||
background-color: ${(props) => props.color};
|
||||
color: ${(props) =>
|
||||
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
|
||||
? s("black50")
|
||||
: s("white75")};
|
||||
background-color: ${(props) => props.color ?? props.theme.textTertiary};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
flex-shrink: 0;
|
||||
font-size: ${(props) => props.size / 2}px;
|
||||
|
||||
// adjust font size down for each additional character
|
||||
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarWithPresence } from "~/components/Avatar";
|
||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -78,49 +78,56 @@ function Collaborators(props: Props) {
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const renderAvatar = React.useCallback(
|
||||
({ model: collaborator, ...rest }) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== currentUserId;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[presentIds, ui, currentUserId, editingIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(popoverProps) => (
|
||||
<NudeButton
|
||||
width={Math.min(collaborators.length, limit) * 32}
|
||||
height={32}
|
||||
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
|
||||
height={AvatarSize.Large}
|
||||
{...popoverProps}
|
||||
>
|
||||
<Facepile
|
||||
size={AvatarSize.Large}
|
||||
limit={limit}
|
||||
overflow={collaborators.length - limit}
|
||||
overflow={Math.max(0, collaborators.length - limit)}
|
||||
users={collaborators}
|
||||
renderAvatar={(collaborator) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== user.id;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderAvatar={renderAvatar}
|
||||
/>
|
||||
</NudeButton>
|
||||
)}
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Text from "./Text";
|
||||
|
||||
const extensions = [
|
||||
...richExtensions,
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
HoverPreviewsExtension,
|
||||
];
|
||||
const extensions = withUIExtensions(richExtensions);
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -33,33 +24,8 @@ type Props = {
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleStopEditing = React.useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
const handleClickDisclosure = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isExpanded && document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
setExpanded(!isExpanded);
|
||||
},
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = React.useMemo(
|
||||
() =>
|
||||
debounce(async (getValue) => {
|
||||
@@ -67,7 +33,6 @@ function CollectionDescription({ collection }: Props) {
|
||||
await collection.save({
|
||||
data: getValue(false),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
toast.error(t("Sorry, an error occurred saving the collection"));
|
||||
throw err;
|
||||
@@ -76,162 +41,44 @@ function CollectionDescription({ collection }: Props) {
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (getValue) => {
|
||||
setDirty(true);
|
||||
await handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
const childRef = React.useRef<HTMLDivElement>(null);
|
||||
const childOffsetHeight = childRef.current?.offsetHeight || 0;
|
||||
const editorStyle = React.useMemo(
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditing(false);
|
||||
}, [collection.id]);
|
||||
const placeholder = `${t("Add a description")}…`;
|
||||
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
|
||||
|
||||
return (
|
||||
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<Input data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<span onClick={can.update ? handleStartEditing : undefined}>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{collection.hasDescription || isEditing || isDirty ? (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
Loading…
|
||||
</Placeholder>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
key={key}
|
||||
defaultValue={collection.data}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={!isEditing}
|
||||
autoFocus={isEditing}
|
||||
onBlur={handleStopEditing}
|
||||
extensions={extensions}
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
canUpdate
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
can.update && (
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</Placeholder>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</Input>
|
||||
{!isEditing && (
|
||||
<Disclosure
|
||||
onClick={handleClickDisclosure}
|
||||
aria-label={isExpanded ? t("Collapse") : t("Expand")}
|
||||
size={30}
|
||||
>
|
||||
<Arrow />
|
||||
</Disclosure>
|
||||
<>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{(collection.hasDescription || can.update) && (
|
||||
<React.Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<Editor
|
||||
defaultValue={collection.data}
|
||||
onChange={handleSave}
|
||||
placeholder={`${t("Add a description")}…`}
|
||||
extensions={extensions}
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update}
|
||||
editorStyle={editorStyle}
|
||||
embedsDisabled
|
||||
/>
|
||||
<div ref={childRef} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
</MaxHeight>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Disclosure = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("divider")};
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
transform: rotate(-90deg) translateX(-50%);
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${s("sidebarText")};
|
||||
}
|
||||
`;
|
||||
|
||||
const Placeholder = styled(ButtonLink)`
|
||||
const Placeholder = styled(Text)`
|
||||
color: ${s("placeholder")};
|
||||
cursor: text;
|
||||
min-height: 27px;
|
||||
`;
|
||||
|
||||
const MaxHeight = styled.div`
|
||||
position: relative;
|
||||
max-height: 25vh;
|
||||
overflow: hidden;
|
||||
margin: 8px -8px -8px;
|
||||
padding: 8px;
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
max-height: initial;
|
||||
overflow: initial;
|
||||
|
||||
${Disclosure} {
|
||||
top: initial;
|
||||
bottom: 0;
|
||||
transform: rotate(90deg) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover ${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${s("background")} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
&:after {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${s("backgroundSecondary")};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
.heading-anchor {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CollectionDescription);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AuthorizationError } from "~/utils/errors";
|
||||
|
||||
type Props = {
|
||||
/** The navigation node to move, must represent a document. */
|
||||
@@ -30,12 +32,29 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
dialogs.closeAllModals();
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
{
|
||||
documentName: item.title,
|
||||
collectionName: collection.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
} finally {
|
||||
dialogs.closeAllModals();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,7 @@ type Props = {
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MenuHeading,
|
||||
MenuItem as TMenuItem,
|
||||
} from "~/types";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import MouseSafeArea from "./MouseSafeArea";
|
||||
@@ -167,7 +168,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
}
|
||||
|
||||
if (item.type === "button") {
|
||||
return (
|
||||
const menuItem = (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
@@ -182,6 +183,14 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return item.tooltip ? (
|
||||
<Tooltip content={item.tooltip} placement={"bottom"}>
|
||||
<div>{menuItem}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{menuItem}</>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
|
||||
@@ -105,14 +105,15 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
path.slice(0, -1).forEach((node: NavigationNode) => {
|
||||
const title = node.title || t("Untitled");
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {node.title}
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
</>
|
||||
) : (
|
||||
node.title
|
||||
title
|
||||
),
|
||||
to: {
|
||||
pathname: node.url,
|
||||
@@ -121,7 +122,7 @@ function DocumentBreadcrumb(
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [path, category, sidebarContext, collectionNode]);
|
||||
}, [t, path, category, sidebarContext, collectionNode]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
@@ -134,7 +135,7 @@ function DocumentBreadcrumb(
|
||||
{path.slice(0, -1).map((node: NavigationNode) => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SmallSlash />
|
||||
{node.title}
|
||||
{node.title || t("Untitled")}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -18,6 +19,7 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
@@ -70,6 +72,10 @@ function DocumentCard(props: Props) {
|
||||
[pin]
|
||||
);
|
||||
|
||||
// If the document was updated within the last 7 days, show a timestamp instead of reading time
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -142,8 +148,14 @@ function DocumentCard(props: Props) {
|
||||
: document.titleWithDefault}
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
)}
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
</Content>
|
||||
@@ -164,6 +176,21 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
@@ -78,6 +78,10 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const recentlyViewedItemIds = documents.recentlyViewed
|
||||
.slice(0, 5)
|
||||
.map((item) => item.id);
|
||||
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(items, ["title"], {
|
||||
@@ -126,11 +130,18 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.filter((item) => item.type === "collection")
|
||||
.filter((item) => recentlyViewedItemIds.includes(item.id))
|
||||
.concat(
|
||||
items.filter((item) => item.type === NavigationNodeType.Collection)
|
||||
)
|
||||
.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -304,7 +315,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
expanded={isExpanded(index)}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
depth={(node.depth ?? 0) - baseDepth}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
|
||||
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const OFFSET = 12;
|
||||
const ICON_SIZE = 24;
|
||||
const DISCLOSURE = 20;
|
||||
|
||||
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
|
||||
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -71,7 +71,13 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
subtitle={subtitle}
|
||||
image={<Avatar key={model.id} model={model} size={32} />}
|
||||
image={
|
||||
<Avatar
|
||||
key={model.id}
|
||||
model={model}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
|
||||
+16
-13
@@ -17,7 +17,7 @@ import useDictionary from "~/hooks/useDictionary";
|
||||
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
@@ -49,11 +49,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
async (file: File | string) => {
|
||||
const options = {
|
||||
documentId: id,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
});
|
||||
};
|
||||
const result =
|
||||
file instanceof File
|
||||
? await uploadFile(file, options)
|
||||
: await uploadFileFromUrl(file, options);
|
||||
return result.url;
|
||||
},
|
||||
[id]
|
||||
@@ -195,15 +199,14 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.editorStyle?.paddingBottom &&
|
||||
(!props.readOnly || props.shareId) && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
UnpublishIcon,
|
||||
RestoreIcon,
|
||||
UserIcon,
|
||||
CrossIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,32 +17,61 @@ import { useLocation } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Item, { Actions } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
import Text from "./Text";
|
||||
|
||||
export type RevisionEvent = {
|
||||
name: "revisions.create";
|
||||
latest: boolean;
|
||||
};
|
||||
|
||||
export type DocumentEvent = {
|
||||
name:
|
||||
| "documents.publish"
|
||||
| "documents.unpublish"
|
||||
| "documents.archive"
|
||||
| "documents.unarchive"
|
||||
| "documents.delete"
|
||||
| "documents.restore"
|
||||
| "documents.add_user"
|
||||
| "documents.remove_user"
|
||||
| "documents.move";
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type Event = { id: string; actorId: string; createdAt: string } & (
|
||||
| RevisionEvent
|
||||
| DocumentEvent
|
||||
);
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
event: Event<Document>;
|
||||
latest?: boolean;
|
||||
event: Event;
|
||||
};
|
||||
|
||||
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { revisions } = useStores();
|
||||
const { revisions, users } = useStores();
|
||||
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
|
||||
const user = "userId" in event ? users.get(event.userId) : undefined;
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const revisionLoadedRef = React.useRef(false);
|
||||
const opts = {
|
||||
userName: event.actor.name,
|
||||
userName: actor?.name,
|
||||
};
|
||||
const isRevision = event.name === "revisions.create";
|
||||
const isDerivedFromDocument =
|
||||
event.id === RevisionHelper.latestId(document.id);
|
||||
let meta, icon, to: LocationDescriptor | undefined;
|
||||
|
||||
const ref = React.useRef<HTMLAnchorElement>(null);
|
||||
@@ -50,23 +82,32 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
};
|
||||
|
||||
const prefetchRevision = async () => {
|
||||
if (event.name === "revisions.create" && event.modelId) {
|
||||
await revisions.fetch(event.modelId);
|
||||
if (
|
||||
!document.isDeleted &&
|
||||
event.name === "revisions.create" &&
|
||||
!isDerivedFromDocument &&
|
||||
!revisionLoadedRef.current
|
||||
) {
|
||||
await revisions.fetch(event.id, { force: true });
|
||||
revisionLoadedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
icon = <EditIcon size={16} />;
|
||||
meta = latest ? (
|
||||
meta = event.latest ? (
|
||||
<>
|
||||
{t("Current version")} · {event.actor.name}
|
||||
{t("Current version")} · {actor?.name}
|
||||
</>
|
||||
) : (
|
||||
t("{{userName}} edited", opts)
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||
pathname: documentHistoryPath(
|
||||
document,
|
||||
isDerivedFromDocument ? "latest" : event.id
|
||||
),
|
||||
state: {
|
||||
sidebarContext,
|
||||
retainScrollPosition: true,
|
||||
@@ -75,47 +116,51 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
break;
|
||||
|
||||
case "documents.archive":
|
||||
icon = <ArchiveIcon size={16} />;
|
||||
icon = <ArchiveIcon />;
|
||||
meta = t("{{userName}} archived", opts);
|
||||
break;
|
||||
|
||||
case "documents.unarchive":
|
||||
icon = <RestoreIcon />;
|
||||
meta = t("{{userName}} restored", opts);
|
||||
break;
|
||||
|
||||
case "documents.delete":
|
||||
icon = <TrashIcon size={16} />;
|
||||
icon = <TrashIcon />;
|
||||
meta = t("{{userName}} deleted", opts);
|
||||
break;
|
||||
case "documents.add_user":
|
||||
icon = <UserIcon />;
|
||||
meta = t("{{userName}} added {{addedUserName}}", {
|
||||
...opts,
|
||||
addedUserName: event.user?.name ?? t("a user"),
|
||||
addedUserName: user?.name ?? t("a user"),
|
||||
});
|
||||
break;
|
||||
case "documents.remove_user":
|
||||
icon = <CrossIcon />;
|
||||
meta = t("{{userName}} removed {{removedUserName}}", {
|
||||
...opts,
|
||||
removedUserName: event.user?.name ?? t("a user"),
|
||||
removedUserName: user?.name ?? t("a user"),
|
||||
});
|
||||
break;
|
||||
|
||||
case "documents.restore":
|
||||
icon = <RestoreIcon />;
|
||||
meta = t("{{userName}} moved from trash", opts);
|
||||
break;
|
||||
|
||||
case "documents.publish":
|
||||
icon = <PublishIcon size={16} />;
|
||||
icon = <PublishIcon />;
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
|
||||
case "documents.unpublish":
|
||||
icon = <UnpublishIcon size={16} />;
|
||||
icon = <UnpublishIcon />;
|
||||
meta = t("{{userName}} unpublished", opts);
|
||||
break;
|
||||
|
||||
case "documents.move":
|
||||
icon = <MoveIcon size={16} />;
|
||||
icon = <MoveIcon />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
break;
|
||||
|
||||
@@ -136,8 +181,8 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
to = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseItem
|
||||
return event.name === "revisions.create" ? (
|
||||
<RevisionItem
|
||||
small
|
||||
exact
|
||||
to={to}
|
||||
@@ -153,17 +198,12 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar model={event.actor} size={32} />}
|
||||
subtitle={
|
||||
<Subtitle>
|
||||
{icon}
|
||||
{meta}
|
||||
</Subtitle>
|
||||
}
|
||||
image={<Avatar model={actor} size={AvatarSize.Large} />}
|
||||
subtitle={meta}
|
||||
actions={
|
||||
isRevision && isActive && event.modelId && !latest ? (
|
||||
isRevision && isActive && !event.latest ? (
|
||||
<StyledEventBoundary>
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
<RevisionMenu document={document} revisionId={event.id} />
|
||||
</StyledEventBoundary>
|
||||
) : undefined
|
||||
}
|
||||
@@ -171,63 +211,100 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<EventItem>
|
||||
<IconWrapper size="xsmall" type="secondary">
|
||||
{icon}
|
||||
</IconWrapper>
|
||||
<Text size="xsmall" type="secondary">
|
||||
{meta} ·{" "}
|
||||
<Time dateTime={event.createdAt} relative shorten addSuffix />
|
||||
</Text>
|
||||
</EventItem>
|
||||
);
|
||||
};
|
||||
|
||||
const BaseItem = React.forwardRef(function _BaseItem(
|
||||
{ to, ...rest }: ItemProps,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
return <ListItem to={to} ref={ref} {...rest} />;
|
||||
});
|
||||
const lineStyle = css`
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 22px;
|
||||
width: 1px;
|
||||
height: calc(50% - 14px + 8px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:first-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(50% + 14px);
|
||||
left: 22px;
|
||||
width: 1px;
|
||||
height: calc(50% - 14px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h3 + &::before {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled(Text)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const EventItem = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 8px 0;
|
||||
padding: 4px 10px;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
${lineStyle}
|
||||
`;
|
||||
|
||||
const StyledEventBoundary = styled(EventBoundary)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
margin: -3px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemStyle = css`
|
||||
const RevisionItem = styled(Item)`
|
||||
border: 0;
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
img {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 23px;
|
||||
width: 2px;
|
||||
height: calc(100% + 8px);
|
||||
background: ${s("textSecondary")};
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
height: 50%;
|
||||
top: auto;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:first-child:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
${lineStyle}
|
||||
|
||||
${Actions} {
|
||||
opacity: 0.5;
|
||||
@@ -238,8 +315,4 @@ const ItemStyle = css`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled(Item)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export default observer(EventListItem);
|
||||
|
||||
+62
-38
@@ -1,17 +1,26 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Initials from "./Avatar/Initials";
|
||||
|
||||
type Props = {
|
||||
/** The users to display */
|
||||
users: User[];
|
||||
/** The size of the avatars, defaults to AvatarSize.Large */
|
||||
size?: number;
|
||||
/** A number to show as the number of additional users */
|
||||
overflow?: number;
|
||||
/** The maximum number of users to display, defaults to 8 */
|
||||
limit?: number;
|
||||
renderAvatar?: (user: User) => React.ReactNode;
|
||||
/** A component to render the avatar, defaults to Avatar. */
|
||||
renderAvatar?: React.ComponentType<
|
||||
React.ComponentProps<typeof Avatar> & {
|
||||
model: User;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function Facepile({
|
||||
@@ -19,55 +28,70 @@ function Facepile({
|
||||
overflow = 0,
|
||||
size = AvatarSize.Large,
|
||||
limit = 8,
|
||||
renderAvatar = DefaultAvatar,
|
||||
renderAvatar = Avatar,
|
||||
...rest
|
||||
}: Props) {
|
||||
const filtered = users.filter(Boolean).slice(-limit);
|
||||
const Component = renderAvatar;
|
||||
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
<More size={size}>
|
||||
<span>
|
||||
{users.length ? "+" : ""}
|
||||
{overflow}
|
||||
</span>
|
||||
</More>
|
||||
<Initials size={size} content={String(overflow)}>
|
||||
{users.length ? "+" : ""}
|
||||
{overflow}
|
||||
</Initials>
|
||||
)}
|
||||
{users
|
||||
.filter(Boolean)
|
||||
.slice(0, limit)
|
||||
.map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
{filtered.map((model, index) => {
|
||||
const lastChild = index === 0 && overflow <= 0;
|
||||
return (
|
||||
<Component
|
||||
key={model.id}
|
||||
{...{
|
||||
model,
|
||||
size,
|
||||
style: {
|
||||
marginRight: lastChild ? 0 : -4,
|
||||
...(lastChild || filtered.length === 1
|
||||
? {}
|
||||
: { clipPath: `url(#${clipPathId(size)})` }),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<FacepileClip size={size} />
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultAvatar(user: User) {
|
||||
return <Avatar model={user} size={AvatarSize.Large} />;
|
||||
function FacepileClip({ size }: { size: number }) {
|
||||
return (
|
||||
<SVG
|
||||
width="25"
|
||||
height="28"
|
||||
viewBox="0 0 25 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<clipPath id={clipPathId(size)}>
|
||||
<path
|
||||
transform={size !== 28 ? `scale(${size / 28})` : ""}
|
||||
d="M14.0633 0.5C18.1978 0.5 21.8994 2.34071 24.3876 5.24462C22.8709 7.81315 22.0012 10.8061 22.0012 14C22.0012 17.1939 22.8709 20.1868 24.3876 22.7554C21.8994 25.6593 18.1978 27.5 14.0633 27.5C6.57035 27.5 0.5 21.4537 0.5 14C0.5 6.54628 6.57035 0.5 14.0633 0.5Z"
|
||||
/>
|
||||
</clipPath>
|
||||
</SVG>
|
||||
);
|
||||
}
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
margin-right: -8px;
|
||||
function clipPathId(size: number) {
|
||||
return `facepile-${size}`;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const More = styled.div<{ size: number }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 100%;
|
||||
background: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("white")};
|
||||
border: 2px solid ${s("background")};
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
const SVG = styled.svg`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
const Avatars = styled(Flex)`
|
||||
|
||||
@@ -6,12 +6,12 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -27,7 +27,7 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column ref={ref}>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt="" /> : null}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
|
||||
@@ -176,6 +176,7 @@ function Input(
|
||||
if (ev.key === "Enter" && ev.metaKey) {
|
||||
if (props.onRequestSubmit) {
|
||||
props.onRequestSubmit(ev);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +231,11 @@ function Input(
|
||||
])}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<NativeInput
|
||||
@@ -243,11 +245,12 @@ function Input(
|
||||
])}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
type={type}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
|
||||
@@ -48,7 +48,8 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
|
||||
options: Option[];
|
||||
/** @deprecated Removing soon, do not use. */
|
||||
note?: React.ReactNode;
|
||||
onChange?: (value: string | null) => void;
|
||||
/** Callback function that is called when the value changes. Return false to cancel the change. */
|
||||
onChange?: (value: string | null) => void | Promise<boolean | void>;
|
||||
style?: React.CSSProperties;
|
||||
/**
|
||||
* Set to true if this component is rendered inside a Modal.
|
||||
@@ -165,9 +166,18 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
||||
if (previousValue.current === select.selectedValue) {
|
||||
return;
|
||||
}
|
||||
const previous = previousValue.current;
|
||||
previousValue.current = select.selectedValue;
|
||||
|
||||
onChange?.(select.selectedValue);
|
||||
const response = onChange?.(select.selectedValue);
|
||||
if (response && response instanceof Promise) {
|
||||
void response.then((success) => {
|
||||
if (success === false) {
|
||||
select.selectedValue = previous;
|
||||
select.setSelectedValue(previous);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import EventListItem from "./EventListItem";
|
||||
import EventListItem, { type Event } from "./EventListItem";
|
||||
|
||||
type Props = {
|
||||
events: Event<Document>[];
|
||||
events: Event[];
|
||||
document: Document;
|
||||
fetch: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<Event<Document>[]>;
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
@@ -32,13 +29,8 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event<Document>, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
/>
|
||||
renderItem={(item: Event) => (
|
||||
<EventListItem key={item.id} event={item} document={document} />
|
||||
)}
|
||||
renderHeading={(name) => <Heading>{name}</Heading>}
|
||||
{...rest}
|
||||
|
||||
@@ -60,7 +60,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
fetchCounter = 0;
|
||||
|
||||
@observable
|
||||
renderCount = 15;
|
||||
renderCount = Pagination.defaultLimit;
|
||||
|
||||
@observable
|
||||
offset = 0;
|
||||
@@ -108,13 +108,16 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
...this.props.options,
|
||||
});
|
||||
|
||||
if (this.offset !== 0) {
|
||||
this.renderCount += limit;
|
||||
}
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += limit;
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
this.isFetchingInitial = false;
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
@@ -248,7 +251,9 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
|
||||
type Props = {
|
||||
/** The children to render */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
@@ -167,18 +168,24 @@ export const AccessControlList = observer(
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
try {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
@@ -201,11 +208,7 @@ export const AccessControlList = observer(
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Avatar
|
||||
model={membership.user}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
<Avatar model={membership.user} size={AvatarSize.Medium} />
|
||||
}
|
||||
title={membership.user.name}
|
||||
subtitle={membership.user.email}
|
||||
@@ -219,18 +222,24 @@ export const AccessControlList = observer(
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
try {
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const AccessControlList = observer(
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
image={<Avatar model={user} showBorder={false} />}
|
||||
image={<Avatar model={user} />}
|
||||
title={user.name}
|
||||
subtitle={t("You have full access")}
|
||||
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
|
||||
@@ -160,9 +160,7 @@ export const AccessControlList = observer(
|
||||
) : document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Avatar model={document.createdBy} showBorder={false} />
|
||||
}
|
||||
image={<Avatar model={document.createdBy} />}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
|
||||
@@ -73,9 +73,7 @@ const DocumentMemberListItem = ({
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={
|
||||
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
|
||||
}
|
||||
image={<Avatar model={user} size={AvatarSize.Medium} />}
|
||||
subtitle={
|
||||
membership?.sourceId ? (
|
||||
<Trans>
|
||||
|
||||
@@ -158,13 +158,7 @@ export const Suggestions = observer(
|
||||
: suggestion.isViewer
|
||||
? t("Viewer")
|
||||
: t("Editor"),
|
||||
image: (
|
||||
<Avatar
|
||||
model={suggestion}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
),
|
||||
image: <Avatar model={suggestion} size={AvatarSize.Medium} />,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import { inviteUser } from "~/actions/definitions/users";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
|
||||
import { homePath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ArchiveLink from "./components/ArchiveLink";
|
||||
import Collections from "./components/Collections";
|
||||
import { DraftsLink } from "./components/DraftsLink";
|
||||
import DragPlaceholder from "./components/DragPlaceholder";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
@@ -107,24 +106,7 @@ function AppSidebar() {
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts > 25
|
||||
? "25+"
|
||||
: documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{can.createDocument && <DraftsLink />}
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow>
|
||||
@@ -158,8 +140,4 @@ const Overflow = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
export default observer(AppSidebar);
|
||||
|
||||
@@ -14,6 +14,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { AvatarSize } from "../Avatar";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
@@ -40,7 +41,9 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
image={
|
||||
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
|
||||
}
|
||||
onClick={() =>
|
||||
history.push(
|
||||
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
|
||||
@@ -228,7 +228,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
|
||||
import { StyledError } from "./Collections";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
function ArchiveLink() {
|
||||
@@ -64,38 +65,40 @@ function ArchiveLink() {
|
||||
useDropToArchive();
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
<SidebarContext.Provider value="archive">
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,26 +2,29 @@ import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
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 Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { createDocument } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -41,12 +44,14 @@ const CollectionLink: React.FC<Props> = ({
|
||||
depth,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
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(
|
||||
@@ -58,119 +63,132 @@ const CollectionLink: React.FC<Props> = ({
|
||||
[collection]
|
||||
);
|
||||
|
||||
// Drop to re-parent document
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
const { id, collectionId } = item;
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
const handleExpand = React.useCallback(() => {
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
}
|
||||
}, [expanded, onDisclosureClick]);
|
||||
|
||||
const document = documents.get(id);
|
||||
if (collection.id === collectionId && !document?.parentDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevCollection = collections.get(collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: <ConfirmMoveDialog item={item} collection={collection} />,
|
||||
});
|
||||
} else {
|
||||
await documents.move({ documentId: id, collectionId: collection.id });
|
||||
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => can.createDocument,
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
|
||||
collection,
|
||||
handleExpand,
|
||||
parentRef
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void collection.fetchDocuments();
|
||||
}, [collection]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeCollectionId: collection.id,
|
||||
sidebarContext,
|
||||
});
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
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 },
|
||||
});
|
||||
},
|
||||
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
|
||||
);
|
||||
|
||||
return (
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<>
|
||||
<Relative ref={mergeRefs([parentRef, dropRef])}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
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}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
{can.createDocument && (
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
{isAddingNewChild && (
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={<CollectionIcon collection={collection} expanded={expanded} />}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
depth={2}
|
||||
isActive={() => true}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
action={createDocument}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import DocumentsLoader from "~/components/DocumentsLoader";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Text from "~/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import useCollectionDocuments from "../hooks/useCollectionDocuments";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
/** The collection to render the children of. */
|
||||
@@ -36,55 +34,17 @@ function CollectionLinkChildren({
|
||||
prefetchDocument,
|
||||
}: Props) {
|
||||
const pageSize = 250;
|
||||
const can = usePolicy(collection);
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const childDocuments = useCollectionDocuments(collection, documents.active);
|
||||
const [showing, setShowing] = React.useState(pageSize);
|
||||
const dummyRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort && item.collectionId === collection?.id) {
|
||||
toast.message(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
isDraggingAnyDocument: !!monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
|
||||
collection,
|
||||
noop,
|
||||
dummyRef
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!expanded) {
|
||||
@@ -100,12 +60,8 @@ function CollectionLinkChildren({
|
||||
|
||||
return (
|
||||
<Folder expanded={expanded}>
|
||||
{isDraggingAnyDocument && can.createDocument && manualSort && (
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
{canDrop && collection.isManualSort && (
|
||||
<DropCursor isActiveDrop={isOver} innerRef={dropRef} position="top" />
|
||||
)}
|
||||
<DocumentsLoader collection={collection} enabled={expanded}>
|
||||
{!childDocuments && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import Error from "~/components/List/Error";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { createCollection } from "~/actions/definitions/collections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import DraggableCollectionLink from "./DraggableCollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
@@ -17,7 +18,6 @@ import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
const { documents, collections } = useStores();
|
||||
|
||||
@@ -3,10 +3,11 @@ import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { NavigationNode, UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -15,10 +16,11 @@ 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 usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { newNestedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
useDragDocument,
|
||||
useDropToReorderDocument,
|
||||
@@ -58,6 +60,7 @@ function InnerDocumentLink(
|
||||
) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const canUpdate = usePolicy(node.id).update;
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
const hasChildDocuments =
|
||||
@@ -67,6 +70,7 @@ function InnerDocumentLink(
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -216,6 +220,43 @@ function InnerDocumentLink(
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
);
|
||||
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
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);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[
|
||||
documents,
|
||||
collection,
|
||||
sidebarContext,
|
||||
user,
|
||||
node,
|
||||
doc,
|
||||
history,
|
||||
closeAddingNewChild,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative ref={parentRef}>
|
||||
@@ -282,8 +323,11 @@ function InnerDocumentLink(
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
as={Link}
|
||||
to={newNestedDocumentPath(document.id)}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
setExpanded();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
@@ -308,8 +352,25 @@ function InnerDocumentLink(
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Relative>
|
||||
{isAddingNewChild && (
|
||||
<SidebarLink
|
||||
isActive={() => true}
|
||||
depth={depth + 1}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, index) => (
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
@@ -318,7 +379,7 @@ function InnerDocumentLink(
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DraftsIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draftsPath } from "~/utils/routeHelpers";
|
||||
import { useDropToUnpublish } from "../hooks/useDragAndDrop";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
export const DraftsLink = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToUnpublish();
|
||||
|
||||
return (
|
||||
<div ref={dropRef}>
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts > 25 ? "25+" : documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
@@ -9,12 +9,12 @@ import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
|
||||
@@ -3,23 +3,33 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
onSubmit: (title: string) => Promise<void>;
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
onSubmit: (title: string) => Promise<void> | void;
|
||||
/** A callback when the editing status changes. */
|
||||
onEditing?: (isEditing: boolean) => void;
|
||||
/** A callback when editing is canceled. */
|
||||
onCancel?: () => void;
|
||||
/** The default title. */
|
||||
title: string;
|
||||
/** Whether the user can update the title. */
|
||||
canUpdate: boolean;
|
||||
/** The maximum length of the title. */
|
||||
maxLength?: number;
|
||||
/** The default editing state. */
|
||||
isEditing?: boolean;
|
||||
};
|
||||
|
||||
export type RefHandle = {
|
||||
/** A function to set the editing state. */
|
||||
setIsEditing: (isEditing: boolean) => void;
|
||||
};
|
||||
|
||||
function EditableTitle(
|
||||
{ title, onSubmit, canUpdate, onEditing, ...rest }: Props,
|
||||
{ title, onSubmit, canUpdate, onEditing, onCancel, ...rest }: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
|
||||
@@ -59,6 +69,7 @@ function EditableTitle(
|
||||
|
||||
if (trimmedValue === originalValue || trimmedValue.length === 0) {
|
||||
setValue(originalValue);
|
||||
onCancel?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,7 +84,7 @@ function EditableTitle(
|
||||
}
|
||||
}
|
||||
},
|
||||
[originalValue, value, onSubmit]
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
@@ -83,13 +94,14 @@ function EditableTitle(
|
||||
}
|
||||
if (ev.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
onCancel?.();
|
||||
setValue(originalValue);
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
await handleSave(ev);
|
||||
}
|
||||
},
|
||||
[handleSave, originalValue]
|
||||
[handleSave, onCancel, originalValue]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import User from "~/models/User";
|
||||
export type SidebarContextType =
|
||||
| "collections"
|
||||
| "shared"
|
||||
| "archive"
|
||||
| `group-${string}`
|
||||
| `starred-${string}`
|
||||
| undefined;
|
||||
@@ -41,7 +42,7 @@ export const determineSidebarContext = ({
|
||||
}
|
||||
|
||||
if (document.collection) {
|
||||
return "collections";
|
||||
return document.collection.isArchived ? "archive" : "collections";
|
||||
} else if (
|
||||
user.documentMemberships.find((m) => m.documentId === document.id)
|
||||
) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
@@ -12,11 +11,6 @@ import { undraggableOnDesktop } from "~/styles";
|
||||
import Disclosure from "./Disclosure";
|
||||
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: LocationDescriptor;
|
||||
innerRef?: (ref: HTMLElement | null | undefined) => void;
|
||||
|
||||
@@ -86,6 +86,11 @@ function StarredLink({ star }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(
|
||||
() => documentId && documents.prefetchDocument(documentId),
|
||||
[documents, documentId]
|
||||
);
|
||||
|
||||
const getIndex = () => {
|
||||
const next = star?.next();
|
||||
return fractionalIndex(star?.index || null, next?.index || null);
|
||||
@@ -142,6 +147,7 @@ function StarredLink({ star }: Props) {
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
@@ -172,6 +178,7 @@ function StarredLink({ star }: Props) {
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { trashPath } from "~/utils/routeHelpers";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
function TrashLink() {
|
||||
const { policies, dialogs, documents } = useStores();
|
||||
|
||||
@@ -15,10 +15,49 @@ import Star from "~/models/Star";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../components/SidebarLink";
|
||||
import { AuthorizationError } from "~/utils/errors";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
function useHover(
|
||||
elementRef: React.RefObject<HTMLDivElement>,
|
||||
callback: () => void
|
||||
) {
|
||||
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const startHover = React.useCallback(() => {
|
||||
if (!hoverTimeoutRef.current) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
hoverTimeoutRef.current = undefined;
|
||||
callback();
|
||||
}, 500);
|
||||
}
|
||||
}, [callback]);
|
||||
|
||||
const unsetHover = React.useCallback(() => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
element?.addEventListener("dragleave", unsetHover);
|
||||
return () => element?.removeEventListener("dragleave", unsetHover);
|
||||
}, [elementRef, unsetHover]);
|
||||
|
||||
return startHover;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dragging a Starred item
|
||||
*
|
||||
@@ -162,6 +201,84 @@ export function useDragDocument(
|
||||
return [{ isDragging }, draggableRef] as const;
|
||||
}
|
||||
|
||||
export function useDropToChangeCollection(
|
||||
collection: Collection,
|
||||
expandNode: () => void,
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const can = usePolicy(collection);
|
||||
const startHover = useHover(parentRef, expandNode);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, collectionId } = item;
|
||||
const prevCollection = collections.get(collectionId);
|
||||
const document = documents.get(id);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
expandNode();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
{
|
||||
documentName: item.title,
|
||||
collectionName: collection.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => can.createDocument,
|
||||
hover: (_, monitor) => {
|
||||
if (
|
||||
collection.hasDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({ shallow: true })
|
||||
) {
|
||||
startHover();
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reparent
|
||||
*
|
||||
@@ -175,7 +292,7 @@ export function useDropToReparentDocument(
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const hasChildDocuments = !!node?.children.length;
|
||||
const document = node ? documents.get(node.id) : undefined;
|
||||
const pathToNode = React.useMemo(
|
||||
@@ -183,25 +300,7 @@ export function useDropToReparentDocument(
|
||||
[document]
|
||||
);
|
||||
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const resetHoverExpanding = () => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const element = parentRef.current;
|
||||
element?.addEventListener("dragleave", resetHoverExpanding);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener("dragleave", resetHoverExpanding);
|
||||
};
|
||||
}, [parentRef]);
|
||||
const startHover = useHover(parentRef, setExpanded);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
@@ -214,7 +313,9 @@ export function useDropToReparentDocument(
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = documents.get(node.id)?.collection;
|
||||
const collection = node.collectionId
|
||||
? collections.get(node.collectionId)
|
||||
: undefined;
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
@@ -233,22 +334,40 @@ export function useDropToReparentDocument(
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
setExpanded();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"{{ documentName }} cannot be moved within {{ parentDocumentName }}",
|
||||
{
|
||||
documentName: item.title,
|
||||
parentDocumentName: node.title,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: (item) => {
|
||||
if (!node || item.id === node.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setExpanded();
|
||||
if (!document) {
|
||||
return true; // optimistic, in case the document is not loaded yet; server will check for permissions before performing the move.
|
||||
}
|
||||
|
||||
return document.isActive && !!pathToNode && !pathToNode.includes(item.id);
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!!node &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
!!document?.isActive &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
@@ -259,15 +378,7 @@ export function useDropToReparentDocument(
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
startHover();
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
@@ -297,7 +408,7 @@ export function useDropToReorderDocument(
|
||||
}
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
|
||||
const document = documents.get(node.id);
|
||||
|
||||
@@ -308,22 +419,9 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (
|
||||
item.id === node.id ||
|
||||
!policies.abilities(item.id)?.move ||
|
||||
!document?.isActive
|
||||
) {
|
||||
if (item.id === node.id || (document && !document.isActive)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = getMoveParams(item);
|
||||
if (params?.collectionId) {
|
||||
return policies.abilities(params.collectionId)?.updateDocument;
|
||||
}
|
||||
if (params?.parentDocumentId) {
|
||||
return policies.abilities(params.parentDocumentId)?.update;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
drop: async (item) => {
|
||||
@@ -357,7 +455,19 @@ export function useDropToReorderDocument(
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move(params);
|
||||
try {
|
||||
await documents.move(params);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t("The {{ documentName }} cannot be moved here", {
|
||||
documentName: item.title,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -476,3 +586,45 @@ export function useDropToArchive() {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDropToUnpublish() {
|
||||
const { t } = useTranslation();
|
||||
const { policies, documents } = useStores();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item) => {
|
||||
const document = documents.get(item.id);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await document.unpublish({ detach: true });
|
||||
toast.success(
|
||||
t("Unpublished {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
canDrop: (item) => {
|
||||
const policy = policies.abilities(item.id);
|
||||
if (!policy) {
|
||||
return true; // optimistic, let the server check for the necessary permission.
|
||||
}
|
||||
|
||||
return policy.unpublish;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isObject from "lodash/isObject";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import semver from "semver";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { toast } from "sonner";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -114,10 +117,23 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", () => {
|
||||
this.socket.on("authenticated", (data) => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = true;
|
||||
}
|
||||
if (isObject(data) && "editorVersion" in data) {
|
||||
const parsedClientVersion = semver.parse(EDITOR_VERSION);
|
||||
const parsedCurrentVersion = semver.parse(String(data.editorVersion));
|
||||
|
||||
if (
|
||||
parsedClientVersion &&
|
||||
parsedCurrentVersion &&
|
||||
(parsedClientVersion.major < parsedCurrentVersion.major ||
|
||||
parsedClientVersion.minor < parsedCurrentVersion.minor)
|
||||
) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("unauthorized", (err: Error) => {
|
||||
@@ -190,7 +206,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
if (!collection?.documents?.length && !event.fetchIfMissing) {
|
||||
if (!collection?.documents && !event.fetchIfMissing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -225,6 +241,32 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.unpublish",
|
||||
action(
|
||||
(event: {
|
||||
document: PartialExcept<Document, "id">;
|
||||
collectionId: string;
|
||||
}) => {
|
||||
const document = event.document;
|
||||
|
||||
// When document is detached as part of unpublishing, only the owner should be able to view it.
|
||||
if (
|
||||
!document.collectionId &&
|
||||
document.createdBy?.id !== currentUserId
|
||||
) {
|
||||
documents.remove(document.id);
|
||||
} else {
|
||||
documents.add(document);
|
||||
}
|
||||
policies.remove(document.id);
|
||||
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.removeDocument(document.id);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.archive",
|
||||
action((event: PartialExcept<Document, "id">) => {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import React from "react";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import getMenuItems from "../menus/block";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "trigger"
|
||||
> &
|
||||
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
|
||||
Required<Pick<SuggestionsMenuProps, "embeds">>;
|
||||
|
||||
function BlockMenu(props: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { elementRef } = useEditor();
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
@@ -29,7 +28,7 @@ function BlockMenu(props: Props) {
|
||||
shortcut={item.shortcut}
|
||||
/>
|
||||
)}
|
||||
items={getMenuItems(dictionary)}
|
||||
items={getMenuItems(dictionary, elementRef)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ type Emoji = {
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<Emoji>,
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
>;
|
||||
|
||||
const EmojiMenu = (props: Props) => {
|
||||
@@ -48,7 +48,6 @@ const EmojiMenu = (props: Props) => {
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
trigger=":"
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<EmojiMenuItem
|
||||
|
||||
@@ -24,6 +24,54 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type KeyboardShortcutsProps = {
|
||||
popover: ReturnType<typeof usePopoverState>;
|
||||
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
|
||||
handleCaseSensitive: () => void;
|
||||
handleRegex: () => void;
|
||||
};
|
||||
|
||||
function useKeyboardShortcuts({
|
||||
popover,
|
||||
handleOpen,
|
||||
handleCaseSensitive,
|
||||
handleRegex,
|
||||
}: KeyboardShortcutsProps) {
|
||||
// Open popover
|
||||
useKeyDown(
|
||||
(ev) =>
|
||||
isModKey(ev) &&
|
||||
ev.code === "KeyF" &&
|
||||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
handleOpen({ withReplace: ev.altKey });
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Enable/disable case sensitive search
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
handleCaseSensitive();
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Enable/disable regex search
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
handleRegex();
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Whether the find and replace popover is open */
|
||||
open: boolean;
|
||||
@@ -89,42 +137,48 @@ export default function FindAndReplace({
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
// Callbacks
|
||||
const selectInputText = React.useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
|
||||
}, []);
|
||||
|
||||
const selectInputReplaceText = React.useCallback(() => {
|
||||
setTimeout(() => {
|
||||
inputReplaceRef.current?.focus();
|
||||
inputReplaceRef.current?.setSelectionRange(
|
||||
0,
|
||||
inputReplaceRef.current?.value.length
|
||||
);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const handleOpen = React.useCallback(
|
||||
({ withReplace }: { withReplace: boolean }) => {
|
||||
const shouldShowReplace = !readOnly && withReplace;
|
||||
|
||||
// If already open, switch focus to corresponding input text.
|
||||
if (popover.visible) {
|
||||
if (shouldShowReplace) {
|
||||
setShowReplace(true);
|
||||
selectInputReplaceText();
|
||||
} else {
|
||||
selectInputText();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyDown(
|
||||
(ev) =>
|
||||
isModKey(ev) &&
|
||||
!popover.visible &&
|
||||
ev.code === "KeyF" &&
|
||||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
selectionRef.current = window.getSelection()?.toString();
|
||||
popover.show();
|
||||
}
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setRegex((state) => !state);
|
||||
if (shouldShowReplace) {
|
||||
setShowReplace(true);
|
||||
}
|
||||
},
|
||||
{ allowInInput: true }
|
||||
[popover, readOnly, selectInputText, selectInputReplaceText]
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setCaseSensitive((state) => !state);
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Callbacks
|
||||
const handleMore = React.useCallback(() => {
|
||||
setShowReplace((state) => !state);
|
||||
setTimeout(() => inputReplaceRef.current?.focus(), 100);
|
||||
@@ -132,68 +186,65 @@ export default function FindAndReplace({
|
||||
|
||||
const handleCaseSensitive = React.useCallback(() => {
|
||||
setCaseSensitive((state) => {
|
||||
const caseSensitive = !state;
|
||||
const isCaseSensitive = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive,
|
||||
caseSensitive: isCaseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
|
||||
return caseSensitive;
|
||||
return isCaseSensitive;
|
||||
});
|
||||
}, [regexEnabled, editor.commands, searchTerm]);
|
||||
|
||||
const handleRegex = React.useCallback(() => {
|
||||
setRegex((state) => {
|
||||
const regexEnabled = !state;
|
||||
const isRegexEnabled = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
regexEnabled: isRegexEnabled,
|
||||
});
|
||||
|
||||
return regexEnabled;
|
||||
return isRegexEnabled;
|
||||
});
|
||||
}, [caseSensitive, editor.commands, searchTerm]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
function nextPrevious(ev: React.KeyboardEvent<HTMLInputElement>) {
|
||||
function nextPrevious() {
|
||||
if (ev.shiftKey) {
|
||||
editor.commands.prevSearchMatch();
|
||||
} else {
|
||||
editor.commands.nextSearchMatch();
|
||||
}
|
||||
}
|
||||
function selectInputText() {
|
||||
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
|
||||
}
|
||||
|
||||
switch (ev.key) {
|
||||
case "Enter": {
|
||||
ev.preventDefault();
|
||||
nextPrevious(ev);
|
||||
nextPrevious();
|
||||
return;
|
||||
}
|
||||
case "g": {
|
||||
if (ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
nextPrevious(ev);
|
||||
nextPrevious();
|
||||
selectInputText();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "F3": {
|
||||
ev.preventDefault();
|
||||
nextPrevious(ev);
|
||||
nextPrevious();
|
||||
selectInputText();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor.commands]
|
||||
[editor.commands, selectInputText]
|
||||
);
|
||||
|
||||
const handleReplace = React.useCallback(
|
||||
@@ -243,6 +294,15 @@ export default function FindAndReplace({
|
||||
[handleReplace]
|
||||
);
|
||||
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
popover,
|
||||
handleOpen,
|
||||
handleCaseSensitive,
|
||||
handleRegex,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = React.useMemo(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
@@ -285,7 +345,7 @@ export default function FindAndReplace({
|
||||
<>
|
||||
<Tooltip
|
||||
content={t("Previous match")}
|
||||
shortcut="shift+enter"
|
||||
shortcut="Shift+Enter"
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge
|
||||
@@ -295,7 +355,7 @@ export default function FindAndReplace({
|
||||
<CaretUpIcon />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
|
||||
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
@@ -354,7 +414,11 @@ export default function FindAndReplace({
|
||||
</StyledInput>
|
||||
{navigation}
|
||||
{!readOnly && (
|
||||
<Tooltip content={t("Replace options")} placement="bottom">
|
||||
<Tooltip
|
||||
content={t("Replace options")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+f`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
@@ -376,12 +440,28 @@ export default function FindAndReplace({
|
||||
onRequestSubmit={handleReplaceAll}
|
||||
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
|
||||
/>
|
||||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
<Tooltip
|
||||
content={t("Replace")}
|
||||
shortcut="Enter"
|
||||
placement="bottom"
|
||||
>
|
||||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={t("Replace all")}
|
||||
shortcut={`${metaDisplay}+Enter`}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={handleReplaceAll}
|
||||
disabled={disabled}
|
||||
neutral
|
||||
>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
@@ -41,7 +40,8 @@ function usePosition({
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
|
||||
const menuWidth = menuRef.current?.offsetWidth;
|
||||
const menuHeight = menuRef.current?.offsetHeight;
|
||||
|
||||
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||
return defaultPosition;
|
||||
@@ -78,13 +78,24 @@ function usePosition({
|
||||
|
||||
// position at the top right of code blocks
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
const noticeBlock = findParentNode(
|
||||
(node) => node.type.name === "container_notice"
|
||||
)(view.state.selection);
|
||||
|
||||
if (codeBlock && view.state.selection.empty) {
|
||||
const element = view.nodeDOM(codeBlock.pos);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.right = bounds.right;
|
||||
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
|
||||
const position = codeBlock
|
||||
? codeBlock.pos
|
||||
: noticeBlock
|
||||
? noticeBlock.pos
|
||||
: null;
|
||||
|
||||
if (position !== null) {
|
||||
const element = view.nodeDOM(position);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.right = bounds.right;
|
||||
}
|
||||
}
|
||||
|
||||
// tables are an oddity, and need their own positioning logic
|
||||
@@ -184,11 +195,12 @@ function usePosition({
|
||||
// of the selection still
|
||||
const offset = left - (centerOfSelection - menuWidth / 2);
|
||||
return {
|
||||
left: Math.round(left - offsetParent.left),
|
||||
left: Math.max(margin, Math.round(left - offsetParent.left)),
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
|
||||
blockSelection:
|
||||
codeBlock || isColSelection || isRowSelection || noticeBlock,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { dictionary } = this.props;
|
||||
const { view, dictionary } = this.props;
|
||||
const { value } = this.state;
|
||||
const isInternal = isInternalUrl(value);
|
||||
|
||||
@@ -202,6 +202,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
onChange={this.handleSearch}
|
||||
onFocus={this.handleSearch}
|
||||
autoFocus={this.href === ""}
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
@@ -211,11 +212,13 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
{isInternal ? <ArrowIcon /> : <OpenIcon />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={dictionary.removeLink}>
|
||||
<ToolbarButton onClick={this.handleRemoveLink}>
|
||||
<CloseIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
{view.editable && (
|
||||
<Tooltip content={dictionary.removeLink}>
|
||||
<ToolbarButton onClick={this.handleRemoveLink}>
|
||||
<CloseIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ interface MentionItem extends MenuItem {
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<MentionItem>,
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
>;
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
@@ -84,7 +84,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
showBorder={false}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
@@ -158,6 +157,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
if (item.attrs.type === MentionType.Document) {
|
||||
return;
|
||||
}
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
@@ -194,7 +196,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
{...rest}
|
||||
isActive={isActive}
|
||||
filterable={false}
|
||||
trigger="@"
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
> & {
|
||||
pastedText: string;
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const embed = React.useMemo(() => {
|
||||
for (const e of embeds) {
|
||||
const matches = e.matcher(props.pastedText);
|
||||
if (matches) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [embeds, props.pastedText]);
|
||||
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
],
|
||||
[embed, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
trigger=""
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={() => {
|
||||
props.onSelect?.(item);
|
||||
}}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasteMenu;
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
@@ -18,6 +19,7 @@ import getCodeMenuItems from "../menus/code";
|
||||
import getDividerMenuItems from "../menus/divider";
|
||||
import getFormattingMenuItems from "../menus/formatting";
|
||||
import getImageMenuItems from "../menus/image";
|
||||
import getNoticeMenuItems from "../menus/notice";
|
||||
import getReadOnlyMenuItems from "../menus/readOnly";
|
||||
import getTableMenuItems from "../menus/table";
|
||||
import getTableColMenuItems from "../menus/tableCol";
|
||||
@@ -55,6 +57,10 @@ function useIsActive(state: EditorState) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInNotice(state) && selection.from > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!selection || selection.empty) {
|
||||
return false;
|
||||
}
|
||||
@@ -184,6 +190,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "attachment";
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
|
||||
@@ -203,6 +210,8 @@ export default function SelectionToolbar(props: Props) {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
} else if (readOnly) {
|
||||
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
|
||||
}
|
||||
|
||||
@@ -233,7 +233,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const attrs =
|
||||
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
|
||||
|
||||
if (command) {
|
||||
if (item.name === "noop") {
|
||||
// Do nothing
|
||||
} else if (command) {
|
||||
command(attrs);
|
||||
} else {
|
||||
commands[`create${capitalize(item.name)}`](attrs);
|
||||
@@ -435,7 +437,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
if (
|
||||
item.name &&
|
||||
!commands[item.name] &&
|
||||
!commands[`create${capitalize(item.name)}`]
|
||||
!commands[`create${capitalize(item.name)}`] &&
|
||||
item.name !== "noop"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type Props = {
|
||||
/** Callback when the item is clicked */
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
/** An optional icon for the item */
|
||||
icon?: React.ReactElement;
|
||||
icon?: React.ReactNode;
|
||||
/** The title of the item */
|
||||
title: React.ReactNode;
|
||||
/** An optional subtitle for the item */
|
||||
|
||||
@@ -99,22 +99,28 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
];
|
||||
}
|
||||
|
||||
private handleClose = action((insertNewLine: boolean) => {
|
||||
const { view } = this.editor;
|
||||
|
||||
if (insertNewLine) {
|
||||
const transaction = view.state.tr.split(view.state.selection.to);
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
});
|
||||
|
||||
widget = ({ rtl }: WidgetProps) => {
|
||||
const { props, view } = this.editor;
|
||||
const { props } = this.editor;
|
||||
|
||||
return (
|
||||
<BlockMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action((insertNewLine) => {
|
||||
if (insertNewLine) {
|
||||
const transaction = view.state.tr.split(view.state.selection.to);
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
})}
|
||||
onClose={this.handleClose}
|
||||
uploadFile={props.uploadFile}
|
||||
onFileUploadStart={props.onFileUploadStart}
|
||||
onFileUploadStop={props.onFileUploadStop}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
/**
|
||||
* A plugin that allows overriding the default behavior of the editor to allow
|
||||
* copying text for nodes that do not inherently have text children by defining
|
||||
* a `toPlainText` method in the node spec.
|
||||
* copying text including the markdown formatting.
|
||||
*/
|
||||
export default class ClipboardTextSerializer extends Extension {
|
||||
get name() {
|
||||
@@ -14,19 +12,33 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const textSerializers = getTextSerializers(this.editor.schema);
|
||||
const mdSerializer = this.editor.extensions.serializer();
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: () => {
|
||||
const { doc, selection } = this.editor.view.state;
|
||||
const { ranges } = selection;
|
||||
const from = Math.min(...ranges.map((range) => range.$from.pos));
|
||||
const to = Math.max(...ranges.map((range) => range.$to.pos));
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const isMultiline = slice.content.childCount > 1;
|
||||
|
||||
return textBetween(doc, from, to, textSerializers);
|
||||
// This is a cheap way to determine if the content is "complex",
|
||||
// aka it has multiple marks or formatting. In which case we'll use
|
||||
// markdown formatting
|
||||
const copyAsMarkdown =
|
||||
isMultiline ||
|
||||
slice.content.content.some(
|
||||
(node) => node.content.content.length > 1
|
||||
);
|
||||
|
||||
return copyAsMarkdown
|
||||
? mdSerializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
})
|
||||
: slice.content.content
|
||||
.map((node) =>
|
||||
ProsemirrorHelper.toPlainText(node, this.editor.schema)
|
||||
)
|
||||
.join("");
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -33,6 +33,7 @@ export default class EmojiMenuExtension extends Suggestion {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<EmojiMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ export default class MentionMenuExtension extends Suggestion {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<MentionMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
type UserAwareness = {
|
||||
@@ -103,6 +105,11 @@ export default class Multiplayer extends Extension {
|
||||
selectionBuilder,
|
||||
}),
|
||||
yUndoPlugin(),
|
||||
new Plugin({
|
||||
props: {
|
||||
handleScrollToSelection: (view) => isRemoteTransaction(view.state.tr),
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +1,51 @@
|
||||
import { action, observable } from "mobx";
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { Slice } from "prosemirror-model";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import {
|
||||
EditorState,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { v4 } from "uuid";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { IconType, MentionType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
|
||||
/**
|
||||
* Checks if the HTML string is likely coming from Dropbox Paper.
|
||||
*
|
||||
* @param html The HTML string to check.
|
||||
* @returns True if the HTML string is likely coming from Dropbox Paper.
|
||||
*/
|
||||
function isDropboxPaper(html: string): boolean {
|
||||
return html?.includes("usually-unique-id");
|
||||
}
|
||||
|
||||
function sliceSingleNode(slice: Slice) {
|
||||
return slice.openStart === 0 &&
|
||||
slice.openEnd === 0 &&
|
||||
slice.content.childCount === 1
|
||||
? slice.content.firstChild
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the text contents of an HTML string and returns the src of the first
|
||||
* iframe if it exists.
|
||||
*
|
||||
* @param text The HTML string to parse.
|
||||
* @returns The src of the first iframe if it exists, or undefined.
|
||||
*/
|
||||
function parseSingleIframeSrc(html: string) {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
if (
|
||||
doc.body.children.length === 1 &&
|
||||
doc.body.firstElementChild?.tagName === "IFRAME"
|
||||
) {
|
||||
const iframe = doc.body.firstElementChild;
|
||||
const src = iframe.getAttribute("src");
|
||||
if (src) {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore the million ways parsing could fail.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
state: {
|
||||
open: boolean;
|
||||
query: string;
|
||||
pastedText: string;
|
||||
} = observable({
|
||||
open: false,
|
||||
query: "",
|
||||
pastedText: "",
|
||||
});
|
||||
|
||||
get name() {
|
||||
return "paste-handler";
|
||||
}
|
||||
|
||||
private key = new PluginKey(this.name);
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: this.key,
|
||||
props: {
|
||||
transformPastedHTML(html: string) {
|
||||
if (isDropboxPaper(html)) {
|
||||
@@ -107,23 +85,6 @@ export default class PasteHandler extends Extension {
|
||||
const html = event.clipboardData.getData("text/html");
|
||||
const vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
|
||||
function insertLink(href: string, title?: string) {
|
||||
// If it's not an embed and there is no text selected – just go ahead and insert the
|
||||
// link directly
|
||||
const transaction = view.state.tr
|
||||
.insertText(
|
||||
title ?? href,
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
)
|
||||
.addMark(
|
||||
state.selection.from,
|
||||
state.selection.to + (title ?? href).length,
|
||||
state.schema.marks.link.create({ href })
|
||||
);
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
|
||||
// If the users selection is currently in a code block then paste
|
||||
// as plain text, ignore all formatting and HTML content.
|
||||
if (isInCode(state)) {
|
||||
@@ -141,6 +102,13 @@ export default class PasteHandler extends Extension {
|
||||
const supportsCodeMark = !!state.schema.marks.code_inline;
|
||||
|
||||
if (!this.shiftKey) {
|
||||
// If the HTML on the clipboard is from Prosemirror then the best
|
||||
// compatability is to just use the HTML parser, regardless of
|
||||
// whether it "looks" like Markdown, see: outline/outline#2416
|
||||
if (html?.includes("data-pm-slice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the clipboard contents can be parsed as a single url
|
||||
if (isUrl(text)) {
|
||||
// If there is selected text then we want to wrap it in a link to the url
|
||||
@@ -152,28 +120,6 @@ export default class PasteHandler extends Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Is this link embeddable? Create an embed!
|
||||
const { embeds } = this.editor.props;
|
||||
if (
|
||||
embeds &&
|
||||
this.editor.commands.embed &&
|
||||
!isInCode(state) &&
|
||||
!isInList(state)
|
||||
) {
|
||||
for (const embed of embeds) {
|
||||
if (!embed.matchOnInput) {
|
||||
continue;
|
||||
}
|
||||
const matches = embed.matcher(text);
|
||||
if (matches) {
|
||||
this.editor.commands.embed({
|
||||
href: text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
@@ -209,7 +155,7 @@ export default class PasteHandler extends Extension {
|
||||
hasEmoji ? document.icon + " " : ""
|
||||
}${document.titleWithDefault}`;
|
||||
|
||||
insertLink(`${document.path}${hash}`, title);
|
||||
this.insertLink(`${document.path}${hash}`, title);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -217,11 +163,11 @@ export default class PasteHandler extends Extension {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
insertLink(text);
|
||||
this.insertLink(text);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
insertLink(text);
|
||||
this.insertLink(text);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -264,21 +210,17 @@ export default class PasteHandler extends Extension {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the HTML on the clipboard is from Prosemirror then the best
|
||||
// compatability is to just use the HTML parser, regardless of
|
||||
// whether it "looks" like Markdown, see: outline/outline#2416
|
||||
if (html?.includes("data-pm-slice")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the text on the clipboard looks like Markdown OR there is no
|
||||
// html on the clipboard then try to parse content as Markdown
|
||||
if (
|
||||
(isMarkdown(text) && !isDropboxPaper(html)) ||
|
||||
(isMarkdown(text) &&
|
||||
!isDropboxPaper(html) &&
|
||||
!isContainingImage(html)) ||
|
||||
pasteCodeLanguage === "markdown" ||
|
||||
this.shiftKey
|
||||
this.shiftKey ||
|
||||
!html
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -323,10 +265,226 @@ export default class PasteHandler extends Extension {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply: (tr, set) => {
|
||||
let mapping = tr.mapping;
|
||||
|
||||
// See if the transaction adds or removes any placeholders
|
||||
const meta = tr.getMeta(this.key);
|
||||
const hasDecorations = set.find().length;
|
||||
|
||||
// We only want a single paste placeholder at a time, so if we're adding a new
|
||||
// placeholder we can just return a new DecorationSet and avoid mapping logic.
|
||||
if (meta?.add) {
|
||||
const { from, to, id } = meta.add;
|
||||
const decorations = [
|
||||
Decoration.inline(
|
||||
from,
|
||||
to,
|
||||
{
|
||||
class: "paste-placeholder",
|
||||
},
|
||||
{
|
||||
id,
|
||||
}
|
||||
),
|
||||
];
|
||||
return DecorationSet.create(tr.doc, decorations);
|
||||
}
|
||||
|
||||
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
|
||||
try {
|
||||
mapping = recreateTransform(tr.before, tr.doc, {
|
||||
complexSteps: true,
|
||||
wordDiffs: false,
|
||||
simplifyDiff: true,
|
||||
}).mapping;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to recreate transform: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
set = set.map(mapping, tr.doc);
|
||||
|
||||
if (meta?.remove) {
|
||||
const { id } = meta.remove;
|
||||
const decorations = set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id === id
|
||||
);
|
||||
return set.remove(decorations);
|
||||
}
|
||||
|
||||
return set;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** Tracks whether the Shift key is currently held down */
|
||||
private shiftKey = false;
|
||||
|
||||
private showPasteMenu = action((text: string) => {
|
||||
this.state.pastedText = text;
|
||||
this.state.open = true;
|
||||
});
|
||||
|
||||
private hidePasteMenu = action(() => {
|
||||
this.state.open = false;
|
||||
});
|
||||
|
||||
private insertLink(href: string, title?: string) {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const { from } = state.selection;
|
||||
const to = from + (title ?? href).length;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.insertText(title ?? href, state.selection.from, state.selection.to)
|
||||
.addMark(from, to, state.schema.marks.link.create({ href }))
|
||||
.setMeta(this.key, { add: { from, to, id: href } });
|
||||
view.dispatch(transaction);
|
||||
this.showPasteMenu(href);
|
||||
}
|
||||
|
||||
private insertEmbed = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
);
|
||||
}
|
||||
|
||||
this.editor.commands.embed({
|
||||
href: this.state.pastedText,
|
||||
});
|
||||
};
|
||||
|
||||
private removePlaceholder = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
if (result) {
|
||||
view.dispatch(
|
||||
state.tr.setMeta(this.key, {
|
||||
remove: { id: this.state.pastedText },
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private findPlaceholder = (
|
||||
state: EditorState,
|
||||
id: string
|
||||
): [number, number] | null => {
|
||||
const decos = this.key.getState(state) as DecorationSet;
|
||||
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
|
||||
return found?.length ? [found[0].from, found[0].to] : null;
|
||||
};
|
||||
|
||||
private handleSelect = (item: MenuItem) => {
|
||||
switch (item.name) {
|
||||
case "noop": {
|
||||
this.hidePasteMenu();
|
||||
this.removePlaceholder();
|
||||
break;
|
||||
}
|
||||
case "embed": {
|
||||
this.hidePasteMenu();
|
||||
this.insertEmbed();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
keys() {
|
||||
return {
|
||||
Backspace: () => {
|
||||
this.hidePasteMenu();
|
||||
return false;
|
||||
},
|
||||
"Mod-z": () => {
|
||||
this.hidePasteMenu();
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<PasteMenu
|
||||
rtl={rtl}
|
||||
embeds={this.editor.props.embeds}
|
||||
pastedText={this.state.pastedText}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={this.hidePasteMenu}
|
||||
onSelect={this.handleSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the HTML string is likely coming from Dropbox Paper.
|
||||
*
|
||||
* @param html The HTML string to check.
|
||||
* @returns True if the HTML string is likely coming from Dropbox Paper.
|
||||
*/
|
||||
function isDropboxPaper(html: string): boolean {
|
||||
return html?.includes("usually-unique-id");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the HTML string contains an image.
|
||||
*
|
||||
* @param html The HTML string to check.
|
||||
* @returns True if the HTML string contains an image.
|
||||
*/
|
||||
function isContainingImage(html: string): boolean {
|
||||
return html?.includes("<img");
|
||||
}
|
||||
|
||||
function sliceSingleNode(slice: Slice) {
|
||||
return slice.openStart === 0 &&
|
||||
slice.openEnd === 0 &&
|
||||
slice.content.childCount === 1
|
||||
? slice.content.firstChild
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the text contents of an HTML string and returns the src of the first
|
||||
* iframe if it exists.
|
||||
*
|
||||
* @param text The HTML string to parse.
|
||||
* @returns The src of the first iframe if it exists, or undefined.
|
||||
*/
|
||||
function parseSingleIframeSrc(html: string) {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
if (
|
||||
doc.body.children.length === 1 &&
|
||||
doc.body.firstElementChild?.tagName === "IFRAME"
|
||||
) {
|
||||
const iframe = doc.body.firstElementChild;
|
||||
const src = iframe.getAttribute("src");
|
||||
if (src) {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore the million ways parsing could fail.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
const emdash = new InputRule(/--$/, "—");
|
||||
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
|
||||
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
const registered = new InputRule(/\(r\)$/, "®️");
|
||||
const trademarked = new InputRule(/\(tm\)$/, "™️");
|
||||
|
||||
@@ -19,7 +19,9 @@ export default class Suggestion extends Extension {
|
||||
super(options);
|
||||
|
||||
this.openRegex = new RegExp(
|
||||
`(?:^|\\s|\\()${escapeRegExp(this.options.trigger)}(${`[\\p{L}\\p{M}\\d${
|
||||
`(?:^|\\s|\\()${escapeRegExp(
|
||||
this.options.trigger
|
||||
)}(${`[\\p{L}\/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import Keys from "~/editor/extensions/Keys";
|
||||
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
|
||||
import PasteHandler from "~/editor/extensions/PasteHandler";
|
||||
import PreventTab from "~/editor/extensions/PreventTab";
|
||||
import SmartText from "~/editor/extensions/SmartText";
|
||||
|
||||
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
||||
|
||||
export const withUIExtensions = (nodes: Nodes) => [
|
||||
...nodes,
|
||||
SmartText,
|
||||
PasteHandler,
|
||||
ClipboardTextSerializer,
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
MentionMenuExtension,
|
||||
FindAndReplaceExtension,
|
||||
HoverPreviewsExtension,
|
||||
// Order these default key handlers last
|
||||
PreventTab,
|
||||
Keys,
|
||||
];
|
||||
@@ -13,13 +13,11 @@ export default function attachmentMenuItems(
|
||||
name: "replaceAttachment",
|
||||
tooltip: dictionary.replaceAttachment,
|
||||
icon: <ReplaceIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "deleteAttachment",
|
||||
tooltip: dictionary.deleteAttachment,
|
||||
icon: <TrashIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -38,7 +38,12 @@ const Img = styled(Image)`
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
export default function blockMenuItems(
|
||||
dictionary: Dictionary,
|
||||
documentRef: React.RefObject<HTMLDivElement>
|
||||
): MenuItem[] {
|
||||
const documentWidth = documentRef.current?.clientWidth ?? 0;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "heading",
|
||||
@@ -119,7 +124,11 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
name: "table",
|
||||
title: dictionary.table,
|
||||
icon: <TableIcon />,
|
||||
attrs: { rowsCount: 3, colsCount: 3 },
|
||||
attrs: {
|
||||
rowsCount: 3,
|
||||
colsCount: 3,
|
||||
colWidth: documentWidth / 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
|
||||
@@ -33,14 +33,12 @@ export default function imageMenuItems(
|
||||
name: "alignLeft",
|
||||
tooltip: dictionary.alignLeft,
|
||||
icon: <AlignImageLeftIcon />,
|
||||
visible: true,
|
||||
active: isLeftAligned,
|
||||
},
|
||||
{
|
||||
name: "alignCenter",
|
||||
tooltip: dictionary.alignCenter,
|
||||
icon: <AlignImageCenterIcon />,
|
||||
visible: true,
|
||||
active: (state) =>
|
||||
isNodeActive(schema.nodes.image)(state) &&
|
||||
!isLeftAligned(state) &&
|
||||
@@ -51,19 +49,16 @@ export default function imageMenuItems(
|
||||
name: "alignRight",
|
||||
tooltip: dictionary.alignRight,
|
||||
icon: <AlignImageRightIcon />,
|
||||
visible: true,
|
||||
active: isRightAligned,
|
||||
},
|
||||
{
|
||||
name: "alignFullWidth",
|
||||
tooltip: dictionary.alignFullWidth,
|
||||
icon: <AlignFullWidthIcon />,
|
||||
visible: true,
|
||||
active: isFullWidthAligned,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "downloadImage",
|
||||
@@ -75,13 +70,11 @@ export default function imageMenuItems(
|
||||
name: "replaceImage",
|
||||
tooltip: dictionary.replaceImage,
|
||||
icon: <ReplaceIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "deleteImage",
|
||||
tooltip: dictionary.deleteImage,
|
||||
icon: <TrashIcon />,
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
DoneIcon,
|
||||
ExpandedIcon,
|
||||
InfoIcon,
|
||||
StarredIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { NoticeTypes } from "@shared/editor/nodes/Notice";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function noticeMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean | undefined,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node(-1);
|
||||
const currentStyle = node?.attrs.style as NoticeTypes;
|
||||
|
||||
const mapping = {
|
||||
[NoticeTypes.Info]: dictionary.infoNotice,
|
||||
[NoticeTypes.Warning]: dictionary.warningNotice,
|
||||
[NoticeTypes.Success]: dictionary.successNotice,
|
||||
[NoticeTypes.Tip]: dictionary.tipNotice,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: "container_notice",
|
||||
visible: !readOnly,
|
||||
label: mapping[currentStyle],
|
||||
icon: <ExpandedIcon />,
|
||||
children: [
|
||||
{
|
||||
name: NoticeTypes.Info,
|
||||
icon: <InfoIcon />,
|
||||
label: dictionary.infoNotice,
|
||||
active: () => currentStyle === NoticeTypes.Info,
|
||||
},
|
||||
{
|
||||
name: NoticeTypes.Success,
|
||||
icon: <DoneIcon />,
|
||||
label: dictionary.successNotice,
|
||||
active: () => currentStyle === NoticeTypes.Success,
|
||||
},
|
||||
{
|
||||
name: NoticeTypes.Warning,
|
||||
icon: <WarningIcon />,
|
||||
label: dictionary.warningNotice,
|
||||
active: () => currentStyle === NoticeTypes.Warning,
|
||||
},
|
||||
{
|
||||
name: NoticeTypes.Tip,
|
||||
icon: <StarredIcon />,
|
||||
label: dictionary.tipNotice,
|
||||
active: () => currentStyle === NoticeTypes.Tip,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useState, useLayoutEffect } from "react";
|
||||
|
||||
export default function useComponentSize(
|
||||
ref: React.RefObject<HTMLElement | null>
|
||||
): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach(({ target }) => {
|
||||
if (
|
||||
size.width !== target.clientWidth ||
|
||||
size.height !== target.clientHeight
|
||||
) {
|
||||
setSize({ width: target.clientWidth, height: target.clientHeight });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
setSize({
|
||||
width: ref.current?.clientWidth,
|
||||
height: ref.current?.clientHeight,
|
||||
});
|
||||
sizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => sizeObserver.disconnect();
|
||||
}, [ref, size.height, size.width]);
|
||||
|
||||
return size;
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import * as React from "react";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export function usePinnedDocuments(
|
||||
urlId: "home" | string,
|
||||
collectionId?: string
|
||||
) {
|
||||
type UrlId = "home" | string;
|
||||
|
||||
export const pinsCacheKey = (urlId: UrlId) => `pins-${urlId}`;
|
||||
|
||||
export function usePinnedDocuments(urlId: UrlId, collectionId?: string) {
|
||||
const { pins } = useStores();
|
||||
const [pinsCacheCount, setPinsCacheCount] = usePersistedState<number>(
|
||||
`pins-${urlId}`,
|
||||
pinsCacheKey(urlId),
|
||||
0
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ type RequestResponse<T> = {
|
||||
error: unknown;
|
||||
/** Whether the request is currently in progress. */
|
||||
loading: boolean;
|
||||
/** Whether the request has completed - useful to check if the request has completed at least once. */
|
||||
loaded: boolean;
|
||||
/** Function to start the request. */
|
||||
request: () => Promise<T | undefined>;
|
||||
};
|
||||
@@ -26,6 +28,7 @@ export default function useRequest<T = unknown>(
|
||||
const isMounted = useIsMounted();
|
||||
const [data, setData] = React.useState<T>();
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [loaded, setLoaded] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState();
|
||||
|
||||
const request = React.useCallback(async () => {
|
||||
@@ -36,6 +39,7 @@ export default function useRequest<T = unknown>(
|
||||
if (isMounted()) {
|
||||
setData(response);
|
||||
setError(undefined);
|
||||
setLoaded(true);
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
@@ -57,5 +61,5 @@ export default function useRequest<T = unknown>(
|
||||
}
|
||||
}, [request, makeRequestOnMount]);
|
||||
|
||||
return { data, loading, error, request };
|
||||
return { data, loading, loaded, error, request };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
|
||||
/**
|
||||
* Hook to calculate text statistics
|
||||
* @param text The string to calculate statistics for
|
||||
* @param selectedText A substring of the text to calculate statistics for
|
||||
* @returns An object containing total and selected statistics
|
||||
*/
|
||||
export function useTextStats(text: string, selectedText: string = "") {
|
||||
const numTotalWords = countWords(text);
|
||||
const regex = emojiRegex();
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
return {
|
||||
total: {
|
||||
words: numTotalWords,
|
||||
characters: text.length,
|
||||
emoji: matches.length ?? 0,
|
||||
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
|
||||
},
|
||||
selected: {
|
||||
words: countWords(selectedText),
|
||||
characters: selectedText.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
|
||||
// Hyphenated words are counted as two words
|
||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import { toast } from "sonner";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Collection from "~/models/Collection";
|
||||
import ContextMenu, { Placement } from "~/components/ContextMenu";
|
||||
@@ -31,10 +32,13 @@ import {
|
||||
createTemplate,
|
||||
archiveCollection,
|
||||
restoreCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
@@ -63,11 +67,28 @@ function CollectionMenu({
|
||||
placement,
|
||||
});
|
||||
const team = useCurrentTeam();
|
||||
const { documents, dialogs } = useStores();
|
||||
const { documents, dialogs, subscriptions } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
loading: subscriptionLoading,
|
||||
loaded: subscriptionLoaded,
|
||||
request: loadSubscription,
|
||||
} = useRequest(() =>
|
||||
subscriptions.fetchOne({
|
||||
collectionId: collection.id,
|
||||
event: SubscriptionType.Document,
|
||||
})
|
||||
);
|
||||
|
||||
const handlePointerEnter = React.useCallback(() => {
|
||||
if (!subscriptionLoading && !subscriptionLoaded) {
|
||||
void loadSubscription();
|
||||
}
|
||||
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
|
||||
|
||||
const handleExport = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Export collection"),
|
||||
@@ -157,6 +178,8 @@ function CollectionMenu({
|
||||
actionToMenuItem(restoreCollection, context),
|
||||
actionToMenuItem(starCollection, context),
|
||||
actionToMenuItem(unstarCollection, context),
|
||||
actionToMenuItem(subscribeCollection, context),
|
||||
actionToMenuItem(unsubscribeCollection, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
@@ -272,9 +295,15 @@ function CollectionMenu({
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
<MenuButton {...menu} onPointerEnter={handlePointerEnter}>
|
||||
{label}
|
||||
</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<OverflowMenuButton
|
||||
aria-label={t("Show menu")}
|
||||
{...menu}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
|
||||
+42
-14
@@ -1,6 +1,6 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -12,7 +12,7 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { SubscriptionType, UserPreference } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Document from "~/models/Document";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
@@ -57,7 +57,7 @@ import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { MenuItem, MenuItemButton } from "~/types";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { MenuContext, useMenuContext } from "./MenuContext";
|
||||
|
||||
@@ -92,22 +92,38 @@ type MenuTriggerProps = {
|
||||
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { subscriptions } = useStores();
|
||||
const { subscriptions, pins } = useStores();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
|
||||
const { data, loading, error, request } = useRequest(() =>
|
||||
subscriptions.fetchPage({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
})
|
||||
const {
|
||||
loading: auxDataLoading,
|
||||
loaded: auxDataLoaded,
|
||||
request: auxDataRequest,
|
||||
} = useRequest(() =>
|
||||
Promise.all([
|
||||
subscriptions.fetchOne({
|
||||
documentId: document.id,
|
||||
event: SubscriptionType.Document,
|
||||
}),
|
||||
document.collectionId
|
||||
? subscriptions.fetchOne({
|
||||
collectionId: document.collectionId,
|
||||
event: SubscriptionType.Document,
|
||||
})
|
||||
: noop,
|
||||
pins.fetchOne({
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId ?? null,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const handlePointerEnter = React.useCallback(() => {
|
||||
if (isUndefined(data ?? error) && !loading) {
|
||||
void request();
|
||||
if (!auxDataLoading && !auxDataLoaded) {
|
||||
void auxDataRequest();
|
||||
void document.loadRelations();
|
||||
}
|
||||
}, [data, error, loading, request, document]);
|
||||
}, [auxDataLoading, auxDataLoaded, auxDataRequest, document]);
|
||||
|
||||
return label ? (
|
||||
<MenuButton
|
||||
@@ -245,8 +261,20 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
},
|
||||
actionToMenuItem(starDocument, context),
|
||||
actionToMenuItem(unstarDocument, context),
|
||||
actionToMenuItem(subscribeDocument, context),
|
||||
actionToMenuItem(unsubscribeDocument, context),
|
||||
{
|
||||
...actionToMenuItem(subscribeDocument, context),
|
||||
disabled: collection?.isSubscribed,
|
||||
tooltip: collection?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined,
|
||||
} as MenuItemButton,
|
||||
{
|
||||
...actionToMenuItem(unsubscribeDocument, context),
|
||||
disabled: collection?.isSubscribed,
|
||||
tooltip: collection?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined,
|
||||
} as MenuItemButton,
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Find and replace")}…`,
|
||||
|
||||
@@ -24,7 +24,6 @@ const NotificationMenu: React.FC = () => {
|
||||
{
|
||||
type: "button",
|
||||
title: t("Notification settings"),
|
||||
visible: true,
|
||||
onClick: () => performAction(navigateToNotificationSettings, context),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TableOfContentsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
@@ -23,28 +24,29 @@ function TableOfContentsMenu() {
|
||||
Infinity
|
||||
);
|
||||
|
||||
// @ts-expect-error check
|
||||
const items: MenuItem[] = React.useMemo(() => {
|
||||
const i = [
|
||||
{
|
||||
type: "heading",
|
||||
visible: true,
|
||||
title: t("Contents"),
|
||||
},
|
||||
...headings.map((heading) => ({
|
||||
type: "link",
|
||||
href: `#${heading.id}`,
|
||||
title: t(heading.title),
|
||||
title: <HeadingWrapper>{t(heading.title)}</HeadingWrapper>,
|
||||
level: heading.level - minHeading,
|
||||
})),
|
||||
];
|
||||
] as MenuItem[];
|
||||
|
||||
if (i.length === 1) {
|
||||
i.push({
|
||||
type: "link",
|
||||
href: "#",
|
||||
title: t("Headings you add to the document will appear here"),
|
||||
// @ts-expect-error check
|
||||
title: (
|
||||
<HeadingWrapper>
|
||||
{t("Headings you add to the document will appear here")}
|
||||
</HeadingWrapper>
|
||||
),
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
@@ -71,4 +73,10 @@ function TableOfContentsMenu() {
|
||||
);
|
||||
}
|
||||
|
||||
const HeadingWrapper = styled.div`
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
|
||||
export default observer(TableOfContentsMenu);
|
||||
|
||||
@@ -6,11 +6,16 @@ import Field from "./decorators/Field";
|
||||
class ApiKey extends ParanoidModel {
|
||||
static modelName = "ApiKey";
|
||||
|
||||
/** The user chosen name of the API key. */
|
||||
/** The human-readable name of this API key */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/** A list of scopes that this API key has access to. If empty, the key has full access. */
|
||||
@Field
|
||||
@observable
|
||||
scope?: string[];
|
||||
|
||||
/** An optional datetime that the API key expires. */
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -5,8 +5,6 @@ import Field from "./decorators/Field";
|
||||
class AuthenticationProvider extends Model {
|
||||
static modelName = "AuthenticationProvider";
|
||||
|
||||
id: string;
|
||||
|
||||
displayName: string;
|
||||
|
||||
name: string;
|
||||
|
||||
@@ -129,6 +129,16 @@ export default class Collection extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there is a subscription for this collection in the store.
|
||||
*
|
||||
* @returns True if there is a subscription, false otherwise.
|
||||
*/
|
||||
@computed
|
||||
get isSubscribed(): boolean {
|
||||
return !!this.store.rootStore.subscriptions.getByCollectionId(this.id);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isManualSort(): boolean {
|
||||
return this.sort.field === "index";
|
||||
@@ -181,6 +191,11 @@ export default class Collection extends ParanoidModel {
|
||||
return !this.isArchived && !this.isDeleted;
|
||||
}
|
||||
|
||||
@computed
|
||||
get hasDocuments() {
|
||||
return !!this.documents?.length;
|
||||
}
|
||||
|
||||
fetchDocuments = async (options?: { force: boolean }) => {
|
||||
if (this.isFetching) {
|
||||
return;
|
||||
@@ -258,6 +273,36 @@ export default class Collection extends ParanoidModel {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the document identified by the given id to the collection in
|
||||
* memory. Does not add the document to the database or store.
|
||||
*
|
||||
* @param document The document to add.
|
||||
* @param parentDocumentId The id of the document to add the new document to.
|
||||
*/
|
||||
@action
|
||||
addDocument(document: Document, parentDocumentId?: string) {
|
||||
if (!this.documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parentDocumentId) {
|
||||
this.documents.unshift(document.asNavigationNode);
|
||||
return;
|
||||
}
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === parentDocumentId) {
|
||||
node.children = [document.asNavigationNode, ...(node.children ?? [])];
|
||||
} else {
|
||||
travelNodes(node.children);
|
||||
}
|
||||
});
|
||||
|
||||
travelNodes(this.documents);
|
||||
}
|
||||
|
||||
@action
|
||||
updateIndex(index: string) {
|
||||
this.index = index;
|
||||
@@ -341,6 +386,22 @@ export default class Collection extends ParanoidModel {
|
||||
@action
|
||||
unstar = async () => this.store.unstar(this);
|
||||
|
||||
/**
|
||||
* Subscribes the current user to this collection.
|
||||
*
|
||||
* @returns A promise that resolves when the subscription is created.
|
||||
*/
|
||||
@action
|
||||
subscribe = () => this.store.subscribe(this);
|
||||
|
||||
/**
|
||||
* Unsubscribes the current user from this collection.
|
||||
*
|
||||
* @returns A promise that resolves when the subscription is destroyed.
|
||||
*/
|
||||
@action
|
||||
unsubscribe = () => this.store.unsubscribe(this);
|
||||
|
||||
archive = () => this.store.archive(this);
|
||||
|
||||
restore = () => this.store.restore(this);
|
||||
|
||||
+15
-7
@@ -27,6 +27,7 @@ import { client } from "~/utils/ApiClient";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Collection from "./Collection";
|
||||
import Notification from "./Notification";
|
||||
import Pin from "./Pin";
|
||||
import View from "./View";
|
||||
import ArchivableModel from "./base/ArchivableModel";
|
||||
import Field from "./decorators/Field";
|
||||
@@ -307,9 +308,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
*/
|
||||
@computed
|
||||
get isSubscribed(): boolean {
|
||||
return !!this.store.rootStore.subscriptions.orderedData.find(
|
||||
(subscription) => subscription.documentId === this.id
|
||||
);
|
||||
return !!this.store.rootStore.subscriptions.getByDocumentId(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,7 +449,11 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
restore = (options?: { revisionId?: string; collectionId?: string }) =>
|
||||
this.store.restore(this, options);
|
||||
|
||||
unpublish = () => this.store.unpublish(this);
|
||||
unpublish = (
|
||||
options: { detach?: boolean } = {
|
||||
detach: false,
|
||||
}
|
||||
) => this.store.unpublish(this, options);
|
||||
|
||||
@action
|
||||
enableEmbeds = () => {
|
||||
@@ -463,12 +466,17 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
};
|
||||
|
||||
@action
|
||||
pin = (collectionId?: string | null) =>
|
||||
this.store.rootStore.pins.create({
|
||||
pin = async (collectionId?: string | null) => {
|
||||
const pin = new Pin({}, this.store.rootStore.pins);
|
||||
|
||||
await pin.save({
|
||||
documentId: this.id,
|
||||
...(collectionId ? { collectionId } : {}),
|
||||
});
|
||||
|
||||
return pin;
|
||||
};
|
||||
|
||||
@action
|
||||
unpin = (collectionId?: string) => {
|
||||
const pin = this.store.rootStore.pins.orderedData.find(
|
||||
@@ -501,7 +509,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
* @returns A promise that resolves when the subscription is destroyed.
|
||||
*/
|
||||
@action
|
||||
unsubscribe = (userId: string) => this.store.unsubscribe(userId, this);
|
||||
unsubscribe = () => this.store.unsubscribe(this);
|
||||
|
||||
@action
|
||||
view = () => {
|
||||
|
||||
@@ -7,8 +7,6 @@ import Relation from "./decorators/Relation";
|
||||
class Event<T extends Model> extends Model {
|
||||
static modelName = "Event";
|
||||
|
||||
id: string;
|
||||
|
||||
name: string;
|
||||
|
||||
modelId: string | undefined;
|
||||
|
||||
@@ -11,8 +11,6 @@ import Model from "./base/Model";
|
||||
class FileOperation extends Model {
|
||||
static modelName = "FileOperation";
|
||||
|
||||
id: string;
|
||||
|
||||
@observable
|
||||
state: FileOperationState;
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ import Relation from "~/models/decorators/Relation";
|
||||
class Integration<T = unknown> extends Model {
|
||||
static modelName = "Integration";
|
||||
|
||||
id: string;
|
||||
|
||||
type: IntegrationType;
|
||||
|
||||
service: IntegrationService;
|
||||
|
||||
@@ -9,8 +9,6 @@ import Relation from "./decorators/Relation";
|
||||
class Membership extends Model {
|
||||
static modelName = "Membership";
|
||||
|
||||
id: string;
|
||||
|
||||
userId: string;
|
||||
|
||||
@Relation(() => User, { onDelete: "cascade" })
|
||||
|
||||
+31
-1
@@ -1,15 +1,21 @@
|
||||
import { observable } from "mobx";
|
||||
import PinsStore from "~/stores/PinsStore";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import { pinsCacheKey } from "~/hooks/usePinnedDocuments";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterCreate, AfterDelete, AfterRemove } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Pin extends Model {
|
||||
static modelName = "Pin";
|
||||
|
||||
store: PinsStore;
|
||||
|
||||
/** The collection ID that the document is pinned to. If empty the document is pinned to home. */
|
||||
collectionId: string;
|
||||
collectionId: string | null;
|
||||
|
||||
/** The collection that the document is pinned to. If empty the document is pinned to home. */
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
@@ -26,6 +32,30 @@ class Pin extends Model {
|
||||
@observable
|
||||
@Field
|
||||
index: string;
|
||||
|
||||
@AfterCreate
|
||||
@AfterDelete
|
||||
@AfterRemove
|
||||
static updateCache(model: Pin) {
|
||||
const pins = model.store;
|
||||
|
||||
// Pinned to home
|
||||
if (!model.collectionId) {
|
||||
setPersistedState(pinsCacheKey("home"), pins.home.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pinned to collection
|
||||
const collection = pins.rootStore.collections.get(model.collectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedState(
|
||||
pinsCacheKey(collection.urlId),
|
||||
pins.inCollection(collection.id).length
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Pin;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observable } from "mobx";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
@@ -25,6 +26,13 @@ class Subscription extends Model {
|
||||
@Relation(() => Document, { onDelete: "cascade" })
|
||||
document?: Document;
|
||||
|
||||
/** The collection ID being subscribed to */
|
||||
collectionId: string;
|
||||
|
||||
/** The collection being subscribed to */
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
collection?: Collection;
|
||||
|
||||
/** The event being subscribed to */
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -7,8 +7,6 @@ import Relation from "./decorators/Relation";
|
||||
class View extends Model {
|
||||
static modelName = "View";
|
||||
|
||||
id: string;
|
||||
|
||||
documentId: string;
|
||||
|
||||
@Relation(() => Document)
|
||||
|
||||
@@ -72,8 +72,7 @@ function AuthenticatedRoutes() {
|
||||
<Redirect exact from="/templates" to={settingsPath("templates")} />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collection/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collection/:id" component={Collection} />
|
||||
<Route exact path="/collection/:id/:tab?" component={Collection} />
|
||||
<Route exact path="/doc/new" component={DocumentNew} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
|
||||
@@ -22,6 +22,7 @@ type Props = {
|
||||
|
||||
function ApiKeyNew({ onSubmit }: Props) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [scope, setScope] = React.useState("");
|
||||
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
|
||||
ExpiryType.Week
|
||||
);
|
||||
@@ -51,6 +52,10 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
setName(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleScopeChange = React.useCallback((event) => {
|
||||
setScope(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleExpiryTypeChange = React.useCallback((value: string) => {
|
||||
const expiry = value as ExpiryType;
|
||||
setExpiryType(expiry);
|
||||
@@ -70,6 +75,7 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
await apiKeys.create({
|
||||
name,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
scope: scope ? scope.split(" ") : undefined,
|
||||
});
|
||||
toast.success(
|
||||
t(
|
||||
@@ -83,20 +89,16 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[t, name, expiresAt, onSubmit, apiKeys]
|
||||
[t, name, scope, expiresAt, onSubmit, apiKeys]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
|
||||
)}
|
||||
</Text>
|
||||
<Flex column>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("Development")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
minLength={ApiKeyValidation.minNameLength}
|
||||
@@ -105,6 +107,20 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Scopes")}
|
||||
placeholder="documents.info"
|
||||
onChange={handleScopeChange}
|
||||
value={scope}
|
||||
flex
|
||||
/>
|
||||
<Text type="secondary" size="small" as="p">
|
||||
{t(
|
||||
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access"
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Flex align="center" gap={16}>
|
||||
<StyledExpirySelect
|
||||
ariaLabel={t("Expiration")}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user