Compare commits

..

10 Commits

Author SHA1 Message Date
Tom Moor f8cd5f3e4b fix: Address PR feedback 2025-01-28 19:38:46 -05:00
Tom Moor 39852470cc Update API key list UI 2025-01-27 22:13:47 -05:00
Tom Moor 9a03e1c947 Update API key UI 2025-01-27 22:08:53 -05:00
Tom Moor cfaa08403a Store scopes with full url 2025-01-27 21:35:42 -05:00
Tom Moor 99bc586f34 Switch to storing array 2025-01-27 21:22:54 -05:00
Tom Moor 75838bb311 Merge branch 'main' into tom/api-scopes 2025-01-27 20:30:30 -05:00
Tom Moor 8b3115be9a test 2025-01-25 00:30:00 -05:00
Tom Moor 7782292500 Allow creation 2025-01-25 00:24:13 -05:00
Tom Moor a7da968499 Add scope restriction 2025-01-24 23:24:49 -05:00
Tom Moor a95005776f scope storage 2025-01-24 22:45:32 -05:00
528 changed files with 6940 additions and 28851 deletions
+183
View File
@@ -0,0 +1,183 @@
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: /.*/
+7
View File
@@ -0,0 +1,7 @@
#!/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
-3
View File
@@ -1,8 +1,5 @@
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
+7 -7
View File
@@ -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@postgres:5432/outline
DATABASE_URL=postgres://user:pass@localhost: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://redis:6379
REDIS_URL=redis://localhost: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.
@@ -147,10 +147,6 @@ DISCORD_SERVER_ID=
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
@@ -205,10 +201,14 @@ SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_SERVICE=
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
-2
View File
@@ -15,8 +15,6 @@ requestInfoDefaultTitles:
requestInfoLabelToAdd: more information needed
requestInfoUserstoExclude:
- tommoor
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
-163
View File
@@ -1,163 +0,0 @@
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=8192
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 }}
-212
View File
@@ -1,212 +0,0 @@
name: Docker
on:
push:
tags:
- "v*"
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- 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
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- 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
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
-30
View File
@@ -1,30 +0,0 @@
name: Lint
on:
pull_request:
branches: [ main ]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
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 --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: 'Applied automatic fixes'
+1 -1
View File
@@ -24,6 +24,6 @@ jobs:
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned,A1"
exempt-issue-labels: "security,pinned"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
+2 -3
View File
@@ -1,6 +1,5 @@
ARG APP_PATH=/opt/outline
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
FROM outlinewiki/outline-base AS base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -31,7 +30,7 @@ RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
+1 -4
View File
@@ -1,14 +1,11 @@
ARG APP_PATH=/opt/outline
FROM node:20 AS deps
FROM node:20-slim AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.83.0
The Licensed Work is (c) 2025 General Outline, Inc.
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 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: 2029-04-11
Change Date: 2028-11-11
Change License: Apache License, Version 2.0
-4
View File
@@ -171,10 +171,6 @@
"description": "smtp.example.com (optional)",
"required": false
},
"SMTP_SERVICE": {
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
"required": false
},
"SMTP_PORT": {
"description": "1234 (optional)",
"required": false
+15 -78
View File
@@ -8,13 +8,12 @@ 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";
@@ -61,7 +60,7 @@ export const createCollection = createAction({
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event, stores }) => {
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
@@ -77,10 +76,10 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -103,10 +102,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -134,7 +133,7 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -149,7 +148,7 @@ export const searchInCollection = createAction({
},
perform: ({ activeCollectionId }) => {
history.push(searchPath({ collectionId: activeCollectionId }));
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
},
});
@@ -159,7 +158,7 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -169,7 +168,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -186,7 +185,7 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -196,7 +195,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -206,66 +205,6 @@ 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",
@@ -338,13 +277,13 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
perform: ({ activeCollectionId, t }) => {
if (!activeCollectionId) {
return;
}
@@ -372,7 +311,7 @@ export const createTemplate = createAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
@@ -392,7 +331,5 @@ export const rootCollectionActions = [
createCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection,
];
-34
View File
@@ -2,7 +2,6 @@ import copy from "copy-to-clipboard";
import {
BeakerIcon,
CopyIcon,
EditIcon,
ToolsIcon,
TrashIcon,
UserIcon,
@@ -84,38 +83,6 @@ 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 />,
@@ -202,7 +169,6 @@ export const developer = createAction({
createToast,
createTestUsers,
clearIndexedDB,
startTyping,
],
});
+8 -54
View File
@@ -29,7 +29,6 @@ import {
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -126,20 +125,6 @@ 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",
@@ -334,7 +319,6 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
@@ -363,9 +347,8 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -375,7 +358,7 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
await document?.unsubscribe();
await document?.unsubscribe(currentUserId);
toast.success(t("Unsubscribed from document notifications"));
},
@@ -511,25 +494,6 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
@@ -575,12 +539,7 @@ export const copyDocument = createAction({
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
@@ -708,7 +667,6 @@ export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -718,7 +676,7 @@ export const searchInDocument = createAction({
return !!document?.isActive;
},
perform: ({ activeDocumentId }) => {
history.push(searchPath({ documentId: activeDocumentId }));
history.push(searchPath(undefined, { documentId: activeDocumentId }));
},
});
@@ -831,15 +789,15 @@ export const openRandomDocument = createAction({
},
});
export const searchDocumentsForQuery = (query: string) =>
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query })),
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -1221,8 +1179,6 @@ export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createDraftDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
importDocument,
@@ -1230,14 +1186,12 @@ export const rootDocumentActions = [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
+1 -10
View File
@@ -50,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query: searchQuery.query })),
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToDrafts = createAction({
@@ -62,15 +62,6 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath()),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
-2
View File
@@ -2,8 +2,6 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
+2 -1
View File
@@ -5,6 +5,7 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default observer(Authenticated);
+2 -15
View File
@@ -1,22 +1,15 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -55,7 +48,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
@@ -80,11 +72,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
+18 -16
View File
@@ -7,10 +7,9 @@ export enum AvatarSize {
Small = 16,
Toast = 18,
Medium = 24,
Large = 28,
XLarge = 32,
XXLarge = 48,
Upload = 64,
Large = 32,
XLarge = 48,
XXLarge = 64,
}
export interface IAvatar {
@@ -21,37 +20,36 @@ 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;
/** Optional click handler */
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Optional class name */
className?: string;
/** Optional style */
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const { model, style, ...rest } = props;
const { showBorder, 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} {...rest} />
<CircleImg
onError={handleError}
src={src}
$showBorder={showBorder}
{...rest}
/>
) : model ? (
<Initials color={model.color} {...rest}>
<Initials color={model.color} $showBorder={showBorder} {...rest}>
{model.initial}
</Initials>
) : (
<Initials {...rest} />
<Initials $showBorder={showBorder} {...rest} />
)}
</Relative>
);
@@ -67,11 +65,15 @@ const Relative = styled.div`
flex-shrink: 0;
`;
const CircleImg = styled.img<{ size: number }>`
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
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 -53
View File
@@ -5,45 +5,17 @@ import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
import Avatar from "./Avatar";
/**
* Props for the AvatarWithPresence component
*/
type Props = {
/** The user to display the avatar for */
user: User;
/** Whether the user is currently present in the document */
isPresent: boolean;
/** Whether the user is currently editing the document */
isEditing: boolean;
/** Whether the user is currently observing the document */
isObserving: boolean;
/** Whether this avatar represents the current user */
isCurrentUser: boolean;
/** Optional click handler for the avatar */
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Size of the avatar, defaults to AvatarSize.Large */
size?: AvatarSize;
/** Optional inline styles to apply to the avatar wrapper */
style?: React.CSSProperties;
};
/**
* AvatarWithPresence component displays a user's avatar with visual indicators
* for their current status (present, editing, observing).
*
* The component shows different visual states:
* - Present users have full opacity
* - Non-present users have reduced opacity
* - Observing users have a colored border matching their user color
* - Hovering shows a colored border
*
* A tooltip displays the user's name and current status.
*
* @param props - Component properties
* @returns React component
*/
function AvatarWithPresence({
onClick,
user,
@@ -51,8 +23,6 @@ function AvatarWithPresence({
isEditing,
isObserving,
isCurrentUser,
size = AvatarSize.Large,
style,
}: Props) {
const { t } = useTranslation();
const status = isPresent
@@ -77,47 +47,29 @@ function AvatarWithPresence({
}
placement="bottom"
>
<AvatarPresence
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={size} />
</AvatarPresence>
<Avatar model={user} onClick={onClick} size={32} />
</AvatarWrapper>
</Tooltip>
</>
);
}
/**
* Centered container for tooltip content
*/
const Centered = styled.div`
text-align: center;
`;
/**
* Props for the AvatarPresence styled component
*/
type AvatarWrapperProps = {
/** Whether the user is currently present */
$isPresent: boolean;
/** Whether the user is currently observing */
$isObserving: boolean;
/** The user's color for border highlighting */
$color: string;
};
/**
* Styled component that wraps the Avatar and provides visual indicators
* for the user's presence status.
*
* - Adjusts opacity based on presence
* - Adds colored borders for observing users
* - Handles hover effects
*/
const AvatarPresence = styled.div<AvatarWrapperProps>`
const AvatarWrapper = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
+7 -13
View File
@@ -1,33 +1,27 @@
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: ${(props) =>
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
? s("black50")
: s("white75")};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
color: ${s("white75")};
background-color: ${(props) => props.color};
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;
// adjust font size down for each additional character
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
font-size: ${(props) => props.size / 2}px;
font-weight: 500;
`;
-4
View File
@@ -80,10 +80,6 @@ const RealButton = styled(ActionButton)<RealProps>`
} 0 0 0 1px inset;
}
&:focus-visible {
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
}
&:disabled {
color: ${props.theme.textTertiary};
background: none;
+36 -43
View File
@@ -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 { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import { AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
() =>
orderBy(
filter(
users.all,
users.orderedData,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
),
[document.collaboratorIds, users.all, presentIds]
[document.collaboratorIds, users.orderedData, presentIds]
);
// load any users we don't yet have in memory
@@ -78,56 +78,49 @@ 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) * AvatarSize.Large}
height={AvatarSize.Large}
width={Math.min(collaborators.length, limit) * 32}
height={32}
{...popoverProps}
>
<Facepile
size={AvatarSize.Large}
limit={limit}
overflow={Math.max(0, collaborators.length - limit)}
overflow={collaborators.length - limit}
users={collaborators}
renderAvatar={renderAvatar}
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
}
/>
);
}}
/>
</NudeButton>
)}
+185 -35
View File
@@ -1,22 +1,30 @@
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 { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
const extensions = withUIExtensions(richExtensions);
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
type Props = {
collection: Collection;
@@ -25,9 +33,33 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
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) => {
@@ -35,6 +67,7 @@ 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;
@@ -43,45 +76,162 @@ function CollectionDescription({ collection }: Props) {
[collection, t]
);
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]
const handleChange = React.useCallback(
async (getValue) => {
setDirty(true);
await handleSave(getValue);
},
[handleSave]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<>
{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}
userId={user.id}
editorStyle={editorStyle}
embedsDisabled
/>
<div ref={childRef} />
</React.Suspense>
<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>
)}
</>
</MaxHeight>
);
}
const Placeholder = styled(Text)`
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)`
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 -2
View File
@@ -3,7 +3,6 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -71,7 +70,7 @@ function CommandBarItem(
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
<Key key={key}>{key}</Key>
))}
</React.Fragment>
))}
+6 -25
View File
@@ -1,12 +1,10 @@
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. */
@@ -32,29 +30,12 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
};
const handleSubmit = async () => {
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();
}
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
dialogs.closeAllModals();
};
return (
+4 -10
View File
@@ -4,12 +4,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -20,21 +14,21 @@ function ConnectionStatus() {
const { t } = useTranslation();
const codeToMessage = {
[DocumentTooLarge.code]: {
1009: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
[AuthenticationFailed.code]: {
4401: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
[AuthorizationFailed.code]: {
4403: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
[TooManyConnections.code]: {
4503: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
-6
View File
@@ -13,7 +13,6 @@ import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
@@ -32,7 +31,6 @@ type Props = {
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
@@ -92,7 +90,6 @@ const MenuItem = (
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
@@ -161,9 +158,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
+1 -10
View File
@@ -20,7 +20,6 @@ import {
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
@@ -168,7 +167,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
if (item.type === "button") {
const menuItem = (
return (
<MenuItem
as="button"
id={`${item.title}-${index}`}
@@ -183,14 +182,6 @@ 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") {
+29 -14
View File
@@ -2,11 +2,16 @@ import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Optional } from "utility-types";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = {
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -14,6 +19,7 @@ type DefaultCollectionInputSelectProps = {
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -41,26 +47,36 @@ const DefaultCollectionInputSelect = ({
void fetchData();
}, [fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo(
const options = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
type: "item",
label: collection.name,
label: (
<Flex align="center">
<IconWrapper>
<CollectionIcon collection={collection} />
</IconWrapper>
{collection.name}
</Flex>
),
value: collection.id,
icon: <CollectionIcon collection={collection} />,
},
],
[
{
type: "item",
label: t("Home"),
label: (
<Flex align="center">
<IconWrapper>
<HomeIcon />
</IconWrapper>
{t("Home")}
</Flex>
),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
]
),
[collections.nonPrivate, t]
);
@@ -70,14 +86,13 @@ const DefaultCollectionInputSelect = ({
}
return (
<InputSelectNew
options={options}
<InputSelect
value={defaultCollectionId ?? "home"}
options={options}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short
{...rest}
/>
);
};
+1 -4
View File
@@ -23,10 +23,7 @@ function Dialogs() {
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
style={modal.style}
>
+4 -5
View File
@@ -105,15 +105,14 @@ 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} /> {title}
<StyledIcon value={node.icon} color={node.color} /> {node.title}
</>
) : (
title
node.title
),
to: {
pathname: node.url,
@@ -122,7 +121,7 @@ function DocumentBreadcrumb(
});
});
return output;
}, [t, path, category, sidebarContext, collectionNode]);
}, [path, category, sidebarContext, collectionNode]);
if (!collections.isLoaded) {
return null;
@@ -135,7 +134,7 @@ function DocumentBreadcrumb(
{path.slice(0, -1).map((node: NavigationNode) => (
<React.Fragment key={node.id}>
<SmallSlash />
{node.title || t("Untitled")}
{node.title}
</React.Fragment>
))}
</>
+3 -14
View File
@@ -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, NavigationNodeType } from "@shared/types";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
@@ -78,10 +78,6 @@ 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"], {
@@ -130,18 +126,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.filter((item) => item.type === "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) => {
@@ -315,7 +304,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={(node.depth ?? 0) - baseDepth}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
+2 -2
View File
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
) {
const { t } = useTranslation();
const OFFSET = 12;
const DISCLOSURE = 20;
const ICON_SIZE = 24;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
return (
<Node
+2 -8
View File
@@ -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, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -71,13 +71,7 @@ function DocumentViews({ document, isOpen }: Props) {
key={model.id}
title={model.name}
subtitle={subtitle}
image={
<Avatar
key={model.id}
model={model}
size={AvatarSize.Large}
/>
}
image={<Avatar key={model.id} model={model} size={32} />}
border={false}
small
/>
+26 -55
View File
@@ -6,9 +6,7 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { getDataTransferFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
@@ -19,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, uploadFileFromUrl } from "~/utils/files";
import { uploadFile } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -51,15 +49,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const previousCommentIds = React.useRef<string[]>();
const handleUploadFile = React.useCallback(
async (file: File | string) => {
const options = {
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
preset: AttachmentPreset.DocumentAttachment,
};
const result =
file instanceof File
? await uploadFile(file, options)
: await uploadFileFromUrl(file, options);
});
return result.url;
},
[id]
@@ -185,54 +179,31 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[updateComments]
);
const paragraphs = React.useMemo(() => {
if (props.readOnly && typeof props.value === "object") {
return ProsemirrorHelper.getPlainParagraphs(props.value);
}
return undefined;
}, [props.readOnly, props.value]);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
<p key={index} dir="auto">
{paragraph.content?.map((content) => content.text)}
</p>
))}
</div>
</EditorContainer>
) : (
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
)}
{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}
/>
)}
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
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}
/>
)}
</>
</ErrorBoundary>
);
+5 -5
View File
@@ -1,9 +1,9 @@
import styled from "styled-components";
import Text from "~/components/Text";
import { s } from "@shared/styles";
const Empty = styled(Text).attrs({
type: "tertiary",
selectable: false,
})``;
const Empty = styled.p`
color: ${s("textTertiary")};
user-select: none;
`;
export default Empty;
+4 -5
View File
@@ -7,7 +7,6 @@ import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
@@ -78,9 +77,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Module failed to load")} />
<Heading>
<h1>
<Trans>Loading Failed</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
@@ -102,9 +101,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Something Unexpected Happened")} />
<Heading>
<h1>
<Trans>Something Unexpected Happened</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
+78 -151
View File
@@ -7,9 +7,6 @@ import {
PublishIcon,
MoveIcon,
UnpublishIcon,
RestoreIcon,
UserIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -17,61 +14,32 @@ 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 { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions } from "~/components/List/Item";
import Event from "~/models/Event";
import { Avatar } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } 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;
event: Event<Document>;
latest?: boolean;
};
const EventListItem = ({ event, document, ...rest }: Props) => {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
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 { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = React.useRef(false);
const opts = {
userName: actor?.name,
userName: event.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);
@@ -82,32 +50,23 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
};
const prefetchRevision = async () => {
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
if (event.name === "revisions.create" && event.modelId) {
await revisions.fetch(event.modelId);
}
};
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = event.latest ? (
meta = latest ? (
<>
{t("Current version")} &middot; {actor?.name}
{t("Current version")} &middot; {event.actor.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
pathname: documentHistoryPath(document, event.modelId || "latest"),
state: {
sidebarContext,
retainScrollPosition: true,
@@ -116,51 +75,47 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
break;
case "documents.archive":
icon = <ArchiveIcon />;
icon = <ArchiveIcon size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
icon = <RestoreIcon />;
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon />;
icon = <TrashIcon size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.add_user":
icon = <UserIcon />;
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: user?.name ?? t("a user"),
addedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
icon = <CrossIcon />;
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: user?.name ?? t("a user"),
removedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.restore":
icon = <RestoreIcon />;
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon />;
icon = <PublishIcon size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon />;
icon = <UnpublishIcon size={16} />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon />;
icon = <MoveIcon size={16} />;
meta = t("{{userName}} moved", opts);
break;
@@ -181,8 +136,8 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
to = undefined;
}
return event.name === "revisions.create" ? (
<RevisionItem
return (
<BaseItem
small
exact
to={to}
@@ -198,12 +153,17 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={actor} size={AvatarSize.Large} />}
subtitle={meta}
image={<Avatar model={event.actor} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision && isActive && !event.latest ? (
isRevision && isActive && event.modelId && !latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.id} />
<RevisionMenu document={document} revisionId={event.modelId} />
</StyledEventBoundary>
) : undefined
}
@@ -211,100 +171,63 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
ref={ref}
{...rest}
/>
) : (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={event.createdAt} relative shorten addSuffix />
</Text>
</EventItem>
);
};
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: ${(props) => (props.theme.isDark ? "lighten" : "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: ${(props) => (props.theme.isDark ? "lighten" : "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 BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement>
) {
return <ListItem to={to} ref={ref} {...rest} />;
});
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const RevisionItem = styled(Item)`
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ItemStyle = css`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
${lineStyle}
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;
}
${Actions} {
opacity: 0.5;
@@ -315,4 +238,8 @@ const RevisionItem = styled(Item)`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
export default observer(EventListItem);
+38 -62
View File
@@ -1,26 +1,17 @@
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;
/** A component to render the avatar, defaults to Avatar. */
renderAvatar?: React.ComponentType<
React.ComponentProps<typeof Avatar> & {
model: User;
}
>;
renderAvatar?: (user: User) => React.ReactNode;
};
function Facepile({
@@ -28,70 +19,55 @@ function Facepile({
overflow = 0,
size = AvatarSize.Large,
limit = 8,
renderAvatar = Avatar,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
const filtered = users.filter(Boolean).slice(-limit);
const Component = renderAvatar;
return (
<Avatars {...rest}>
{overflow > 0 && (
<Initials size={size} content={String(overflow)}>
{users.length ? "+" : ""}
{overflow}
</Initials>
<More size={size}>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
</More>
)}
{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} />
{users
.filter(Boolean)
.slice(0, limit)
.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
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>
);
function DefaultAvatar(user: User) {
return <Avatar model={user} size={AvatarSize.Large} />;
}
function clipPathId(size: number) {
return `facepile-${size}`;
}
const AvatarWrapper = styled.div`
margin-right: -8px;
const SVG = styled.svg`
position: absolute;
top: 0;
left: 0;
&: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 Avatars = styled(Flex)`
+8
View File
@@ -0,0 +1,8 @@
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
export default Fade;
-28
View File
@@ -1,28 +0,0 @@
import React from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
/**
* Fade in animation for a component.
*
* @param timing - The duration of the fade in animation, default is 250ms.
*/
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
type Props = {
children?: JSX.Element | null;
/** If true, children will be animated. */
animate: boolean;
};
/**
* Wraps children in a <Fade> if loading is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = React.useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
export default Fade;
+16 -13
View File
@@ -23,6 +23,7 @@ type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
@@ -34,6 +35,7 @@ const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
showFilter,
@@ -52,7 +54,9 @@ const FilterOptions = ({
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems.map((selected) => selected.label).join(", ")
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
: "";
const renderItem = React.useCallback(
@@ -66,7 +70,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon}
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -159,16 +163,10 @@ const FilterOptions = ({
const showFilterInput = showFilter || options.length > 10;
return (
<>
<div>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
disclosure
>
<StyledButton {...props} className={className} neutral disclosure>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
@@ -195,7 +193,7 @@ const FilterOptions = ({
/>
)}
</ContextMenu>
</>
</div>
);
};
@@ -233,7 +231,6 @@ const SearchInput = styled(Input)`
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
margin: 0;
}
${NativeInput} {
@@ -270,9 +267,15 @@ export const StyledButton = styled(Button)`
}
${Inner} {
line-height: 28px;
line-height: 24px;
min-height: auto;
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
export default FilterOptions;
+1 -1
View File
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${s("backdrop")} !important;
z-index: ${depths.overlay};
z-index: ${depths.modalOverlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
+1 -1
View File
@@ -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";
+141 -147
View File
@@ -1,5 +1,4 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
@@ -9,7 +8,6 @@ import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
@@ -25,9 +23,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -38,155 +36,151 @@ enum Direction {
DOWN,
}
const HoverPreviewDesktop = observer(
({ element, unfurlId, dataLoading, onClose }: Props) => {
const { unfurls } = useStores();
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
);
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -196,7 +190,7 @@ function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
unfurlId={unfurlId}
data={data}
dataLoading={dataLoading}
/>
);
@@ -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>
+3 -11
View File
@@ -45,10 +45,6 @@ export const NativeInput = styled.input<{
${ellipsis()}
${undraggableOnDesktop()}
&[readOnly] {
color: ${s("textSecondary")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
@@ -130,14 +126,13 @@ export interface Props
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
type?: "text" | "email" | "checkbox" | "search" | "textarea";
labelHidden?: boolean;
label?: string;
flex?: boolean;
short?: boolean;
margin?: string | number;
error?: string;
rows?: number;
/** Optional component that appears inside the input before the textarea and any icon */
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
@@ -181,7 +176,6 @@ function Input(
if (ev.key === "Enter" && ev.metaKey) {
if (props.onRequestSubmit) {
props.onRequestSubmit(ev);
return;
}
}
@@ -236,11 +230,10 @@ 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
@@ -250,12 +243,11 @@ 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}
+1 -2
View File
@@ -60,8 +60,7 @@ function InputSearchPage({
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchPath({
query: ev.currentTarget.value,
searchPath(ev.currentTarget.value, {
collectionId,
ref: source,
})
+2 -12
View File
@@ -48,8 +48,7 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
options: Option[];
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
/** Callback function that is called when the value changes. Return false to cancel the change. */
onChange?: (value: string | null) => void | Promise<boolean | void>;
onChange?: (value: string | null) => void;
style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
@@ -166,18 +165,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
if (previousValue.current === select.selectedValue) {
return;
}
const previous = previousValue.current;
previousValue.current = 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);
}, [onChange, select.selectedValue]);
React.useLayoutEffect(() => {
-354
View File
@@ -1,354 +0,0 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { transparentize } from "polished";
import React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import Scrollable from "./Scrollable";
import { IconWrapper } from "./Sidebar/components/SidebarLink";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import {
InputSelectRoot,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./primitives/components/InputSelect";
type Separator = {
/* Denotes a horizontal divider line to be rendered in the menu, */
type: "separator";
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
/* Representative text shown in the menu for this option. */
label: string;
/* Actual value of this option. */
value: string;
/* Additional info shown alongside the label. */
description?: string;
/* An icon shown alongside the label. */
icon?: React.ReactElement;
};
export type Option = Item | Separator;
type Props = {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
value?: string;
/* Callback when an option is selected. */
onChange: (value: string) => void;
/* ARIA label for accessibility. */
ariaLabel: string;
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean;
} & TriggerButtonProps;
export function InputSelectNew(props: Props) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
...triggerProps
} = props;
const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false);
const triggerRef =
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const isMobile = useMobile();
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator />;
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
</InputSelectItem>
);
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return (
<MobileSelect
{...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={triggerRef}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
};
function MobileSelect(props: MobileSelectProps) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
...triggerProps
} = props;
const [open, setOpen] = React.useState(false);
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const selectedOption = React.useMemo(
() =>
value
? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined,
[value, options]
);
const handleSelect = React.useCallback(
async (val: string) => {
setOpen(false);
onChange(val);
},
[onChange]
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <Separator />;
}
const isSelected = option === selectedOption;
return (
<SelectItemWrapper
key={option.value}
onClick={() => handleSelect(option.value)}
data-state={isSelected ? "checked" : "unchecked"}
>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
{isSelected && <SelectItemIndicator />}
</SelectItemWrapper>
);
},
[handleSelect, selectedOption, optionsHaveIcon]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
function Label({ text, hidden }: { text: string; hidden: boolean }) {
const labelText = <LabelText>{text}</LabelText>;
return hidden ? (
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
) : (
labelText
);
}
function Option({
option,
optionsHaveIcon,
}: {
option: Item;
optionsHaveIcon: boolean;
}) {
const icon = optionsHaveIcon ? (
option.icon ? (
<IconWrapper>{option.icon}</IconWrapper>
) : (
<IconSpacer />
)
) : null;
return (
<OptionContainer align="center">
{icon}
{option.label}
{option.description && (
<>
&nbsp;
<Description type="tertiary" size="small" ellipsis>
{option.description}
</Description>
</>
)}
</OptionContainer>
);
}
const Wrapper = styled.label<{ short?: boolean }>`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const OptionContainer = styled(Flex)`
min-height: 24px;
`;
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
`;
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
+26
View File
@@ -0,0 +1,26 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
type Props = {
children?: React.ReactNode;
label: React.ReactNode | string;
};
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
</Flex>
);
export const Label = styled(Flex)`
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
color: ${s("text")};
`;
export default observer(Labeled);
+9 -11
View File
@@ -33,7 +33,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
ellipsis?: boolean;
};
const ListItem = (
@@ -46,7 +45,6 @@ const ListItem = (
border,
to,
keyboardNavigation,
ellipsis,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -85,9 +83,7 @@ const ListItem = (
column={!compact}
$selected={selected}
>
<Heading $small={small} $ellipsis={ellipsis}>
{title}
</Heading>
<Heading $small={small}>{title}</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
@@ -109,7 +105,7 @@ const ListItem = (
$border={border}
$small={small}
activeStyle={{
background: theme.sidebarActiveBackground,
background: theme.accent,
}}
{...rest}
{...rovingTabIndex}
@@ -212,10 +208,10 @@ const Image = styled(Flex)`
color: ${s("text")};
`;
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
const Heading = styled.p<{ $small?: boolean }>`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
${ellipsis()}
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
@@ -223,13 +219,14 @@ const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
const Content = styled(Flex)<{ $selected: boolean }>`
flex-direction: column;
flex-grow: 1;
color: ${s("text")};
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${s("textTertiary")};
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
margin-top: -2px;
`;
@@ -237,7 +234,8 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center;
justify-content: center;
flex-shrink: 0;
color: ${s("textSecondary")};
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default React.forwardRef(ListItem);
+69 -5
View File
@@ -1,7 +1,24 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { locales } from "@shared/utils/date";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import { useLocaleTime } from "~/hooks/useLocaleTime";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
export type Props = {
children?: React.ReactNode;
@@ -12,12 +29,59 @@ export type Props = {
format?: Partial<Record<keyof typeof locales, string>>;
};
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
const { tooltipContent, content } = useLocaleTime(rest);
const LocaleTime: React.FC<Props> = ({
addSuffix,
children,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
return (
<Tooltip content={tooltipContent} placement="bottom">
<time dateTime={rest.dateTime}>{children || content}</time>
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
};
+1 -1
View File
@@ -147,7 +147,7 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
props.$fullscreen
? transparentize(0.25, props.theme.background)
: props.theme.modalBackdrop} !important;
z-index: ${depths.overlay};
z-index: ${depths.modalOverlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -48,15 +48,6 @@ function Notifications(
notifications.approximateUnreadCount
);
}
// PWA badging
if ("setAppBadge" in navigator) {
if (notifications.approximateUnreadCount) {
void navigator.setAppBadge(notifications.approximateUnreadCount);
} else {
void navigator.clearAppBadge();
}
}
}, [notifications.approximateUnreadCount]);
return (
+13 -5
View File
@@ -1,13 +1,16 @@
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, { type Event } from "./EventListItem";
import EventListItem from "./EventListItem";
type Props = {
events: Event[];
events: Event<Document>[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -29,8 +32,13 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event) => (
<EventListItem key={item.id} event={item} document={document} />
renderItem={(item: Event<Document>, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
+3 -8
View File
@@ -60,7 +60,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
fetchCounter = 0;
@observable
renderCount = Pagination.defaultLimit;
renderCount = 15;
@observable
offset = 0;
@@ -108,16 +108,13 @@ 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;
@@ -251,9 +248,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
</div>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
);
+1 -1
View File
@@ -1,7 +1,7 @@
import { m, TargetAndTransition } from "framer-motion";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import useComponentSize from "~/hooks/useComponentSize";
type Props = {
/** The children to render */
@@ -2,7 +2,6 @@ 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";
@@ -168,24 +167,18 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
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;
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
return true;
}}
disabled={!can.update}
value={membership.permission}
@@ -208,7 +201,11 @@ export const AccessControlList = observer(
<ListItem
key={membership.id}
image={
<Avatar model={membership.user} size={AvatarSize.Medium} />
<Avatar
model={membership.user}
size={AvatarSize.Medium}
showBorder={false}
/>
}
title={membership.user.name}
subtitle={membership.user.email}
@@ -222,24 +219,18 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
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;
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
return true;
}}
disabled={!can.update}
value={membership.permission}
@@ -146,7 +146,7 @@ export const AccessControlList = observer(
/>
) : (
<ListItem
image={<Avatar model={user} />}
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
@@ -160,7 +160,9 @@ export const AccessControlList = observer(
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
image={
<Avatar model={document.createdBy} showBorder={false} />
}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
@@ -73,7 +73,9 @@ const DocumentMemberListItem = ({
return (
<ListItem
title={user.name}
image={<Avatar model={user} size={AvatarSize.Medium} />}
image={
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
}
subtitle={
membership?.sourceId ? (
<Trans>
@@ -96,7 +96,7 @@ export const Suggestions = observer(
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.activeOrInvited
: users.orderedData
).filter((u) => !u.isSuspended && u.id !== user.id);
if (isEmail(query)) {
@@ -114,7 +114,7 @@ export const Suggestions = observer(
}, [
getSuggestionForEmail,
users,
users.activeOrInvited,
users.orderedData,
groups,
groups.orderedData,
document?.id,
@@ -158,7 +158,13 @@ export const Suggestions = observer(
: suggestion.isViewer
? t("Viewer")
: t("Editor"),
image: <Avatar model={suggestion} size={AvatarSize.Medium} />,
image: (
<Avatar
model={suggestion}
size={AvatarSize.Medium}
showBorder={false}
/>
),
};
}
+26 -4
View File
@@ -1,25 +1,26 @@
import { observer } from "mobx-react";
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import { DraftsIcon, 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, searchPath } from "~/utils/routeHelpers";
import { homePath, draftsPath, 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";
@@ -106,7 +107,24 @@ function AppSidebar() {
label={t("Search")}
exact={false}
/>
{can.createDocument && <DraftsLink />}
{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>
}
/>
)}
</Section>
</Overflow>
<Scrollable flex shadow>
@@ -140,4 +158,8 @@ const Overflow = styled.div`
flex-shrink: 0;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);
+1 -4
View File
@@ -14,7 +14,6 @@ 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";
@@ -41,9 +40,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
{teamAvailable && (
<SidebarButton
title={team.name}
image={
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
onClick={() =>
history.push(
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
+7 -20
View File
@@ -54,8 +54,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const [hasPointerMoved, setPointerMoved] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
@@ -116,10 +114,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerActivity = React.useCallback(() => {
if (ui.sidebarIsClosed) {
// clear the timeout when mouse exits
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
setHovering(document.hasFocus());
setPointerMoved(true);
}
@@ -128,20 +122,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerLeave = React.useCallback(
(ev) => {
if (hasPointerMoved) {
// clear any previous timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
// add a short delay when mouse exits the sidebar before closing
hoverTimeoutRef.current = setTimeout(() => {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}, 500);
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}
},
[width, hasPointerMoved]
@@ -242,6 +228,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
alt={user.name}
model={user}
size={24}
showBorder={false}
style={{ marginLeft: 4 }}
/>
}
@@ -14,7 +14,6 @@ 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() {
@@ -65,40 +64,38 @@ function ArchiveLink() {
useDropToArchive();
return (
<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}
<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}
/>
)}
/>
</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>
</Relative>
) : null}
</Flex>
);
}
@@ -2,29 +2,26 @@ 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 { 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 { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
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 from "./SidebarLink";
import SidebarLink, { DragObject } from "./SidebarLink";
type Props = {
collection: Collection;
@@ -44,14 +41,12 @@ 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(
@@ -63,132 +58,119 @@ const CollectionLink: React.FC<Props> = ({
[collection]
);
const handleExpand = React.useCallback(() => {
if (!expanded) {
onDisclosureClick();
}
}, [expanded, onDisclosureClick]);
// 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 parentRef = React.useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
handleExpand,
parentRef
);
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 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={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 && (
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<SidebarLink
depth={2}
isActive={() => true}
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=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
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>
<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,23 +1,25 @@
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 from "./SidebarLink";
import SidebarLink, { DragObject } from "./SidebarLink";
type Props = {
/** The collection to render the children of. */
@@ -34,17 +36,55 @@ function CollectionLinkChildren({
prefetchDocument,
}: Props) {
const pageSize = 250;
const { documents } = useStores();
const can = usePolicy(collection);
const manualSort = collection.sort.field === "index";
const { documents, dialogs, collections } = useStores();
const { t } = useTranslation();
const childDocuments = useCollectionDocuments(collection, documents.active);
const [showing, setShowing] = React.useState(pageSize);
const dummyRef = React.useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
noop,
dummyRef
);
// 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(),
}),
});
React.useEffect(() => {
if (!expanded) {
@@ -60,8 +100,12 @@ function CollectionLinkChildren({
return (
<Folder expanded={expanded}>
{canDrop && collection.isManualSort && (
<DropCursor isActiveDrop={isOver} innerRef={dropRef} position="top" />
{isDraggingAnyDocument && can.createDocument && manualSort && (
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
)}
<DocumentsLoader collection={collection} enabled={expanded}>
{!childDocuments && (
@@ -10,7 +10,6 @@ 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";
@@ -18,6 +17,7 @@ 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,25 +3,22 @@ import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode, UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { NavigationNode } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { newNestedDocumentPath } from "~/utils/routeHelpers";
import {
useDragDocument,
useDropToReorderDocument,
@@ -29,6 +26,7 @@ import {
} from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
@@ -60,7 +58,6 @@ 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 =
@@ -70,7 +67,6 @@ function InnerDocumentLink(
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
React.useEffect(() => {
if (
@@ -220,43 +216,6 @@ 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}>
@@ -323,11 +282,8 @@ function InnerDocumentLink(
<NudeButton
type={undefined}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
as={Link}
to={newNestedDocumentPath(document.id)}
>
<PlusIcon />
</NudeButton>
@@ -352,25 +308,8 @@ 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, childIndex) => (
{nodeChildren.map((childNode, index) => (
<DocumentLink
key={childNode.id}
collection={collection}
@@ -379,7 +318,7 @@ function InnerDocumentLink(
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
index={index}
parentId={node.id}
/>
))}
@@ -1,41 +0,0 @@
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,33 +3,23 @@ import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
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. */
type Props = {
onSubmit: (title: string) => Promise<void>;
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, onCancel, ...rest }: Props,
{ title, onSubmit, canUpdate, onEditing, ...rest }: Props,
ref: React.RefObject<RefHandle>
) {
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
@@ -69,20 +59,21 @@ function EditableTitle(
if (trimmedValue === originalValue || trimmedValue.length === 0) {
setValue(originalValue);
onCancel?.();
return;
}
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
}
},
[originalValue, value, onCancel, onSubmit]
[originalValue, value, onSubmit]
);
const handleKeyDown = React.useCallback(
@@ -92,14 +83,13 @@ function EditableTitle(
}
if (ev.key === "Escape") {
setIsEditing(false);
onCancel?.();
setValue(originalValue);
}
if (ev.key === "Enter") {
await handleSave(ev);
}
},
[handleSave, onCancel, originalValue]
[handleSave, originalValue]
);
React.useEffect(() => {
@@ -125,10 +115,7 @@ function EditableTitle(
/>
</form>
) : (
<span
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
className={rest.className}
>
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
{value}
</span>
)}
@@ -5,7 +5,6 @@ import User from "~/models/User";
export type SidebarContextType =
| "collections"
| "shared"
| "archive"
| `group-${string}`
| `starred-${string}`
| undefined;
@@ -42,7 +41,7 @@ export const determineSidebarContext = ({
}
if (document.collection) {
return document.collection.isArchived ? "archive" : "collections";
return "collections";
} else if (
user.documentMemberships.find((m) => m.documentId === document.id)
) {
@@ -4,6 +4,7 @@ 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";
@@ -11,6 +12,11 @@ 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,11 +86,6 @@ 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);
@@ -147,7 +142,6 @@ function StarredLink({ star }: Props) {
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
icon={icon}
isActive={(
match,
@@ -178,7 +172,6 @@ function StarredLink({ star }: Props) {
node={node}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
@@ -6,8 +6,7 @@ import { useTranslation } from "react-i18next";
import DocumentDelete from "~/scenes/DocumentDelete";
import useStores from "~/hooks/useStores";
import { trashPath } from "~/utils/routeHelpers";
import { DragObject } from "../hooks/useDragAndDrop";
import SidebarLink from "./SidebarLink";
import SidebarLink, { DragObject } from "./SidebarLink";
function TrashLink() {
const { policies, dialogs, documents } = useStores();
+60 -212
View File
@@ -15,49 +15,10 @@ 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 { AuthorizationError } from "~/utils/errors";
import { DragObject } from "../components/SidebarLink";
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
*
@@ -201,84 +162,6 @@ 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
*
@@ -292,7 +175,7 @@ export function useDropToReparentDocument(
parentRef: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const { documents, collections, dialogs } = useStores();
const { documents, collections, dialogs, policies } = useStores();
const hasChildDocuments = !!node?.children.length;
const document = node ? documents.get(node.id) : undefined;
const pathToNode = React.useMemo(
@@ -300,7 +183,25 @@ export function useDropToReparentDocument(
[document]
);
const startHover = useHover(parentRef, setExpanded);
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]);
return useDrop<
DragObject,
@@ -313,9 +214,7 @@ export function useDropToReparentDocument(
return;
}
const collection = node.collectionId
? collections.get(node.collectionId)
: undefined;
const collection = documents.get(node.id)?.collection;
const prevCollection = collections.get(item.collectionId);
if (
@@ -334,40 +233,22 @@ export function useDropToReparentDocument(
),
});
} else {
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;
await documents.move({
documentId: item.id,
parentDocumentId: node.id,
});
}
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);
setExpanded();
},
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.
@@ -378,7 +259,15 @@ export function useDropToReparentDocument(
shallow: true,
})
) {
startHover();
if (!hoverExpanding.current) {
hoverExpanding.current = setTimeout(() => {
hoverExpanding.current = undefined;
if (monitor.isOver({ shallow: true })) {
setExpanded();
}
}, 500);
}
}
},
collect: (monitor) => ({
@@ -408,7 +297,7 @@ export function useDropToReorderDocument(
}
) {
const { t } = useTranslation();
const { documents, collections, dialogs } = useStores();
const { documents, collections, dialogs, policies } = useStores();
const document = documents.get(node.id);
@@ -419,9 +308,22 @@ export function useDropToReorderDocument(
>({
accept: "document",
canDrop: (item: DragObject) => {
if (item.id === node.id || (document && !document.isActive)) {
if (
item.id === node.id ||
!policies.abilities(item.id)?.move ||
!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) => {
@@ -455,19 +357,7 @@ export function useDropToReorderDocument(
),
});
} else {
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);
}
}
void documents.move(params);
}
}
},
@@ -586,45 +476,3 @@ 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(),
}),
});
}
+2 -55
View File
@@ -6,11 +6,7 @@ import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
import {
FileOperationState,
FileOperationType,
ImportState,
} from "@shared/types";
import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
@@ -19,7 +15,6 @@ import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupUser from "~/models/GroupUser";
import Import from "~/models/Import";
import Membership from "~/models/Membership";
import Notification from "~/models/Notification";
import Pin from "~/models/Pin";
@@ -105,7 +100,6 @@ class WebsocketProvider extends React.Component<Props> {
subscriptions,
fileOperations,
notifications,
imports,
} = this.props;
const currentUserId = auth?.user?.id;
@@ -196,7 +190,7 @@ class WebsocketProvider extends React.Component<Props> {
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
if (!collection?.documents && !event.fetchIfMissing) {
if (!collection?.documents?.length && !event.fetchIfMissing) {
continue;
}
@@ -231,32 +225,6 @@ 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">) => {
@@ -626,23 +594,6 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
imports.add(event);
});
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
imports.add(event);
if (
event.state === ImportState.Completed &&
event.createdBy?.id === auth.user?.id
) {
toast.success(event.name, {
description: this.props.t("Your import completed"),
});
}
});
this.socket.on(
"subscriptions.create",
(event: PartialExcept<Subscription, "id">) => {
@@ -668,10 +619,6 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => {
users.remove(event.modelId);
});
this.socket.on(
"userMemberships.update",
async (event: PartialExcept<UserMembership, "id">) => {
-88
View File
@@ -1,88 +0,0 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import React from "react";
import styled from "styled-components";
import { Drawer as DrawerPrimitive } from "vaul";
import { depths, s } from "@shared/styles";
import Flex from "../Flex";
import Text from "../Text";
import { Overlay } from "./components/Overlay";
/** Root Drawer component - all the other components are rendered inside it. */
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root {...props} />
);
Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DrawerPrimitive.Portal>
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
<StyledContent ref={ref} {...rest}>
{children}
</StyledContent>
</DrawerPrimitive.Portal>
);
});
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
/** Drawer's title shown in the center. */
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>((props, ref) => {
const { hidden, children, ...rest } = props;
const title = (
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
);
return (
<DrawerPrimitive.Title ref={ref} {...rest} asChild>
{hidden ? (
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
) : (
<>{title}</>
)}
</DrawerPrimitive.Title>
);
});
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
/** Styled components. */
const StyledContent = styled(DrawerPrimitive.Content)`
z-index: ${depths.menu};
position: fixed;
left: 0;
right: 0;
bottom: 0;
min-width: 180px;
max-width: 100%;
min-height: 44px;
max-height: 90vh;
padding: 6px;
border-radius: 6px;
background: ${s("menuBackground")};
`;
const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
-135
View File
@@ -1,135 +0,0 @@
import * as InputSelectPrimitive from "@radix-ui/react-select";
import React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Props as ButtonProps } from "~/components/Button";
import Separator from "~/components/ContextMenu/Separator";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./components/InputSelect";
/** Root InputSelect component - all the other components are rendered inside it. */
const InputSelectRoot = InputSelectPrimitive.Root;
/** InputSelect's trigger. */
export type TriggerButtonProps = {
/** When true, "nude" variant of Button is rendered. */
nude?: boolean;
/** Optional css class names to pass to the trigger. */
className?: string;
} & Pick<ButtonProps<unknown>, "borderOnHover">;
type InputSelectTriggerProps = { placeholder: string } & TriggerButtonProps &
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>;
const InputSelectTrigger = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
InputSelectTriggerProps
>((props, ref) => {
const { placeholder, children, ...buttonProps } = props;
return (
<InputSelectPrimitive.Trigger ref={ref} asChild>
<SelectButton neutral disclosure {...buttonProps}>
<InputSelectPrimitive.Value placeholder={placeholder} />
</SelectButton>
</InputSelectPrimitive.Trigger>
);
});
InputSelectTrigger.displayName = InputSelectPrimitive.Trigger.displayName;
/** InputSelect's content - renders the options in a scrollable element. */
type ContentProps = Omit<
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Content>,
"position"
>;
const InputSelectContent = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Content>,
ContentProps
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Portal>
<StyledContent ref={ref} position={"popper"} {...rest}>
<InputSelectPrimitive.Viewport style={{ overscrollBehavior: "none" }}>
{children}
</InputSelectPrimitive.Viewport>
</StyledContent>
</InputSelectPrimitive.Portal>
);
});
InputSelectContent.displayName = InputSelectPrimitive.Content.displayName;
/** Individual InputSelect option rendered in the menu. */
const InputSelectItem = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Item>
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Item ref={ref} {...rest} asChild>
<SelectItemWrapper>
<InputSelectPrimitive.ItemText>
{children}
</InputSelectPrimitive.ItemText>
<InputSelectPrimitive.ItemIndicator asChild>
<SelectItemIndicator />
</InputSelectPrimitive.ItemIndicator>
</SelectItemWrapper>
</InputSelectPrimitive.Item>
);
});
InputSelectItem.displayName = InputSelectPrimitive.Item.displayName;
/** Horizontal separator rendered between the options. */
const InputSelectSeparator = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Separator>
>((props, ref) => (
<InputSelectPrimitive.Separator ref={ref} asChild>
<Separator {...props} />
</InputSelectPrimitive.Separator>
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
min-width: var(--radix-select-trigger-width);
max-width: 400px;
min-height: 44px;
max-height: 350px;
padding: 4px;
border-radius: 6px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
transform-origin: 50% 0;
&[data-side="bottom"] {
animation: ${fadeAndSlideDown} 200ms ease;
}
&[data-side="top"] {
animation: ${fadeAndSlideUp} 200ms ease;
}
@media print {
display: none;
}
`;
export {
InputSelectRoot,
InputSelectTrigger,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
};
@@ -1,136 +0,0 @@
/**
* Reusable components for InputSelect abstraction.
*/
import { CheckmarkIcon } from "outline-icons";
import React from "react";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Flex from "~/components/Flex";
export const SelectItem = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div">
>((props, ref) => {
const { children, ...rest } = props;
return (
<ItemContainer
ref={ref}
justify="space-between"
align="center"
gap={8}
{...rest}
>
{children}
<IconSpacer />
</ItemContainer>
);
});
SelectItem.displayName = "SelectItem";
export const SelectItemIndicator = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>((props, ref) => (
<IndicatorContainer ref={ref} {...props}>
<CheckmarkIcon />
</IndicatorContainer>
));
SelectItemIndicator.displayName = "SelectItemIndicator";
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const SelectButton = styled(Button)<{ $nude?: boolean }>`
display: block;
font-weight: normal;
text-transform: none;
width: 100%;
cursor: var(--pointer);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
}
&:focus-visible {
outline: none;
}
${(props) =>
props.$nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 12px;
padding-right: 4px;
}
svg {
justify-self: flex-end;
margin-left: auto;
}
&[data-placeholder=""] {
color: ${s("placeholder")};
}
`;
const ItemContainer = styled(Flex)`
position: relative;
width: 100%;
font-size: 16px;
cursor: var(--pointer);
color: ${s("textSecondary")};
background: none;
margin: 0;
padding: 12px;
border: 0;
border-radius: 4px;
outline: 0;
user-select: none;
white-space: nowrap;
svg {
flex-shrink: 0;
}
@media (hover: hover) {
&:hover,
&:focus {
color: ${s("accentText")};
background: ${s("accent")};
svg {
color: ${s("accentText")};
fill: ${s("accentText")};
}
}
}
&[data-state="checked"] {
${IconSpacer} {
display: none;
}
}
${breakpoint("tablet")`
font-size: 14px;
padding: 4px;
padding-left: 8px;
`}
`;
const IndicatorContainer = styled.span`
width: 24px;
height: 24px;
`;
@@ -1,15 +0,0 @@
import styled from "styled-components";
import { depths, s } from "@shared/styles";
export const Overlay = styled.div`
position: fixed;
inset: 0;
background: ${s("backdrop")};
z-index: ${depths.overlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
&[data-state="open"] {
opacity: 1;
}
`;
+1 -3
View File
@@ -1,7 +1,6 @@
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";
@@ -12,7 +11,6 @@ type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
return (
<SuggestionsMenu
@@ -28,7 +26,7 @@ function BlockMenu(props: Props) {
shortcut={item.shortcut}
/>
)}
items={getMenuItems(dictionary, elementRef)}
items={getMenuItems(dictionary)}
/>
);
}
+54 -134
View File
@@ -24,54 +24,6 @@ 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;
@@ -137,48 +89,42 @@ export default function FindAndReplace({
}
}, [show]);
// 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;
}
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
// 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();
if (shouldShowReplace) {
setShowReplace(true);
}
},
[popover, readOnly, selectInputText, selectInputReplaceText]
}
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
setRegex((state) => !state);
},
{ allowInInput: true }
);
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);
@@ -186,65 +132,68 @@ export default function FindAndReplace({
const handleCaseSensitive = React.useCallback(() => {
setCaseSensitive((state) => {
const isCaseSensitive = !state;
const caseSensitive = !state;
editor.commands.find({
text: searchTerm,
caseSensitive: isCaseSensitive,
caseSensitive,
regexEnabled,
});
return isCaseSensitive;
return caseSensitive;
});
}, [regexEnabled, editor.commands, searchTerm]);
const handleRegex = React.useCallback(() => {
setRegex((state) => {
const isRegexEnabled = !state;
const regexEnabled = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
regexEnabled: isRegexEnabled,
regexEnabled,
});
return isRegexEnabled;
return regexEnabled;
});
}, [caseSensitive, editor.commands, searchTerm]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
function nextPrevious() {
function nextPrevious(ev: React.KeyboardEvent<HTMLInputElement>) {
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();
nextPrevious(ev);
return;
}
case "g": {
if (ev.metaKey) {
ev.preventDefault();
nextPrevious();
nextPrevious(ev);
selectInputText();
}
return;
}
case "F3": {
ev.preventDefault();
nextPrevious();
nextPrevious(ev);
selectInputText();
return;
}
}
},
[editor.commands, selectInputText]
[editor.commands]
);
const handleReplace = React.useCallback(
@@ -294,15 +243,6 @@ export default function FindAndReplace({
[handleReplace]
);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
useKeyboardShortcuts({
popover,
handleOpen,
handleCaseSensitive,
handleRegex,
});
const style: React.CSSProperties = React.useMemo(
() => ({
position: "fixed",
@@ -345,7 +285,7 @@ export default function FindAndReplace({
<>
<Tooltip
content={t("Previous match")}
shortcut="Shift+Enter"
shortcut="shift+enter"
placement="bottom"
>
<ButtonLarge
@@ -355,7 +295,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()}
@@ -414,11 +354,7 @@ export default function FindAndReplace({
</StyledInput>
{navigation}
{!readOnly && (
<Tooltip
content={t("Replace options")}
shortcut={`${altDisplay}+${metaDisplay}+f`}
placement="bottom"
>
<Tooltip content={t("Replace options")} placement="bottom">
<ButtonLarge onClick={handleMore}>
<ReplaceIcon color={theme.textSecondary} />
</ButtonLarge>
@@ -440,28 +376,12 @@ export default function FindAndReplace({
onRequestSubmit={handleReplaceAll}
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
/>
<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>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
{t("Replace all")}
</Button>
</Flex>
)}
</ResizingHeightContainer>
+11 -23
View File
@@ -9,6 +9,7 @@ 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";
@@ -40,8 +41,7 @@ function usePosition({
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
@@ -78,24 +78,13 @@ 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 || 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;
}
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;
}
// tables are an oddity, and need their own positioning logic
@@ -195,12 +184,11 @@ function usePosition({
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.max(margin, Math.round(left - offsetParent.left)),
left: Math.round(left - offsetParent.left),
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
blockSelection:
codeBlock || isColSelection || isRowSelection || noticeBlock,
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
blockSelection: codeBlock || isColSelection || isRowSelection,
visible: true,
};
}
-5
View File
@@ -1,4 +1,3 @@
import { transparentize } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
@@ -14,10 +13,6 @@ const Input = styled.input`
flex-grow: 1;
min-width: 0;
&::placeholder {
color: ${(props) => transparentize(0.5, props.theme.text)};
}
@media (hover: none) and (pointer: coarse) {
font-size: 16px;
}
+128 -206
View File
@@ -1,24 +1,15 @@
import { observer } from "mobx-react";
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { hideScrollbars, s } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import Input from "./Input";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
@@ -41,163 +32,142 @@ type Props = {
view: EditorView;
};
const LinkEditor: React.FC<Props> = ({
mark,
from,
to,
dictionary,
onRemoveLink,
onSelectLink,
onClickLink,
view,
}) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const initialSelectionLength = to - from;
const inputRef = useRef<HTMLInputElement>(null);
const discardRef = useRef(false);
const [query, setQuery] = useState(initialValue);
const [selectedIndex, setSelectedIndex] = useState(-1);
const { documents } = useStores();
type State = {
value: string;
previousValue: string;
};
const trimmedQuery = query.trim();
const results = trimmedQuery
? documents.findByQuery(trimmedQuery, { maxResults: 25 })
: [];
class LinkEditor extends React.Component<Props, State> {
discardInputValue = false;
initialValue = this.href;
initialSelectionLength = this.props.to - this.props.from;
inputRef = React.createRef<HTMLInputElement>();
const { request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
);
state: State = {
value: this.href,
previousValue: "",
};
useEffect(() => {
if (trimmedQuery) {
void request();
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
}
componentDidMount(): void {
window.addEventListener("keydown", this.handleGlobalKeyDown);
}
componentWillUnmount = () => {
window.removeEventListener("keydown", this.handleGlobalKeyDown);
// If we discarded the changes then nothing to do
if (this.discardInputValue) {
return;
}
}, [trimmedQuery, request]);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
inputRef.current?.select();
}
};
// If the link is the same as it was when the editor opened, nothing to do
if (this.state.value === this.initialValue) {
return;
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => {
window.removeEventListener("keydown", handleGlobalKeyDown);
// If the link is totally empty or only spaces then remove the mark
const href = (this.state.value || "").trim();
if (!href) {
return this.handleRemoveLink();
}
// If we discarded the changes then nothing to do
if (discardRef.current) {
return;
}
this.save(href, href);
};
// If the link is the same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
return;
}
handleGlobalKeyDown = (event: KeyboardEvent): void => {
if (event.key === "k" && event.metaKey) {
this.inputRef.current?.select();
}
};
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return handleRemoveLink();
}
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
save = (href: string, title?: string): void => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
this.discardInputValue = true;
const { from, to } = this.props;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
this.props.onSelectLink({ href, title, from, to });
};
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
if (nextSelection) {
dispatch(state.tr.setSelection(nextSelection));
}
view.focus();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
handleKeyDown = (event: React.KeyboardEvent): void => {
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current >= maxIndex ? 0 : current + 1));
return;
}
case "ArrowUp": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current <= 0 ? maxIndex : current - 1));
return;
}
case "Enter": {
event.preventDefault();
const { value } = this.state;
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
const href = selectedDoc.url;
save(href, selectedDoc.title);
} else {
save(trimmedQuery, trimmedQuery);
this.save(value, value);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
if (initialSelectionLength) {
moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (initialValue) {
setQuery(initialValue);
moveSelectionToEnd();
if (this.initialValue) {
this.setState({ value: this.initialValue }, this.moveSelectionToEnd);
} else {
handleRemoveLink();
this.handleRemoveLink();
}
return;
}
}
};
const handleSearch = async (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setQuery(newValue);
setSelectedIndex(-1);
};
handleSearch = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
const value = event.target.value;
const handlePaste = () => {
setTimeout(() => save(query, query), 0);
};
this.setState({
value,
});
const handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const trimmedValue = value.trim();
try {
onClickLink(getHref(), event);
} catch (err) {
toast.error(dictionary.openLinkError);
if (trimmedValue) {
try {
this.setState({
previousValue: trimmedValue,
});
} catch (err) {
Logger.error("Error searching for link", err);
}
}
};
const handleRemoveLink = () => {
discardRef.current = true;
handlePaste = (): void => {
setTimeout(() => this.save(this.state.value, this.state.value), 0);
};
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
try {
this.props.onClickLink(this.href, event);
} catch (err) {
toast.error(this.props.dictionary.openLinkError);
}
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
const { from, to, mark, view, onRemoveLink } = this.props;
const { state, dispatch } = this.props.view;
const { state, dispatch } = view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
@@ -206,102 +176,54 @@ const LinkEditor: React.FC<Props> = ({
view.focus();
};
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
moveSelectionToEnd = () => {
const { to, view } = this.props;
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
if (nextSelection) {
dispatch(state.tr.setSelection(nextSelection));
}
view.focus();
};
return (
<>
render() {
const { dictionary } = this.props;
const { value } = this.state;
const isInternal = isInternalUrl(value);
return (
<Wrapper>
<Input
ref={inputRef}
value={query}
placeholder={dictionary.searchOrPasteLink}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={getHref() === ""}
readOnly={!view.editable}
ref={this.inputRef}
value={value}
placeholder={dictionary.enterLink}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleSearch}
onFocus={this.handleSearch}
autoFocus={this.href === ""}
/>
<Tooltip
content={isInternal ? dictionary.goToLink : dictionary.openLink}
>
<ToolbarButton onClick={handleOpenLink} disabled={!query}>
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
{isInternal ? <ArrowIcon /> : <OpenIcon />}
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
)}
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
</Wrapper>
<SearchResults $hasResults={hasResults}>
<ResizingHeightContainer>
{hasResults && (
<>
{results.map((doc, index) => (
<SuggestionsMenuItem
onClick={() => {
save(doc.url, doc.title);
if (initialSelectionLength) {
moveSelectionToEnd();
}
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
key={doc.id}
subtitle={doc.collection?.name}
title={doc.title}
icon={
doc.icon ? (
<Icon value={doc.icon} color={doc.color ?? undefined} />
) : (
<DocumentIcon />
)
}
/>
))}
</>
)}
</ResizingHeightContainer>
</SearchResults>
</>
);
};
);
}
}
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("menuBackground")};
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
clip-path: inset(0px -100px -100px -100px);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin-top: -6px;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "6px" : "0")};
max-height: 240px;
pointer-events: all;
${hideScrollbars()}
@media (hover: none) and (pointer: coarse) {
position: fixed;
top: auto;
bottom: 40px;
border-radius: 0;
max-height: 50vh;
padding: 8px 8px 4px;
}
`;
export default observer(LinkEditor);
export default LinkEditor;
+15 -46
View File
@@ -1,6 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import { DocumentIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -10,13 +10,11 @@ import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import {
DocumentsSection,
UserSection,
CollectionsSection,
} from "~/actions/sections";
import { DocumentsSection, UserSection } from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -44,19 +42,23 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections } = useStores();
const { auth, documents, users } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const maxResultsInSection = search ? 25 : 5;
const { loading, request } = useRequest(
const { loading, request } = useRequest<{
documents: Document[];
users: User[];
}>(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
return {
documents: res.data.documents.map(documents.add),
users: res.data.users.map(users.add),
};
}, [search, documents, users])
);
@@ -82,6 +84,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
>
<Avatar
model={user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
@@ -125,34 +128,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
} as MentionItem)
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
@@ -180,13 +155,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
return;
}
if (!documentId) {
if (item.attrs.type === MentionType.Document) {
return;
}
// Check if the mentioned user has access to the document
@@ -4,7 +4,6 @@ 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";
@@ -19,7 +18,6 @@ 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";
@@ -57,10 +55,6 @@ function useIsActive(state: EditorState) {
return true;
}
if (isInNotice(state) && selection.from > 0) {
return true;
}
if (!selection || selection.empty) {
return false;
}
@@ -190,7 +184,6 @@ 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[] = [];
@@ -210,8 +203,6 @@ 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);
}
+3 -2
View File
@@ -645,11 +645,12 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
<>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
@@ -658,7 +659,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
onClick: () => handleClickItem(item),
})}
</ListItem>
</React.Fragment>
</>
);
previousHeading = currentHeading;
+2 -11
View File
@@ -1,4 +1,3 @@
import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
@@ -12,8 +11,6 @@ export type Props = {
disabled?: boolean;
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
/** Callback when the item is hovered */
onPointerMove?: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
icon?: React.ReactNode;
/** The title of the item */
@@ -28,7 +25,6 @@ function SuggestionsMenuItem({
selected,
disabled,
onClick,
onPointerMove,
title,
subtitle,
shortcut,
@@ -57,7 +53,6 @@ function SuggestionsMenuItem({
ref={ref}
active={selected}
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
icon={icon}
>
{title}
@@ -69,16 +64,12 @@ function SuggestionsMenuItem({
const Subtitle = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
props.$active ? props.theme.white50 : props.theme.textTertiary};
`;
const Shortcut = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
props.$active ? props.theme.white50 : props.theme.textTertiary};
flex-grow: 1;
text-align: right;
`;
@@ -1,10 +1,12 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import textBetween from "@shared/editor/lib/textBetween";
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
/**
* A plugin that allows overriding the default behavior of the editor to allow
* copying text including the markdown formatting.
* copying text for nodes that do not inherently have text children by defining
* a `toPlainText` method in the node spec.
*/
export default class ClipboardTextSerializer extends Extension {
get name() {
@@ -12,33 +14,19 @@ export default class ClipboardTextSerializer extends Extension {
}
get plugins() {
const mdSerializer = this.editor.extensions.serializer();
const textSerializers = getTextSerializers(this.editor.schema);
return [
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice) => {
const isMultiline = slice.content.childCount > 1;
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));
// 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("");
return textBetween(doc, from, to, textSerializers);
},
},
}),
+13 -20
View File
@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import stores from "~/stores";
import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
import { client } from "~/utils/ApiClient";
interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
unfurlId: string | null;
data: Record<string, any> | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
unfurlId: null,
data: null,
dataLoading: false,
});
@@ -62,25 +62,19 @@ export default class HoverPreviews extends Extension {
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
this.state.activeLinkElement = element;
this.state.unfurlId = transformedUrl;
} else {
this.state.data = data;
} catch (err) {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
this.state.dataLoading = false;
}
}),
this.options.delay
@@ -107,11 +101,10 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
unfurlId={this.state.unfurlId}
data={this.state.data}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);
-7
View File
@@ -1,5 +1,4 @@
import isEqual from "lodash/isEqual";
import { Plugin } from "prosemirror-state";
import {
ySyncPlugin,
yCursorPlugin,
@@ -9,7 +8,6 @@ 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 = {
@@ -105,11 +103,6 @@ export default class Multiplayer extends Extension {
selectionBuilder,
}),
yUndoPlugin(),
new Plugin({
props: {
handleScrollToSelection: (view) => isRemoteTransaction(view.state.tr),
},
}),
];
}
+60 -121
View File
@@ -10,8 +10,8 @@ import {
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { codeLanguages } from "@shared/editor/lib/code";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
@@ -20,12 +20,57 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
/**
* 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;
}
export default class PasteHandler extends Extension {
state: {
open: boolean;
@@ -88,7 +133,7 @@ export default class PasteHandler extends Extension {
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state, { inclusive: true })) {
if (isInCode(state)) {
event.preventDefault();
view.dispatch(state.tr.insertText(text));
return true;
@@ -103,13 +148,6 @@ 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
@@ -122,8 +160,6 @@ export default class PasteHandler extends Extension {
}
// Is the link a link to a document? If so, we can grab the title and insert it.
const containsHash = text.includes("#");
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
@@ -135,7 +171,7 @@ export default class PasteHandler extends Extension {
return;
}
if (document) {
if (state.schema.nodes.mention && !containsHash) {
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
@@ -169,51 +205,6 @@ export default class PasteHandler extends Extension {
this.insertLink(text);
});
}
} else if (isCollectionUrl(text)) {
const slug = parseCollectionSlug(text);
if (slug) {
stores.collections
.fetch(slug)
.then((collection) => {
if (view.isDestroyed) {
return;
}
if (collection) {
if (state.schema.nodes.mention && !containsHash) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(collection.icon) ===
IconType.Emoji;
const title = `${
hasEmoji ? collection.icon + " " : ""
}${collection.name}`;
this.insertLink(`${collection.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
this.insertLink(text);
});
}
} else {
this.insertLink(text);
}
@@ -228,7 +219,7 @@ export default class PasteHandler extends Extension {
state.tr
.replaceSelectionWith(
state.schema.nodes.code_block.create({
language: Object.keys(codeLanguages).includes(
language: Object.keys(LANGUAGES).includes(
vscodeMeta.mode
)
? vscodeMeta.mode
@@ -258,17 +249,21 @@ 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) &&
!isContainingImage(html)) ||
(isMarkdown(text) && !isDropboxPaper(html)) ||
pasteCodeLanguage === "markdown" ||
this.shiftKey ||
!html
this.shiftKey
) {
event.preventDefault();
@@ -480,59 +475,3 @@ export default class PasteHandler extends Extension {
/>
);
}
/**
* 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;
}
+2 -2
View File
@@ -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\)$/, "™️");

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