Compare commits

..

2 Commits

Author SHA1 Message Date
Tom Moor 8e9beac59f test 2023-08-08 23:12:41 -04:00
Tom Moor a0f7c76405 Add support for SSL in development 2023-08-08 22:46:31 -04:00
1738 changed files with 51848 additions and 122879 deletions
+16 -31
View File
@@ -1,28 +1,29 @@
{ {
"presets": [ "presets": [
"@babel/preset-react", "@babel/preset-react",
"@babel/preset-env", "@babel/preset-typescript",
"@babel/preset-typescript" [
"@babel/preset-env",
{
"corejs": {
"version": "3",
"proposals": true
},
"useBuiltIns": "usage"
}
]
], ],
"plugins": [ "plugins": [
"babel-plugin-transform-typescript-metadata", "styled-components",
[ [
"@babel/plugin-proposal-decorators", "@babel/plugin-proposal-decorators",
{ {
"legacy": true "legacy": true
} }
], ],
"@babel/plugin-transform-class-properties", "@babel/plugin-transform-destructuring",
[ "@babel/plugin-transform-regenerator",
"transform-inline-environment-variables", "transform-class-properties"
{
"include": [
"SOURCE_COMMIT",
"SOURCE_VERSION"
]
}
],
"tsconfig-paths-module-resolver"
], ],
"env": { "env": {
"production": { "production": {
@@ -35,29 +36,13 @@
] ]
], ],
"ignore": [ "ignore": [
"**/__mocks__",
"**/*.test.ts" "**/*.test.ts"
] ]
}, },
"development": { "development": {
"ignore": [ "ignore": [
"**/__mocks__",
"**/*.test.ts" "**/*.test.ts"
] ]
},
"test": {
"presets": [
[
"@babel/preset-env",
{
"corejs": {
"version": "3",
"proposals": true
},
"useBuiltIns": "usage"
}
]
]
} }
} }
} }
+180
View File
@@ -0,0 +1,180 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:18.12
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
resource_class: large
environment:
NODE_ENV: test
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
DATABASE_URL_TEST: postgres://postgres:password@localhost:5432/circle_test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
SMTP_FROM_EMAIL: hello@example.com
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
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-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: yarn test:server --forceExit
bundle-size:
<<: *defaults
environment:
NODE_ENV: production
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ 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:
version: 20.10.6
- 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/v6,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: docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
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:
- test-app
- test-shared
- test-server
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
+1
View File
@@ -13,4 +13,5 @@ app.json
crowdin.yml crowdin.yml
build build
docker-compose.yml docker-compose.yml
fakes3
node_modules node_modules
-13
View File
@@ -1,13 +0,0 @@
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
# Setting it to true allows React dev tools add-on in Firefox to successfully detect the project
DEVELOPMENT_UNSAFE_INLINE_CSP=true
# Increase the log level to debug for development
LOG_LEVEL=debug
+25 -64
View File
@@ -12,14 +12,15 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default # For production point these at your databases, in development the default
# should work out of the box. # should work out of the box.
DATABASE_URL=postgres://user:pass@postgres:5432/outline DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
DATABASE_CONNECTION_POOL_MIN= DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX= DATABASE_CONNECTION_POOL_MAX=
# Uncomment this to disable SSL for connecting to Postgres # Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable # PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this # 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, # or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation # use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options. # for a list of available options.
@@ -29,44 +30,32 @@ REDIS_URL=redis://redis:6379
# URL should point to the fully qualified, publicly accessible URL. If using a # URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different. # proxy the port in URL and PORT may be different.
URL= URL=https://app.outline.dev:3000
PORT=3000 PORT=3000
# See [documentation](docs/SERVICES.md) on running a separate collaboration # See [documentation](docs/SERVICES.md) on running a separate collaboration
# server, for normal operation this does not need to be set. # server, for normal operation this does not need to be set.
COLLABORATION_URL= COLLABORATION_URL=
# Specify what storage system to use. Possible value is one of "s3" or "local". # To support uploading of images for avatars and document attachments an
# For "local", the avatar images and document attachments will be saved on local disk. # s3-compatible storage must be provided. AWS S3 is recommended for redundancy
FILE_STORAGE=local # however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under # A more detailed guide on setting up S3 is available here:
# which all attachments/images go. Make sure that the process has permissions to create # => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
# this path and also to write files to it. #
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
# Maximum allowed size for the uploaded attachment.
FILE_STORAGE_UPLOAD_MAX_SIZE=262144000
# Override the maximum size of document imports, generally this should be lower
# than the document attachment maximum size.
FILE_STORAGE_IMPORT_MAX_SIZE=
# Override the maximum size of workspace imports, these can be especially large
# and the files are temporary being automatically deleted after a period of time.
FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE=
# To support uploading of images for avatars and document attachments in a distributed
# architecture an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
AWS_ACCESS_KEY_ID=get_a_key_from_aws AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x AWS_REGION=xx-xxxx-x
AWS_S3_ACCELERATE_URL= AWS_S3_ACCELERATE_URL=
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569 AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private AWS_S3_ACL=private
# –––––––––––––– AUTHENTICATION –––––––––––––– # –––––––––––––– AUTHENTICATION ––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack, # Third party signin credentials, at least ONE OF EITHER Google, Slack,
@@ -104,7 +93,6 @@ OIDC_CLIENT_SECRET=
OIDC_AUTH_URI= OIDC_AUTH_URI=
OIDC_TOKEN_URI= OIDC_TOKEN_URI=
OIDC_USERINFO_URI= OIDC_USERINFO_URI=
OIDC_LOGOUT_URI=
# Specify which claims to derive user information from # Specify which claims to derive user information from
# Supports any valid JSON path with the JWT payload # Supports any valid JSON path with the JWT payload
@@ -116,40 +104,6 @@ OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes. # Space separated auth scopes.
OIDC_SCOPES=openid profile email OIDC_SCOPES=openid profile email
# To configure the GitHub integration, you'll need to create a GitHub App at
# => https://github.com/settings/apps
#
# When configuring the Client ID, add a redirect URL under "Permissions & events":
# https://<URL>/api/github.callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
# When configuring the Client ID, add a redirect URL under "OAuth2":
# https://<URL>/auth/discord.callback
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
# integrated with.
# Used to verify that the user is a member of the server as well as server
# metadata such as nicknames, server icon and name.
DISCORD_SERVER_ID=
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
# allowed to access Outline. If this is not set, all members of the server
# will be allowed to access Outline.
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL –––––––––––––––– # –––––––––––––––– OPTIONAL ––––––––––––––––
@@ -177,6 +131,10 @@ ENABLE_UPDATES=true
# available memory by 512 for a rough estimate # available memory by 512 for a rough estimate
WEB_CONCURRENCY=1 WEB_CONCURRENCY=1
# Override the maximum size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
# You can remove this line if your reverse proxy already logs incoming http # You can remove this line if your reverse proxy already logs incoming http
# requests and this ends up being duplicative # requests and this ends up being duplicative
DEBUG=http DEBUG=http
@@ -193,9 +151,8 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true SLACK_MESSAGE_ACTIONS=true
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup # Optionally enable google analytics to track pageviews in the knowledge base
# and do not forget to whitelist your domain name in the app settings GOOGLE_ANALYTICS_ID=
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance, # Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI: # and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
@@ -205,10 +162,14 @@ SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or # 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 # "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_SERVICE= SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM_EMAIL= SMTP_FROM_EMAIL=hello@example.com
SMTP_REPLY_EMAIL=hello@example.com
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of # The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated. # available language codes and their rough percentage translated.
-31
View File
@@ -1,31 +0,0 @@
NODE_ENV=test
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
SMTP_HOST=smtp.example.com
SMTP_USERNAME=test
SMTP_FROM_EMAIL=hello@example.com
SMTP_REPLY_EMAIL=hello@example.com
GOOGLE_CLIENT_ID=123
GOOGLE_CLIENT_SECRET=123
SLACK_CLIENT_ID=123
SLACK_CLIENT_SECRET=123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
OIDC_CLIENT_ID=client-id
OIDC_CLIENT_SECRET=client-secret
OIDC_AUTH_URI=http://localhost/authorize
OIDC_TOKEN_URI=http://localhost/token
OIDC_USERINFO_URI=http://localhost/userinfo
IFRAMELY_API_KEY=123
RATE_LIMITER_ENABLED=false
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/tmp
+2 -13
View File
@@ -21,7 +21,7 @@
"eslint-plugin-import", "eslint-plugin-import",
"eslint-plugin-node", "eslint-plugin-node",
"eslint-plugin-react", "eslint-plugin-react",
"eslint-plugin-lodash" "import"
], ],
"rules": { "rules": {
"eqeqeq": 2, "eqeqeq": 2,
@@ -32,21 +32,11 @@
"object-shorthand": "error", "object-shorthand": "error",
"no-mixed-operators": "off", "no-mixed-operators": "off",
"no-useless-escape": "off", "no-useless-escape": "off",
"no-shadow": "off",
"es/no-regexp-lookbehind-assertions": "error", "es/no-regexp-lookbehind-assertions": "error",
"react/self-closing-comp": ["error", { "react/self-closing-comp": ["error", {
"component": true, "component": true,
"html": true "html": true
}], }],
"@typescript-eslint/no-shadow": [
"warn",
{
"allow": ["transaction"],
"hoist": "all",
"ignoreTypeValueShadow": true
}
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error", "@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": [ "@typescript-eslint/no-misused-promises": [
@@ -65,7 +55,6 @@
], ],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }], "padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }], "lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["warn", "method"],
"import/no-named-as-default": "off", "import/no-named-as-default": "off",
"import/no-named-as-default-member": "off", "import/no-named-as-default-member": "off",
"import/newline-after-import": 2, "import/newline-after-import": 2,
@@ -140,4 +129,4 @@
"typescript": {} "typescript": {}
} }
} }
} }
+37
View File
@@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots or videos to help explain your problem.
**Outline (please complete the following information):**
- Install: [getoutline.com or self hosted]
- Version: [commit sha if self hosted]
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Mobile (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
-63
View File
@@ -1,63 +0,0 @@
name: Bug report
description: File a bug to help us improve
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- Outline:
- Browser:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
-2
View File
@@ -15,8 +15,6 @@ requestInfoDefaultTitles:
requestInfoLabelToAdd: more information needed requestInfoLabelToAdd: more information needed
requestInfoUserstoExclude:
- tommoor
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome # Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
-13
View File
@@ -13,16 +13,3 @@ updates:
update-types: ["version-update:semver-major"] update-types: ["version-update:semver-major"]
schedule: schedule:
interval: "weekly" interval: "weekly"
groups:
babel:
patterns:
- "@babel/*"
sentry:
patterns:
- "@sentry/*"
fortawesome:
patterns:
- "@fortawesome/*"
aws:
patterns:
- "@aws-sdk/*"
+1 -1
View File
@@ -1,7 +1,7 @@
# Configuration for probot-no-response - https://github.com/probot/no-response # Configuration for probot-no-response - https://github.com/probot/no-response
# Number of days of inactivity before an Issue is closed for lack of response # Number of days of inactivity before an Issue is closed for lack of response
daysUntilClose: 7 daysUntilClose: 14
# Label requiring a response # Label requiring a response
responseRequiredLabel: more information needed responseRequiredLabel: more information needed
-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=8000
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
SMTP_USERNAME: localhost
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
lint:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
server:
- 'server/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
app:
- 'app/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
test:
needs: build
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14.2
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: outline_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:5.0
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
-52
View File
@@ -1,52 +0,0 @@
name: Docker
on:
push:
tags:
- 'v*'
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.base
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build and push main image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
-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 operations-per-run: 60
stale-issue-label: stale stale-issue-label: stale
stale-pr-label: stale stale-pr-label: stale
exempt-issue-labels: "security,pinned,A1" exempt-issue-labels: "security,pinned"
- name: Print outputs - name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }} run: echo ${{ join(steps.stale.outputs.*, ',') }}
+1 -3
View File
@@ -2,14 +2,12 @@ dist
build build
node_modules/* node_modules/*
.env .env
.env.local
.env.production
.log .log
.vscode/* .vscode/*
npm-debug.log npm-debug.log
stats.json stats.json
.DS_Store .DS_Store
data/* fakes3/*
.idea .idea
*.pem *.pem
*.key *.key
+10 -13
View File
@@ -1,20 +1,20 @@
{ {
"workerIdleMemoryLimit": "0.75", "workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"projects": [ "projects": [
{ {
"displayName": "server", "displayName": "server",
"roots": ["<rootDir>/server", "<rootDir>/plugins"], "roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": { "moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1", "^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1"
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"setupFiles": ["<rootDir>/__mocks__/console.js"], "setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/env.ts"
],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"], "setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalSetup": "<rootDir>/server/test/globalSetup.js", "testEnvironment": "node",
"globalTeardown": "<rootDir>/server/test/globalTeardown.js", "runner": "@getoutline/jest-runner-serial"
"testEnvironment": "node"
}, },
{ {
"displayName": "app", "displayName": "app",
@@ -23,8 +23,7 @@
"^~/(.*)$": "<rootDir>/app/$1", "^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js", "^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js", "^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"modulePaths": ["<rootDir>/app"], "modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"], "setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -39,8 +38,7 @@
"roots": ["<rootDir>/shared"], "roots": ["<rootDir>/shared"],
"moduleNameMapper": { "moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1", "^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1"
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"setupFiles": ["<rootDir>/__mocks__/console.js"], "setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"], "setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
@@ -53,8 +51,7 @@
"^~/(.*)$": "<rootDir>/app/$1", "^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js", "^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js", "^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"setupFiles": ["<rootDir>/__mocks__/window.js"], "setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom", "testEnvironment": "jsdom",
+2 -3
View File
@@ -1,6 +1,4 @@
require("dotenv").config({ require('dotenv').config({ silent: true });
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
});
var path = require('path'); var path = require('path');
@@ -8,4 +6,5 @@ module.exports = {
'config': path.resolve('server/config', 'database.json'), 'config': path.resolve('server/config', 'database.json'),
'migrations-path': path.resolve('server', 'migrations'), 'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'), 'models-path': path.resolve('server', 'models'),
'seeders-path': path.resolve('server/models', 'fixtures'),
} }
+8 -23
View File
@@ -1,17 +1,19 @@
ARG APP_PATH=/opt/outline ARG APP_PATH=/opt/outline
FROM outlinewiki/outline-base AS base FROM outlinewiki/outline-base as base
ARG APP_PATH ARG APP_PATH
WORKDIR $APP_PATH WORKDIR $APP_PATH
# --- # ---
FROM node:20-slim AS runner FROM node:18-alpine AS runner
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
LABEL org.opencontainers.image.source="https://github.com/outline/outline" LABEL org.opencontainers.image.source="https://github.com/outline/outline"
ARG APP_PATH ARG APP_PATH
WORKDIR $APP_PATH WORKDIR $APP_PATH
ENV NODE_ENV=production ENV NODE_ENV production
COPY --from=base $APP_PATH/build ./build COPY --from=base $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server COPY --from=base $APP_PATH/server ./server
@@ -20,28 +22,11 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json COPY --from=base $APP_PATH/package.json ./package.json
# Install wget to healthcheck the server RUN addgroup -g 1001 -S nodejs && \
RUN apt-get update \ adduser -S nodejs -u 1001 && \
&& apt-get install -y wget \ chown -R nodejs:nodejs $APP_PATH/build
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user compatible with Debian and BusyBox based images
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
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
VOLUME /var/lib/outline/data
USER nodejs USER nodejs
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
EXPOSE 3000 EXPOSE 3000
CMD ["yarn", "start"] CMD ["yarn", "start"]
+1 -3
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline ARG APP_PATH=/opt/outline
FROM node:20-slim AS deps FROM node:18-alpine AS deps
ARG APP_PATH ARG APP_PATH
WORKDIR $APP_PATH WORKDIR $APP_PATH
@@ -17,5 +17,3 @@ RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \ RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean yarn cache clean
ENV PORT=3000
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters Parameters
Licensor: General Outline, Inc. Licensor: General Outline, Inc.
Licensed Work: Outline 0.82.0 Licensed Work: Outline 0.64.0
The Licensed Work is (c) 2025 General Outline, Inc. The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document you may not use the Licensed Work for a Document
Service. 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 Licensed Work by creating teams and documents
controlled by such third parties. controlled by such third parties.
Change Date: 2029-02-15 Change Date: 2026-05-23
Change License: Apache License, Version 2.0 Change License: Apache License, Version 2.0
+12 -12
View File
@@ -1,28 +1,28 @@
up: up:
docker compose up -d redis postgres docker-compose up -d redis postgres s3
yarn install-local-ssl yarn install-local-ssl
yarn install --pure-lockfile yarn install --pure-lockfile
yarn dev:watch yarn dev:watch
build: build:
docker compose build --pull outline docker-compose build --pull outline
test: test:
docker compose up -d redis postgres docker-compose up -d redis postgres s3
NODE_ENV=test yarn sequelize db:drop yarn sequelize db:drop --env=test
NODE_ENV=test yarn sequelize db:create yarn sequelize db:create --env=test
NODE_ENV=test yarn sequelize db:migrate yarn sequelize db:migrate --env=test
yarn test yarn test
watch: watch:
docker compose up -d redis postgres docker-compose up -d redis postgres s3
NODE_ENV=test yarn sequelize db:drop yarn sequelize db:drop --env=test
NODE_ENV=test yarn sequelize db:create yarn sequelize db:create --env=test
NODE_ENV=test yarn sequelize db:migrate yarn sequelize db:migrate --env=test
yarn test:watch yarn test:watch
destroy: destroy:
docker compose stop docker-compose stop
docker compose rm -f docker-compose rm -f
.PHONY: up build destroy test watch # let's go to reserve rules names .PHONY: up build destroy test watch # let's go to reserve rules names
+2 -6
View File
@@ -32,7 +32,7 @@ There is a short guide for [setting up a development environment](https://docs.g
Outline is built and maintained by a small team we'd love your help to fix bugs and add features! Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted. Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline: If youre looking for ways to get started, here's a list of ways to help us improve Outline:
@@ -96,10 +96,6 @@ Or to run migrations on test database:
yarn sequelize db:migrate --env test yarn sequelize db:migrate --env test
``` ```
# Activity ## License
![Alt](https://repobeats.axiom.co/api/embed/ff2e4e6918afff1acf9deb72d1ba6b071d586178.svg "Repobeats analytics image")
# License
Outline is [BSL 1.1 licensed](LICENSE). Outline is [BSL 1.1 licensed](LICENSE).
-1
View File
@@ -1 +0,0 @@
export default null;
+7 -30
View File
@@ -3,13 +3,7 @@
"description": "Open source wiki and knowledge base for growing teams", "description": "Open source wiki and knowledge base for growing teams",
"website": "https://www.getoutline.com/", "website": "https://www.getoutline.com/",
"repository": "https://github.com/outline/outline", "repository": "https://github.com/outline/outline",
"keywords": [ "keywords": ["wiki", "team", "node", "markdown", "slack"],
"wiki",
"team",
"node",
"markdown",
"slack"
],
"success_url": "/", "success_url": "/",
"formation": { "formation": {
"web": { "web": {
@@ -39,11 +33,6 @@
"generator": "secret", "generator": "secret",
"required": true "required": true
}, },
"UTILS_SECRET": {
"description": "A 32-character secret key, generate with openssl rand -hex 32",
"generator": "secret",
"required": true
},
"ENABLE_UPDATES": { "ENABLE_UPDATES": {
"value": "true", "value": "true",
"required": true "required": true
@@ -92,14 +81,6 @@
"description": "", "description": "",
"required": false "required": false
}, },
"OIDC_DISABLE_REDIRECT": {
"description": "Prevent the app from automatically redirecting to the OIDC login page",
"required": false
},
"OIDC_LOGOUT_URI": {
"description": "",
"required": false
},
"OIDC_USERNAME_CLAIM": { "OIDC_USERNAME_CLAIM": {
"description": "Specify which claims to derive user information from. Supports any valid JSON path with the JWT payload", "description": "Specify which claims to derive user information from. Supports any valid JSON path with the JWT payload",
"value": "preferred_username", "value": "preferred_username",
@@ -147,6 +128,11 @@
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com", "description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
"required": false "required": false
}, },
"AWS_S3_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"AWS_S3_FORCE_PATH_STYLE": { "AWS_S3_FORCE_PATH_STYLE": {
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.", "description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
"value": "true", "value": "true",
@@ -162,19 +148,10 @@
"description": "S3 canned ACL for document attachments", "description": "S3 canned ACL for document attachments",
"required": false "required": false
}, },
"FILE_STORAGE_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"SMTP_HOST": { "SMTP_HOST": {
"description": "smtp.example.com (optional)", "description": "smtp.example.com (optional)",
"required": false "required": false
}, },
"SMTP_SERVICE": {
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
"required": false
},
"SMTP_PORT": { "SMTP_PORT": {
"description": "1234 (optional)", "description": "1234 (optional)",
"required": false "required": false
@@ -222,4 +199,4 @@
"required": false "required": false
} }
} }
} }
+2 -2
View File
@@ -2,10 +2,10 @@
"extends": [ "extends": [
"../.eslintrc", "../.eslintrc",
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react-hooks/recommended" "plugin:react-hooks/recommended",
], ],
"plugins": [ "plugins": [
"eslint-plugin-react-hooks" "eslint-plugin-react-hooks",
], ],
"env": { "env": {
"jest": true, "jest": true,
-25
View File
@@ -1,25 +0,0 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createApiKey = createAction({
name: ({ t }) => t("New API key"),
analyticsName: "New API key",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createApiKey,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New API key"),
content: <ApiKeyNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+22 -217
View File
@@ -1,33 +1,23 @@
import { import {
ArchiveIcon,
CollectionIcon, CollectionIcon,
EditIcon, EditIcon,
PadlockIcon, PadlockIcon,
PlusIcon, PlusIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
StarredIcon, StarredIcon,
SubscribeIcon,
TrashIcon, TrashIcon,
UnstarredIcon, UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons"; } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import stores from "~/stores";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit"; import CollectionEdit from "~/scenes/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew"; import CollectionNew from "~/scenes/CollectionNew";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog"; import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon"; import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections"; import { CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history"; import history from "~/utils/history";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => ( const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} /> <DynamicCollectionIcon collection={collection} />
@@ -44,11 +34,11 @@ export const openCollection = createAction({
return collections.map((collection) => ({ return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust // Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed // cache if the collection is renamed
id: collection.path, id: collection.url,
name: collection.name, name: collection.name,
icon: <ColorCollectionIcon collection={collection} />, icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection, section: CollectionSection,
perform: () => history.push(collection.path), perform: () => history.push(collection.url),
})); }));
}, },
}); });
@@ -61,7 +51,7 @@ export const createCollection = createAction({
keywords: "create", keywords: "create",
visible: ({ stores }) => visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection, stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event, stores }) => { perform: ({ t, event }) => {
event?.preventDefault(); event?.preventDefault();
event?.stopPropagation(); event?.stopPropagation();
stores.dialogs.openModal({ stores.dialogs.openModal({
@@ -75,12 +65,12 @@ export const editCollection = createAction({
name: ({ t, isContextMenu }) => name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"), isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection", analyticsName: "Edit collection",
section: ActiveCollectionSection, section: CollectionSection,
icon: <EditIcon />, icon: <EditIcon />,
visible: ({ activeCollectionId, stores }) => visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId && !!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update, stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => { perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) { if (!activeCollectionId) {
return; return;
} }
@@ -101,62 +91,27 @@ export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) => name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"), isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions", analyticsName: "Collection permissions",
section: ActiveCollectionSection, section: CollectionSection,
icon: <PadlockIcon />, icon: <PadlockIcon />,
visible: ({ activeCollectionId, stores }) => visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId && !!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update, stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => { perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) { if (!activeCollectionId) {
return; return;
} }
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({ stores.dialogs.openModal({
title: t("Share this collection"), title: t("Collection permissions"),
style: { marginBottom: -12 }, content: <CollectionPermissions collectionId={activeCollectionId} />,
content: (
<SharePopover
collection={collection}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
),
}); });
}, },
}); });
export const searchInCollection = createAction({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection?.isActive) {
return false;
}
return stores.policies.abilities(activeCollectionId).readDocument;
},
perform: ({ activeCollectionId }) => {
history.push(searchPath({ collectionId: activeCollectionId }));
},
});
export const starCollection = createAction({ export const starCollection = createAction({
name: ({ t }) => t("Star"), name: ({ t }) => t("Star"),
analyticsName: "Star collection", analyticsName: "Star collection",
section: ActiveCollectionSection, section: CollectionSection,
icon: <StarredIcon />, icon: <StarredIcon />,
keywords: "favorite bookmark", keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => { visible: ({ activeCollectionId, stores }) => {
@@ -176,14 +131,13 @@ export const starCollection = createAction({
const collection = stores.collections.get(activeCollectionId); const collection = stores.collections.get(activeCollectionId);
await collection?.star(); await collection?.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
}, },
}); });
export const unstarCollection = createAction({ export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"), name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection", analyticsName: "Unstar collection",
section: ActiveCollectionSection, section: CollectionSection,
icon: <UnstarredIcon />, icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark", keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => { visible: ({ activeCollectionId, stores }) => {
@@ -206,137 +160,10 @@ 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",
section: CollectionSection,
icon: <ArchiveIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).archive;
},
perform: async ({ activeCollectionId, stores, t }) => {
const { dialogs, collections } = stores;
if (!activeCollectionId) {
return;
}
const collection = collections.get(activeCollectionId);
if (!collection) {
return;
}
dialogs.openModal({
title: t("Archive collection"),
content: (
<ConfirmationDialog
onSubmit={async () => {
await collection.archive();
toast.success(t("Collection archived"));
}}
submitText={t("Archive")}
savingText={`${t("Archiving")}`}
>
{t(
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results."
)}
</ConfirmationDialog>
),
});
},
});
export const restoreCollection = createAction({
name: ({ t }) => t("Restore"),
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).restore;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
await collection.restore();
toast.success(t("Collection restored"));
},
});
export const deleteCollection = createAction({ export const deleteCollection = createAction({
name: ({ t }) => `${t("Delete")}`, name: ({ t }) => t("Delete"),
analyticsName: "Delete collection", analyticsName: "Delete collection",
section: ActiveCollectionSection, section: CollectionSection,
dangerous: true,
icon: <TrashIcon />, icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => { visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) { if (!activeCollectionId) {
@@ -344,7 +171,7 @@ export const deleteCollection = createAction({
} }
return stores.policies.abilities(activeCollectionId).delete; return stores.policies.abilities(activeCollectionId).delete;
}, },
perform: ({ activeCollectionId, t, stores }) => { perform: ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) { if (!activeCollectionId) {
return; return;
} }
@@ -355,6 +182,7 @@ export const deleteCollection = createAction({
} }
stores.dialogs.openModal({ stores.dialogs.openModal({
isCentered: true,
title: t("Delete collection"), title: t("Delete collection"),
content: ( content: (
<CollectionDeleteDialog <CollectionDeleteDialog
@@ -366,33 +194,10 @@ export const deleteCollection = createAction({
}, },
}); });
export const createTemplate = createAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
perform: ({ activeCollectionId, event }) => {
if (!activeCollectionId) {
return;
}
event?.preventDefault();
event?.stopPropagation();
history.push(newTemplatePath(activeCollectionId));
},
});
export const rootCollectionActions = [ export const rootCollectionActions = [
openCollection, openCollection,
createCollection, createCollection,
starCollection, starCollection,
unstarCollection, unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection, deleteCollection,
]; ];
-123
View File
@@ -1,123 +0,0 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createAction } from "..";
import { DocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
onDelete,
}: {
comment: Comment;
onDelete: () => void;
}) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: DocumentSection,
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
visible: () => stores.policies.abilities(comment.id).delete,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Delete comment"),
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
},
});
export const resolveCommentFactory = ({
comment,
onResolve,
}: {
comment: Comment;
onResolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () =>
stores.policies.abilities(comment.id).resolve &&
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onResolve();
toast.success(t("Thread resolved"));
},
});
export const unresolveCommentFactory = ({
comment,
onUnresolve,
}: {
comment: Comment;
onUnresolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () =>
stores.policies.abilities(comment.id).unresolve &&
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onUnresolve();
},
});
export const viewCommentReactionsFactory = ({
comment,
}: {
comment: Comment;
}) =>
createAction({
name: ({ t }) => `${t("View reactions")}`,
analyticsName: "View comment reactions",
section: DocumentSection,
icon: <SmileyIcon />,
visible: () =>
stores.policies.abilities(comment.id).read &&
comment.reactions.length > 0,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Reactions"),
content: <ViewReactionsDialog model={comment} />,
});
},
});
+14 -161
View File
@@ -1,153 +1,38 @@
import copy from "copy-to-clipboard"; import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
import {
BeakerIcon,
CopyIcon,
EditIcon,
ToolsIcon,
TrashIcon,
UserIcon,
} from "outline-icons";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import stores from "~/stores";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections"; import { DeveloperSection } from "~/actions/sections";
import env from "~/env"; import env from "~/env";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import Logger from "~/utils/Logger"; import Logger from "~/utils/Logger";
import { deleteAllDatabases } from "~/utils/developer"; import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
import { homePath } from "~/utils/routeHelpers";
export const copyId = createAction({
name: ({ t }) => t("Copy ID"),
icon: <CopyIcon />,
keywords: "uuid",
section: DeveloperSection,
children: ({
currentTeamId,
currentUserId,
activeCollectionId,
activeDocumentId,
}) => {
function copyAndToast(text: string | null | undefined) {
if (text) {
copy(text);
toast.success("Copied to clipboard");
}
}
return [
createAction({
name: "Copy User ID",
section: DeveloperSection,
icon: <CopyIcon />,
visible: () => !!currentUserId,
perform: () => copyAndToast(currentUserId),
}),
createAction({
name: "Copy Team ID",
section: DeveloperSection,
icon: <CopyIcon />,
visible: () => !!currentTeamId,
perform: () => copyAndToast(currentTeamId),
}),
createAction({
name: "Copy Collection ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!activeCollectionId,
perform: () => copyAndToast(activeCollectionId),
}),
createAction({
name: "Copy Document ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!activeDocumentId,
perform: () => copyAndToast(activeDocumentId),
}),
createAction({
name: "Copy Team ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!currentTeamId,
perform: () => copyAndToast(currentTeamId),
}),
createAction({
name: "Copy Release ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!env.VERSION,
perform: () => copyAndToast(env.VERSION),
}),
];
},
});
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({ export const clearIndexedDB = createAction({
name: ({ t }) => t("Clear IndexedDB cache"), name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />, icon: <TrashIcon />,
keywords: "cache clear database", keywords: "cache clear database",
section: DeveloperSection, section: DeveloperSection,
perform: async ({ t }) => { perform: async ({ t }) => {
history.push(homePath());
await deleteAllDatabases(); await deleteAllDatabases();
toast.success(t("IndexedDB cache cleared")); stores.toasts.showToast(t("IndexedDB cache deleted"));
}, },
}); });
export const createTestUsers = createAction({ export const createTestUsers = createAction({
name: "Create 10 test users", name: "Create test users",
icon: <UserIcon />, icon: <UserIcon />,
section: DeveloperSection, section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development", visible: () => env.ENVIRONMENT === "development",
perform: async () => { perform: async () => {
const count = 10; const count = 10;
await client.post("/developer.create_test_users", { count });
toast.message(`${count} test users created`);
},
});
export const createToast = createAction({ try {
name: "Create toast", await client.post("/developer.create_test_users", { count });
section: DeveloperSection, stores.toasts.showToast(`${count} test users created`);
visible: () => env.ENVIRONMENT === "development", } catch (err) {
perform: () => { stores.toasts.showToast(err.message, { type: "error" });
toast.message("Hello world", { }
duration: 30000,
});
}, },
}); });
@@ -155,9 +40,9 @@ export const toggleDebugLogging = createAction({
name: ({ t }) => t("Toggle debug logging"), name: ({ t }) => t("Toggle debug logging"),
icon: <ToolsIcon />, icon: <ToolsIcon />,
section: DeveloperSection, section: DeveloperSection,
perform: ({ t }) => { perform: async ({ t }) => {
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled; Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
toast.message( stores.toasts.showToast(
Logger.debugLoggingEnabled Logger.debugLoggingEnabled
? t("Debug logging enabled") ? t("Debug logging enabled")
: t("Debug logging disabled") : t("Debug logging disabled")
@@ -165,45 +50,13 @@ export const toggleDebugLogging = createAction({
}, },
}); });
export const toggleFeatureFlag = createAction({
name: "Toggle feature flag",
icon: <BeakerIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
children: Object.values(Feature).map((flag) =>
createAction({
id: `flag-${flag}`,
name: flag,
selected: () => FeatureFlags.isEnabled(flag),
section: DeveloperSection,
perform: () => {
if (FeatureFlags.isEnabled(flag)) {
FeatureFlags.disable(flag);
toast.success(`Disabled feature flag: ${flag}`);
} else {
FeatureFlags.enable(flag);
toast.success(`Enabled feature flag: ${flag}`);
}
},
})
),
});
export const developer = createAction({ export const developer = createAction({
name: ({ t }) => t("Development"), name: ({ t }) => t("Development"),
keywords: "debug", keywords: "debug",
icon: <ToolsIcon />, icon: <ToolsIcon />,
iconInContextMenu: false, iconInContextMenu: false,
section: DeveloperSection, section: DeveloperSection,
children: [ children: [clearIndexedDB, toggleDebugLogging, createTestUsers],
copyId,
toggleDebugLogging,
toggleFeatureFlag,
createToast,
createTestUsers,
clearIndexedDB,
startTyping,
],
}); });
export const rootDeveloperActions = [developer]; export const rootDeveloperActions = [developer];
File diff suppressed because it is too large Load Diff
+27 -55
View File
@@ -3,32 +3,37 @@ import {
SearchIcon, SearchIcon,
ArchiveIcon, ArchiveIcon,
TrashIcon, TrashIcon,
EditIcon,
OpenIcon, OpenIcon,
SettingsIcon, SettingsIcon,
ShapesIcon,
KeyboardIcon, KeyboardIcon,
EmailIcon, EmailIcon,
LogoutIcon, LogoutIcon,
ProfileIcon, ProfileIcon,
BrowserIcon, BrowserIcon,
ShapesIcon,
DraftsIcon,
} from "outline-icons"; } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { UrlHelper } from "@shared/utils/UrlHelper"; import {
import { isMac } from "@shared/utils/browser"; developersUrl,
changelogUrl,
feedbackUrl,
githubIssuesUrl,
} from "@shared/utils/urlHelpers";
import stores from "~/stores"; import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery"; import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts"; import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections"; import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import env from "~/env";
import Desktop from "~/utils/Desktop"; import Desktop from "~/utils/Desktop";
import { isMac } from "~/utils/browser";
import history from "~/utils/history"; import history from "~/utils/history";
import isCloudHosted from "~/utils/isCloudHosted"; import isCloudHosted from "~/utils/isCloudHosted";
import { import {
homePath, homePath,
searchPath, searchPath,
draftsPath, draftsPath,
templatesPath,
archivePath, archivePath,
trashPath, trashPath,
settingsPath, settingsPath,
@@ -50,25 +55,25 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query, name: searchQuery.query,
analyticsName: "Navigate to recent search query", analyticsName: "Navigate to recent search query",
icon: <SearchIcon />, icon: <SearchIcon />,
perform: () => history.push(searchPath({ query: searchQuery.query })), perform: () => history.push(searchPath(searchQuery.query)),
}); });
export const navigateToDrafts = createAction({ export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"), name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts", analyticsName: "Navigate to drafts",
section: NavigationSection, section: NavigationSection,
icon: <DraftsIcon />, icon: <EditIcon />,
perform: () => history.push(draftsPath()), perform: () => history.push(draftsPath()),
visible: ({ location }) => location.pathname !== draftsPath(), visible: ({ location }) => location.pathname !== draftsPath(),
}); });
export const navigateToSearch = createAction({ export const navigateToTemplates = createAction({
name: ({ t }) => t("Search"), name: ({ t }) => t("Templates"),
analyticsName: "Navigate to search", analyticsName: "Navigate to templates",
section: NavigationSection, section: NavigationSection,
icon: <SearchIcon />, icon: <ShapesIcon />,
perform: () => history.push(searchPath()), perform: () => history.push(templatesPath()),
visible: ({ location }) => location.pathname !== searchPath(), visible: ({ location }) => location.pathname !== templatesPath(),
}); });
export const navigateToArchive = createAction({ export const navigateToArchive = createAction({
@@ -96,16 +101,8 @@ export const navigateToSettings = createAction({
section: NavigationSection, section: NavigationSection,
shortcut: ["g", "s"], shortcut: ["g", "s"],
icon: <SettingsIcon />, icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update, visible: ({ stores }) =>
perform: () => history.push(settingsPath()), stores.policies.abilities(stores.auth.team?.id || "").update,
});
export const navigateToWorkspaceSettings = createAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to workspace settings",
section: NavigationSection,
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(settingsPath("details")), perform: () => history.push(settingsPath("details")),
}); });
@@ -118,15 +115,6 @@ export const navigateToProfileSettings = createAction({
perform: () => history.push(settingsPath()), perform: () => history.push(settingsPath()),
}); });
export const navigateToTemplateSettings = createAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to template settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ShapesIcon />,
perform: () => history.push(settingsPath("templates")),
});
export const navigateToNotificationSettings = createAction({ export const navigateToNotificationSettings = createAction({
name: ({ t }) => t("Notifications"), name: ({ t }) => t("Notifications"),
analyticsName: "Navigate to notification settings", analyticsName: "Navigate to notification settings",
@@ -145,22 +133,13 @@ export const navigateToAccountPreferences = createAction({
perform: () => history.push(settingsPath("preferences")), perform: () => history.push(settingsPath("preferences")),
}); });
export const openDocumentation = createAction({
name: ({ t }) => t("Documentation"),
analyticsName: "Open documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(UrlHelper.guide),
});
export const openAPIDocumentation = createAction({ export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"), name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation", analyticsName: "Open API documentation",
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <OpenIcon />, icon: <OpenIcon />,
perform: () => window.open(UrlHelper.developers), perform: () => window.open(developersUrl()),
}); });
export const toggleSidebar = createAction({ export const toggleSidebar = createAction({
@@ -168,7 +147,7 @@ export const toggleSidebar = createAction({
analyticsName: "Toggle sidebar", analyticsName: "Toggle sidebar",
keywords: "hide show navigation", keywords: "hide show navigation",
section: NavigationSection, section: NavigationSection,
perform: () => stores.ui.toggleCollapsedSidebar(), perform: ({ stores }) => stores.ui.toggleCollapsedSidebar(),
}); });
export const openFeedbackUrl = createAction({ export const openFeedbackUrl = createAction({
@@ -177,14 +156,14 @@ export const openFeedbackUrl = createAction({
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <EmailIcon />, icon: <EmailIcon />,
perform: () => window.open(UrlHelper.contact), perform: () => window.open(feedbackUrl()),
}); });
export const openBugReportUrl = createAction({ export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"), name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report", analyticsName: "Open bug report",
section: NavigationSection, section: NavigationSection,
perform: () => window.open(UrlHelper.github), perform: () => window.open(githubIssuesUrl()),
}); });
export const openChangelog = createAction({ export const openChangelog = createAction({
@@ -193,7 +172,7 @@ export const openChangelog = createAction({
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <OpenIcon />, icon: <OpenIcon />,
perform: () => window.open(UrlHelper.changelog), perform: () => window.open(changelogUrl()),
}); });
export const openKeyboardShortcuts = createAction({ export const openKeyboardShortcuts = createAction({
@@ -231,23 +210,16 @@ export const logout = createAction({
analyticsName: "Log out", analyticsName: "Log out",
section: NavigationSection, section: NavigationSection,
icon: <LogoutIcon />, icon: <LogoutIcon />,
perform: async () => { perform: () => stores.auth.logout(),
await stores.auth.logout();
if (env.OIDC_LOGOUT_URI) {
setTimeout(() => {
window.location.replace(env.OIDC_LOGOUT_URI);
}, 200);
}
},
}); });
export const rootNavigationActions = [ export const rootNavigationActions = [
navigateToHome, navigateToHome,
navigateToDrafts, navigateToDrafts,
navigateToTemplates,
navigateToArchive, navigateToArchive,
navigateToTrash, navigateToTrash,
downloadApp, downloadApp,
openDocumentation,
openAPIDocumentation, openAPIDocumentation,
openFeedbackUrl, openFeedbackUrl,
openBugReportUrl, openBugReportUrl,
+2 -15
View File
@@ -1,4 +1,4 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons"; import { MarkAsReadIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { createAction } from ".."; import { createAction } from "..";
import { NotificationSection } from "../sections"; import { NotificationSection } from "../sections";
@@ -13,17 +13,4 @@ export const markNotificationsAsRead = createAction({
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0, visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
}); });
export const markNotificationsAsArchived = createAction({ export const rootNotificationActions = [markNotificationsAsRead];
name: ({ t }) => t("Archive all notifications"),
analyticsName: "Mark notifications as archived",
section: NotificationSection,
icon: <ArchiveIcon />,
iconInContextMenu: false,
perform: ({ stores }) => stores.notifications.markAllAsArchived(),
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
});
export const rootNotificationActions = [
markNotificationsAsRead,
markNotificationsAsArchived,
];
+5 -4
View File
@@ -2,7 +2,6 @@ import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon } from "outline-icons"; import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { matchPath } from "react-router-dom"; import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores"; import stores from "~/stores";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections"; import { RevisionSection } from "~/actions/sections";
@@ -17,7 +16,7 @@ export const restoreRevision = createAction({
analyticsName: "Restore revision", analyticsName: "Restore revision",
icon: <RestoreIcon />, icon: <RestoreIcon />,
section: RevisionSection, section: RevisionSection,
visible: ({ activeDocumentId }) => visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update, !!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId }) => { perform: async ({ event, location, activeDocumentId }) => {
event?.preventDefault(); event?.preventDefault();
@@ -47,7 +46,7 @@ export const copyLinkToRevision = createAction({
analyticsName: "Copy link to revision", analyticsName: "Copy link to revision",
icon: <LinkIcon />, icon: <LinkIcon />,
section: RevisionSection, section: RevisionSection,
perform: async ({ activeDocumentId, t }) => { perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) { if (!activeDocumentId) {
return; return;
} }
@@ -69,7 +68,9 @@ export const copyLinkToRevision = createAction({
copy(url, { copy(url, {
format: "text/plain", format: "text/plain",
onCopy: () => { onCopy: () => {
toast.message(t("Link copied")); stores.toasts.showToast(t("Link copied"), {
type: "info",
});
}, },
}); });
}, },
+2 -23
View File
@@ -1,14 +1,12 @@
import { ArrowIcon, PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { stringToColor } from "@shared/utils/color"; import { stringToColor } from "@shared/utils/color";
import RootStore from "~/stores/RootStore"; import RootStore from "~/stores/RootStore";
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew"; import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo"; import TeamLogo from "~/components/TeamLogo";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { ActionContext } from "~/types"; import { ActionContext } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections"; import { TeamSection } from "../sections";
export const createTeamsList = ({ stores }: { stores: RootStore }) => export const createTeamsList = ({ stores }: { stores: RootStore }) =>
@@ -62,33 +60,14 @@ export const createTeam = createAction({
user && user &&
stores.dialogs.openModal({ stores.dialogs.openModal({
title: t("Create a workspace"), title: t("Create a workspace"),
fullscreen: true,
content: <TeamNew user={user} />, content: <TeamNew user={user} />,
}); });
}, },
}); });
export const desktopLoginTeam = createAction({
name: ({ t }) => t("Login to workspace"),
analyticsName: "Login to workspace",
keywords: "change switch workspace organization team",
section: TeamSection,
icon: <ArrowIcon />,
visible: () => Desktop.isElectron(),
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Login to workspace"),
content: <LoginDialog />,
});
},
});
const StyledTeamLogo = styled(TeamLogo)` const StyledTeamLogo = styled(TeamLogo)`
border-radius: 2px; border-radius: 2px;
border: 0; border: 0;
`; `;
export const rootTeamActions = [switchTeam, createTeam, desktopLoginTeam]; export const rootTeamActions = [switchTeam, createTeam];
+5 -45
View File
@@ -1,14 +1,8 @@
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import stores from "~/stores"; import stores from "~/stores";
import User from "~/models/User";
import Invite from "~/scenes/Invite"; import Invite from "~/scenes/Invite";
import { import { UserDeleteDialog } from "~/components/UserDialogs";
UserChangeRoleDialog,
UserDeleteDialog,
} from "~/components/UserDialogs";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections"; import { UserSection } from "~/actions/sections";
@@ -18,51 +12,16 @@ export const inviteUser = createAction({
icon: <PlusIcon />, icon: <PlusIcon />,
keywords: "team member workspace user", keywords: "team member workspace user",
section: UserSection, section: UserSection,
visible: () => visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser, stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => { perform: ({ t }) => {
stores.dialogs.openModal({ stores.dialogs.openModal({
title: t("Invite to workspace"), title: t("Invite people"),
content: <Invite onSubmit={stores.dialogs.closeAllModals} />, content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
}); });
}, },
}); });
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
createAction({
name: ({ t }) =>
UserRoleHelper.isRoleHigher(role, user!.role)
? `${t("Promote to {{ role }}", {
role: UserRoleHelper.displayName(role, t),
})}…`
: `${t("Demote to {{ role }}", {
role: UserRoleHelper.displayName(role, t),
})}…`,
analyticsName: "Update user role",
section: UserSection,
visible: () => {
const can = stores.policies.abilities(user.id);
return UserRoleHelper.isRoleHigher(role, user.role)
? can.promote
: UserRoleHelper.isRoleLower(role, user.role)
? can.demote
: false;
},
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Update role"),
content: (
<UserChangeRoleDialog
user={user}
role={role}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const deleteUserActionFactory = (userId: string) => export const deleteUserActionFactory = (userId: string) =>
createAction({ createAction({
name: ({ t }) => `${t("Delete user")}`, name: ({ t }) => `${t("Delete user")}`,
@@ -70,7 +29,7 @@ export const deleteUserActionFactory = (userId: string) =>
keywords: "leave", keywords: "leave",
dangerous: true, dangerous: true,
section: UserSection, section: UserSection,
visible: () => stores.policies.abilities(userId).delete, visible: ({ stores }) => stores.policies.abilities(userId).delete,
perform: ({ t }) => { perform: ({ t }) => {
const user = stores.users.get(userId); const user = stores.users.get(userId);
if (!user) { if (!user) {
@@ -79,6 +38,7 @@ export const deleteUserActionFactory = (userId: string) =>
stores.dialogs.openModal({ stores.dialogs.openModal({
title: t("Delete user"), title: t("Delete user"),
isCentered: true,
content: ( content: (
<UserDeleteDialog <UserDeleteDialog
user={user} user={user}
+12 -27
View File
@@ -1,6 +1,5 @@
import flattenDeep from "lodash/flattenDeep"; import { flattenDeep } from "lodash";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner";
import { Optional } from "utility-types"; import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { import {
@@ -74,7 +73,15 @@ export function actionToMenuItem(
icon, icon,
visible, visible,
dangerous: action.dangerous, dangerous: action.dangerous,
onClick: () => performAction(action, context), onClick: () => {
try {
action.perform?.(context);
} catch (err) {
context.stores.toasts.showToast(err.message, {
type: "error",
});
}
},
selected: action.selected?.(context), selected: action.selected?.(context),
}; };
} }
@@ -98,11 +105,6 @@ export function actionToKBar(
) )
: []; : [];
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? (action.section.priority as number) ?? 0
: 0;
return [ return [
{ {
id: action.id, id: action.id,
@@ -113,25 +115,8 @@ export function actionToKBar(
keywords: action.keywords ?? "", keywords: action.keywords ?? "",
shortcut: action.shortcut || [], shortcut: action.shortcut || [],
icon: resolvedIcon, icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)), perform: action.perform ? () => action.perform?.(context) : undefined,
perform: action.perform
? () => performAction(action, context)
: undefined,
}, },
].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
children.map((child) => ({ ...child, parent: child.parent ?? action.id })) ].concat(children.map((child) => ({ ...child, parent: action.id })));
);
}
export async function performAction(action: Action, context: ActionContext) {
const result = action.perform?.(context);
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
} }
-28
View File
@@ -2,32 +2,10 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection"); 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}`;
};
ActiveCollectionSection.priority = 0.8;
export const DeveloperSection = ({ t }: ActionContext) => t("Debug"); export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document"); export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
const activeDocument = stores.documents.active;
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
};
ActiveDocumentSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
export const RevisionSection = ({ t }: ActionContext) => t("Revision"); export const RevisionSection = ({ t }: ActionContext) => t("Revision");
export const SettingsSection = ({ t }: ActionContext) => t("Settings"); export const SettingsSection = ({ t }: ActionContext) => t("Settings");
@@ -38,13 +16,7 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const UserSection = ({ t }: ActionContext) => t("People"); export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
export const TeamSection = ({ t }: ActionContext) => t("Workspace"); export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) => export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches"); t("Recent searches");
RecentSearchesSection.priority = -0.1;
export const TrashSection = ({ t }: ActionContext) => t("Trash");
+2 -7
View File
@@ -1,8 +1,6 @@
/* eslint-disable react/prop-types */ /* eslint-disable react/prop-types */
import * as React from "react"; import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip"; import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import { Action, ActionContext } from "~/types"; import { Action, ActionContext } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & { export type Props = React.HTMLAttributes<HTMLButtonElement> & {
@@ -26,7 +24,6 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props, { action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement> ref: React.Ref<HTMLButtonElement>
) { ) {
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false); const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled; const disabled = rest.disabled;
@@ -63,12 +60,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
? (ev) => { ? (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const response = performAction(action, actionContext); const response = action.perform?.(actionContext);
if (response?.finally) { if (response?.finally) {
setExecuting(true); setExecuting(true);
void response.finally( response.finally(() => setExecuting(false));
() => isMounted() && setExecuting(false)
);
} }
} }
: rest.onClick : rest.onClick
+1
View File
@@ -31,6 +31,7 @@ const Actions = styled(Flex)`
left: 0; left: 0;
border-radius: 3px; border-radius: 3px;
background: ${s("background")}; background: ${s("background")};
transition: ${s("backgroundTransition")};
padding: 12px; padding: 12px;
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
+5 -54
View File
@@ -1,15 +1,14 @@
/* eslint-disable prefer-rest-params */ /* eslint-disable prefer-rest-params */
/* global ga */ /* global ga */
import escape from "lodash/escape"; import { escape } from "lodash";
import * as React from "react"; import * as React from "react";
import { IntegrationService, PublicEnv } from "@shared/types"; import { IntegrationService } from "@shared/types";
import env from "~/env"; import env from "~/env";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
}; };
// TODO: Refactor this component to allow injection from plugins
const Analytics: React.FC = ({ children }: Props) => { const Analytics: React.FC = ({ children }: Props) => {
// Google Analytics 3 // Google Analytics 3
React.useEffect(() => { React.useEffect(() => {
@@ -44,16 +43,12 @@ const Analytics: React.FC = ({ children }: Props) => {
React.useEffect(() => { React.useEffect(() => {
const measurementIds = []; const measurementIds = [];
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(env.analytics.settings?.measurementId));
}
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) { if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
measurementIds.push(env.GOOGLE_ANALYTICS_ID); measurementIds.push(env.GOOGLE_ANALYTICS_ID);
} }
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(integration.settings?.measurementId));
}
});
if (measurementIds.length === 0) { if (measurementIds.length === 0) {
return; return;
} }
@@ -80,50 +75,6 @@ const Analytics: React.FC = ({ children }: Props) => {
document.getElementsByTagName("head")[0]?.appendChild(script); document.getElementsByTagName("head")[0]?.appendChild(script);
}, []); }, []);
// Matomo
React.useEffect(() => {
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service !== IntegrationService.Matomo) {
return;
}
// @ts-expect-error - Matomo global variable
const _paq = (window._paq = window._paq || []);
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
const u = integration.settings?.instanceUrl;
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", integration.settings?.measurementId]);
const d = document,
g = d.createElement("script"),
s = d.getElementsByTagName("script")[0];
g.type = "text/javascript";
g.async = true;
g.src = u + "matomo.js";
s.parentNode?.insertBefore(g, s);
})();
});
}, []);
// Umami
React.useEffect(() => {
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service !== IntegrationService.Umami) {
return;
}
const script = document.createElement("script");
script.defer = true;
script.src = `${integration.settings?.instanceUrl}${integration.settings?.scriptName}`;
script.setAttribute(
"data-website-id",
integration.settings?.measurementId
);
document.getElementsByTagName("head")[0]?.appendChild(script);
});
}, []);
return <>{children}</>; return <>{children}</>;
}; };
+21 -17
View File
@@ -1,50 +1,54 @@
import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = React.HTMLAttributes<HTMLDivElement> & { type Props = React.HTMLAttributes<HTMLDivElement> & {
children: () => React.ReactNode; children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void; onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
items: unknown[];
}; };
function ArrowKeyNavigation( function ArrowKeyNavigation(
{ children, onEscape, items, ...rest }: Props, { children, onEscape, ...rest }: Props,
ref: React.RefObject<HTMLDivElement> ref: React.RefObject<HTMLDivElement>
) { ) {
const composite = useCompositeState();
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLDivElement>) => { (ev) => {
if (onEscape) { if (onEscape) {
if (ev.nativeEvent.isComposing) { if (ev.nativeEvent.isComposing) {
return; return;
} }
if (ev.key === "Escape" || ev.key === "Backspace") { if (ev.key === "Escape") {
ev.preventDefault();
onEscape(ev); onEscape(ev);
} }
if ( if (
ev.key === "ArrowUp" && ev.key === "ArrowUp" &&
// If the first item is focused and the user presses ArrowUp composite.currentId === composite.items[0].id
ev.currentTarget.firstElementChild === document.activeElement
) { ) {
onEscape(ev); onEscape(ev);
} }
} }
}, },
[onEscape] [composite.currentId, composite.items, onEscape]
); );
return ( return (
<RovingTabIndexProvider <Composite
options={{ focusOnClick: true, direction: "both" }} {...rest}
items={items} {...composite}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
> >
<div {...rest} onKeyDown={handleKeyDown} ref={ref}> {children(composite)}
{children()} </Composite>
</div>
</RovingTabIndexProvider>
); );
} }
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
type Props = {
size?: number;
fill?: string;
className?: string;
};
function SlackLogo({ size = 34, fill = "#FFF", className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 34 34"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g stroke="none" strokeWidth="1" fillRule="evenodd">
<g transform="translate(0.000000, 17.822581)">
<path d="M7.23870968,3.61935484 C7.23870968,5.56612903 5.6483871,7.15645161 3.7016129,7.15645161 C1.75483871,7.15645161 0.164516129,5.56612903 0.164516129,3.61935484 C0.164516129,1.67258065 1.75483871,0.0822580645 3.7016129,0.0822580645 L7.23870968,0.0822580645 L7.23870968,3.61935484 Z" />
<path d="M9.02096774,3.61935484 C9.02096774,1.67258065 10.6112903,0.0822580645 12.5580645,0.0822580645 C14.5048387,0.0822580645 16.0951613,1.67258065 16.0951613,3.61935484 L16.0951613,12.4758065 C16.0951613,14.4225806 14.5048387,16.0129032 12.5580645,16.0129032 C10.6112903,16.0129032 9.02096774,14.4225806 9.02096774,12.4758065 C9.02096774,12.4758065 9.02096774,3.61935484 9.02096774,3.61935484 Z" />
</g>
<g>
<path d="M12.5580645,7.23870968 C10.6112903,7.23870968 9.02096774,5.6483871 9.02096774,3.7016129 C9.02096774,1.75483871 10.6112903,0.164516129 12.5580645,0.164516129 C14.5048387,0.164516129 16.0951613,1.75483871 16.0951613,3.7016129 L16.0951613,7.23870968 L12.5580645,7.23870968 Z" />
<path d="M12.5580645,9.02096774 C14.5048387,9.02096774 16.0951613,10.6112903 16.0951613,12.5580645 C16.0951613,14.5048387 14.5048387,16.0951613 12.5580645,16.0951613 L3.7016129,16.0951613 C1.75483871,16.0951613 0.164516129,14.5048387 0.164516129,12.5580645 C0.164516129,10.6112903 1.75483871,9.02096774 3.7016129,9.02096774 C3.7016129,9.02096774 12.5580645,9.02096774 12.5580645,9.02096774 Z" />
</g>
<g transform="translate(17.822581, 0.000000)">
<path d="M8.93870968,12.5580645 C8.93870968,10.6112903 10.5290323,9.02096774 12.4758065,9.02096774 C14.4225806,9.02096774 16.0129032,10.6112903 16.0129032,12.5580645 C16.0129032,14.5048387 14.4225806,16.0951613 12.4758065,16.0951613 L8.93870968,16.0951613 L8.93870968,12.5580645 Z" />
<path d="M7.15645161,12.5580645 C7.15645161,14.5048387 5.56612903,16.0951613 3.61935484,16.0951613 C1.67258065,16.0951613 0.0822580645,14.5048387 0.0822580645,12.5580645 L0.0822580645,3.7016129 C0.0822580645,1.75483871 1.67258065,0.164516129 3.61935484,0.164516129 C5.56612903,0.164516129 7.15645161,1.75483871 7.15645161,3.7016129 L7.15645161,12.5580645 Z" />
</g>
<g transform="translate(17.822581, 17.822581)">
<path d="M3.61935484,8.93870968 C5.56612903,8.93870968 7.15645161,10.5290323 7.15645161,12.4758065 C7.15645161,14.4225806 5.56612903,16.0129032 3.61935484,16.0129032 C1.67258065,16.0129032 0.0822580645,14.4225806 0.0822580645,12.4758065 L0.0822580645,8.93870968 L3.61935484,8.93870968 Z" />
<path d="M3.61935484,7.15645161 C1.67258065,7.15645161 0.0822580645,5.56612903 0.0822580645,3.61935484 C0.0822580645,1.67258065 1.67258065,0.0822580645 3.61935484,0.0822580645 L12.4758065,0.0822580645 C14.4225806,0.0822580645 16.0129032,1.67258065 16.0129032,3.61935484 C16.0129032,5.56612903 14.4225806,7.15645161 12.4758065,7.15645161 L3.61935484,7.15645161 Z" />
</g>
</g>
</svg>
);
}
export default SlackLogo;
+4 -7
View File
@@ -2,10 +2,8 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom"; import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language"; import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator"; import LoadingIndicator from "./LoadingIndicator";
type Props = { type Props = {
@@ -15,11 +13,10 @@ type Props = {
const Authenticated = ({ children }: Props) => { const Authenticated = ({ children }: Props) => {
const { auth } = useStores(); const { auth } = useStores();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false }); const language = auth.user?.language;
const language = user?.language;
// Watching for language changes here as this is the earliest point we might have the user // Watching for language changes here as this is the earliest point we have
// available and means we can start loading translations faster // the user available and means we can start loading translations faster
React.useEffect(() => { React.useEffect(() => {
void changeLanguage(language, i18n); void changeLanguage(language, i18n);
}, [i18n, language]); }, [i18n, language]);
@@ -33,7 +30,7 @@ const Authenticated = ({ children }: Props) => {
} }
void auth.logout(true); void auth.logout(true);
return <Redirect to={logoutPath()} />; return <Redirect to="/" />;
}; };
export default observer(Authenticated); export default observer(Authenticated);
+37 -54
View File
@@ -1,22 +1,17 @@
import { AnimatePresence } from "framer-motion"; import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react"; import { observer, useLocalStore } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { import { Switch, Route, useLocation, matchPath } from "react-router-dom";
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { TeamPreference } from "@shared/types"; import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended"; import ErrorSuspended from "~/scenes/ErrorSuspended";
import DocumentContext from "~/components/DocumentContext";
import type { DocumentContextValue } from "~/components/DocumentContext";
import Layout from "~/components/Layout"; import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown"; import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar"; import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right"; import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings"; import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import type { Editor as TEditor } from "~/editor";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import history from "~/utils/history"; import history from "~/utils/history";
@@ -29,9 +24,7 @@ import {
matchDocumentSlug as slug, matchDocumentSlug as slug,
matchDocumentInsights, matchDocumentInsights,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade"; import Fade from "./Fade";
import { PortalContext } from "./Portal";
const DocumentComments = lazyWithRetry( const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments") () => import("~/scenes/Document/components/Comments")
@@ -51,11 +44,14 @@ type Props = {
const AuthenticatedLayout: React.FC = ({ children }: Props) => { const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores(); const { ui, auth } = useStores();
const location = useLocation(); const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null); const can = usePolicy(ui.activeCollectionId);
const can = usePolicy(ui.activeDocumentId); const { user, team } = auth;
const canCollection = usePolicy(ui.activeCollectionId); const documentContext = useLocalStore<DocumentContextValue>(() => ({
const team = useCurrentTeam(); editor: null,
const [spendPostLoginPath] = usePostLoginPath(); setEditor: (editor: TEditor) => {
documentContext.editor = editor;
},
}));
const goToSearch = (ev: KeyboardEvent) => { const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) { if (!ev.metaKey && !ev.ctrlKey) {
@@ -70,7 +66,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return; return;
} }
const { activeCollectionId } = ui; const { activeCollectionId } = ui;
if (!activeCollectionId || !canCollection.createDocument) { if (!activeCollectionId || !can.createDocument) {
return; return;
} }
history.push(newDocumentPath(activeCollectionId)); history.push(newDocumentPath(activeCollectionId));
@@ -80,35 +76,29 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />; return <ErrorSuspended />;
} }
const postLoginPath = spendPostLoginPath(); const showSidebar = auth.authenticated && user && team;
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = ( const sidebar = showSidebar ? (
<Fade> <Fade>
<Switch> <Switch>
<Route path={settingsPath()} component={SettingsSidebar} /> <Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} /> <Route component={Sidebar} />
</Switch> </Switch>
</Fade> </Fade>
); ) : undefined;
const showHistory = const showHistory = !!matchPath(location.pathname, {
!!matchPath(location.pathname, { path: matchDocumentHistory,
path: matchDocumentHistory, });
}) && can.listRevisions; const showInsights = !!matchPath(location.pathname, {
const showInsights = path: matchDocumentInsights,
!!matchPath(location.pathname, { });
path: matchDocumentInsights,
}) && can.listViews;
const showComments = const showComments =
!showInsights && !showInsights &&
!showHistory && !showHistory &&
can.comment &&
ui.activeDocumentId && ui.activeDocumentId &&
ui.commentsExpanded && ui.commentsExpanded.includes(ui.activeDocumentId) &&
team.getPreference(TeamPreference.Commenting); team?.getPreference(TeamPreference.Commenting);
const sidebarRight = ( const sidebarRight = (
<AnimatePresence <AnimatePresence
@@ -130,24 +120,17 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
); );
return ( return (
<DocumentContextProvider> <DocumentContext.Provider value={documentContext}>
<PortalContext.Provider value={layoutRef.current}> <Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
<Layout <RegisterKeyDown trigger="n" handler={goToNewDocument} />
title={team.name} <RegisterKeyDown trigger="t" handler={goToSearch} />
sidebar={sidebar} <RegisterKeyDown trigger="/" handler={goToSearch} />
sidebarRight={sidebarRight} {children}
ref={layoutRef} <React.Suspense fallback={null}>
> <CommandBar />
<RegisterKeyDown trigger="n" handler={goToNewDocument} /> </React.Suspense>
<RegisterKeyDown trigger="t" handler={goToSearch} /> </Layout>
<RegisterKeyDown trigger="/" handler={goToSearch} /> </DocumentContext.Provider>
{children}
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
); );
}; };
+18 -17
View File
@@ -5,12 +5,10 @@ import Initials from "./Initials";
export enum AvatarSize { export enum AvatarSize {
Small = 16, Small = 16,
Toast = 18,
Medium = 24, Medium = 24,
Large = 28, Large = 32,
XLarge = 32, XLarge = 48,
XXLarge = 48, XXLarge = 64,
Upload = 64,
} }
export interface IAvatar { export interface IAvatar {
@@ -21,37 +19,36 @@ export interface IAvatar {
} }
type Props = { type Props = {
/** The size of the avatar */
size: AvatarSize; size: AvatarSize;
/** The source of the avatar image, if not passing a model. */
src?: string; src?: string;
/** The avatar model, if not passing a source. */
model?: IAvatar; model?: IAvatar;
/** The alt text for the image */
alt?: string; alt?: string;
/** Optional click handler */ showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>; onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Optional class name */
className?: string; className?: string;
/** Optional style */
style?: React.CSSProperties; style?: React.CSSProperties;
}; };
function Avatar(props: Props) { function Avatar(props: Props) {
const { model, style, ...rest } = props; const { showBorder, model, style, ...rest } = props;
const src = props.src || model?.avatarUrl; const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false); const [error, handleError] = useBoolean(false);
return ( return (
<Relative style={style}> <Relative style={style}>
{src && !error ? ( {src && !error ? (
<CircleImg onError={handleError} src={src} {...rest} /> <CircleImg
onError={handleError}
src={src}
$showBorder={showBorder}
{...rest}
/>
) : model ? ( ) : model ? (
<Initials color={model.color} {...rest}> <Initials color={model.color} $showBorder={showBorder} {...rest}>
{model.initial} {model.initial}
</Initials> </Initials>
) : ( ) : (
<Initials {...rest} /> <Initials $showBorder={showBorder} {...rest} />
)} )}
</Relative> </Relative>
); );
@@ -67,11 +64,15 @@ const Relative = styled.div`
flex-shrink: 0; flex-shrink: 0;
`; `;
const CircleImg = styled.img<{ size: number }>` const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
display: block; display: block;
width: ${(props) => props.size}px; width: ${(props) => props.size}px;
height: ${(props) => props.size}px; height: ${(props) => props.size}px;
border-radius: 50%; border-radius: 50%;
border: ${(props) =>
props.$showBorder === false
? "none"
: `2px solid ${props.theme.background}`};
flex-shrink: 0; flex-shrink: 0;
overflow: hidden; overflow: hidden;
`; `;
+6 -54
View File
@@ -4,46 +4,18 @@ import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import User from "~/models/User"; import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
/**
* Props for the AvatarWithPresence component
*/
type Props = { type Props = {
/** The user to display the avatar for */
user: User; user: User;
/** Whether the user is currently present in the document */
isPresent: boolean; isPresent: boolean;
/** Whether the user is currently editing the document */
isEditing: boolean; isEditing: boolean;
/** Whether the user is currently observing the document */
isObserving: boolean; isObserving: boolean;
/** Whether this avatar represents the current user */
isCurrentUser: boolean; isCurrentUser: boolean;
/** Optional click handler for the avatar */
onClick?: React.MouseEventHandler<HTMLImageElement>; 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({ function AvatarWithPresence({
onClick, onClick,
user, user,
@@ -51,8 +23,6 @@ function AvatarWithPresence({
isEditing, isEditing,
isObserving, isObserving,
isCurrentUser, isCurrentUser,
size = AvatarSize.Large,
style,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const status = isPresent const status = isPresent
@@ -64,7 +34,7 @@ function AvatarWithPresence({
return ( return (
<> <>
<Tooltip <Tooltip
content={ tooltip={
<Centered> <Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`} <strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && ( {status && (
@@ -77,47 +47,29 @@ function AvatarWithPresence({
} }
placement="bottom" placement="bottom"
> >
<AvatarPresence <AvatarWrapper
$isPresent={isPresent} $isPresent={isPresent}
$isObserving={isObserving} $isObserving={isObserving}
$color={user.color} $color={user.color}
style={style}
> >
<Avatar model={user} onClick={onClick} size={size} /> <Avatar model={user} onClick={onClick} size={32} />
</AvatarPresence> </AvatarWrapper>
</Tooltip> </Tooltip>
</> </>
); );
} }
/**
* Centered container for tooltip content
*/
const Centered = styled.div` const Centered = styled.div`
text-align: center; text-align: center;
`; `;
/**
* Props for the AvatarPresence styled component
*/
type AvatarWrapperProps = { type AvatarWrapperProps = {
/** Whether the user is currently present */
$isPresent: boolean; $isPresent: boolean;
/** Whether the user is currently observing */
$isObserving: boolean; $isObserving: boolean;
/** The user's color for border highlighting */
$color: string; $color: string;
}; };
/** const AvatarWrapper = styled.div<AvatarWrapperProps>`
* 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>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)}; opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out; transition: opacity 250ms ease-in-out;
border-radius: 50%; border-radius: 50%;
-35
View File
@@ -1,35 +0,0 @@
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import Group from "~/models/Group";
import { AvatarSize } from "../Avatar/Avatar";
type Props = {
/** The group to show an avatar for */
group: Group;
/** The size of the icon, 24px is default to match standard avatars */
size?: number;
/** The color of the avatar */
color?: string;
/** The background color of the avatar */
backgroundColor?: string;
className?: string;
};
export function GroupAvatar({
color,
backgroundColor,
size = AvatarSize.Medium,
className,
}: Props) {
const theme = useTheme();
return (
<Squircle color={color ?? theme.text} size={size} className={className}>
<GroupIcon
color={backgroundColor ?? theme.background}
size={size * 0.75}
/>
</Squircle>
);
}
+7 -14
View File
@@ -1,33 +1,26 @@
import { getLuminance } from "polished";
import styled from "styled-components"; import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
const Initials = styled(Flex)<{ const Initials = styled(Flex)<{
/** The color of the background, defaults to textTertiary. */
color?: string; color?: string;
/** Content is only used to calculate font size, use children to render. */
content?: string;
/** The size of the avatar */
size: number; size: number;
$showBorder?: boolean;
}>` }>`
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 50%; border-radius: 50%;
width: 100%; width: 100%;
height: 100%; height: 100%;
color: ${(props) => color: #fff;
getLuminance(props.color ?? props.theme.textTertiary) > 0.5 background-color: ${(props) => props.color};
? s("black50")
: s("white75")};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
width: ${(props) => props.size}px; width: ${(props) => props.size}px;
height: ${(props) => props.size}px; height: ${(props) => props.size}px;
border-radius: 50%; border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0; flex-shrink: 0;
font-size: ${(props) => props.size / 2}px;
// adjust font size down for each additional character
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
font-weight: 500; font-weight: 500;
`; `;
+3 -4
View File
@@ -1,7 +1,6 @@
import Avatar, { IAvatar, AvatarSize } from "./Avatar"; import Avatar from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence"; import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence }; export { AvatarWithPresence };
export type { IAvatar }; export default Avatar;
+5 -6
View File
@@ -34,17 +34,16 @@ const Link = styled.a`
fill: ${s("text")}; fill: ${s("text")};
} }
&:hover {
background: ${s("sidebarBackground")};
}
${breakpoint("tablet")` ${breakpoint("tablet")`
z-index: ${depths.sidebar + 1}; z-index: ${depths.sidebar + 1};
background: ${s("sidebarBackground")};
position: fixed; position: fixed;
bottom: 0; bottom: 0;
right: 0; left: 0;
padding: 16px; padding: 16px;
&:hover {
background: ${s("sidebarControlHoverBackground")};
}
`}; `};
`; `;
+11 -16
View File
@@ -5,19 +5,20 @@ import styled from "styled-components";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu"; import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { MenuInternalLink } from "~/types"; import { MenuInternalLink } from "~/types";
type Props = React.PropsWithChildren<{ type Props = {
items: MenuInternalLink[]; items: MenuInternalLink[];
max?: number; max?: number;
highlightFirstItem?: boolean; highlightFirstItem?: boolean;
}>; };
function Breadcrumb( function Breadcrumb({
{ items, highlightFirstItem, children, max = 2 }: Props, items,
ref: React.RefObject<HTMLDivElement> | null highlightFirstItem,
) { children,
max = 2,
}: React.PropsWithChildren<Props>) {
const totalItems = items.length; const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items]; const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems; let overflowItems;
@@ -35,13 +36,9 @@ function Breadcrumb(
} }
return ( return (
<Flex justify="flex-start" align="center" ref={ref}> <Flex justify="flex-start" align="center">
{topLevelItems.map((item, index) => ( {topLevelItems.map((item, index) => (
<React.Fragment <React.Fragment key={String(item.to) || index}>
key={
(typeof item.to === "string" ? item.to : item.to.pathname) || index
}
>
{item.icon} {item.icon}
{item.to ? ( {item.to ? (
<Item <Item
@@ -69,8 +66,6 @@ const Slash = styled(GoToIcon)`
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>` const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
${ellipsis()} ${ellipsis()}
${undraggableOnDesktop()}
display: flex; display: flex;
flex-shrink: 1; flex-shrink: 1;
min-width: 0; min-width: 0;
@@ -90,4 +85,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
} }
`; `;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb); export default Breadcrumb;
+7 -15
View File
@@ -1,5 +1,5 @@
import { LocationDescriptor } from "history"; import { LocationDescriptor } from "history";
import { DisclosureIcon } from "outline-icons"; import { ExpandedIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished"; import { darken, lighten, transparentize } from "polished";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
@@ -25,7 +25,7 @@ const RealButton = styled(ActionButton)<RealProps>`
background: ${s("accent")}; background: ${s("accent")};
color: ${s("accentText")}; color: ${s("accentText")};
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px; box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
border-radius: 6px; border-radius: 4px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
height: 32px; height: 32px;
@@ -49,8 +49,8 @@ const RealButton = styled(ActionButton)<RealProps>`
&:disabled { &:disabled {
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
color: ${(props) => transparentize(0.3, props.theme.accentText)}; color: ${(props) => transparentize(0.5, props.theme.accentText)};
background: ${(props) => transparentize(0.1, props.theme.accent)}; background: ${(props) => lighten(0.2, props.theme.accent)};
svg { svg {
fill: ${(props) => props.theme.white50}; fill: ${(props) => props.theme.white50};
@@ -80,10 +80,6 @@ const RealButton = styled(ActionButton)<RealProps>`
} 0 0 0 1px inset; } 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 { &:disabled {
color: ${props.theme.textTertiary}; color: ${props.theme.textTertiary};
background: none; background: none;
@@ -109,7 +105,7 @@ const RealButton = styled(ActionButton)<RealProps>`
background: ${lighten(0.05, props.theme.danger)}; background: ${lighten(0.05, props.theme.danger)};
} }
&:focus-visible { &.focus-visible {
outline-color: ${darken(0.2, props.theme.danger)} !important; outline-color: ${darken(0.2, props.theme.danger)} !important;
} }
`}; `};
@@ -175,7 +171,7 @@ const Button = <T extends React.ElementType = "button">(
danger, danger,
...rest ...rest
} = props; } = props;
const hasText = !!children || value !== undefined; const hasText = children !== undefined || value !== undefined;
const ic = hideIcon ? undefined : action?.icon ?? icon; const ic = hideIcon ? undefined : action?.icon ?? icon;
const hasIcon = ic !== undefined; const hasIcon = ic !== undefined;
@@ -193,14 +189,10 @@ const Button = <T extends React.ElementType = "button">(
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}> <Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && ic} {hasIcon && ic}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>} {hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <StyledDisclosureIcon />} {disclosure && <ExpandedIcon />}
</Inner> </Inner>
</RealButton> </RealButton>
); );
}; };
const StyledDisclosureIcon = styled(DisclosureIcon)`
opacity: 0.8;
`;
export default React.forwardRef(Button); export default React.forwardRef(Button);
+5 -12
View File
@@ -4,7 +4,6 @@ import breakpoint from "styled-components-breakpoint";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
maxWidth?: string;
withStickyHeader?: boolean; withStickyHeader?: boolean;
}; };
@@ -19,24 +18,18 @@ const Container = styled.div<Props>`
`}; `};
`; `;
type ContentProps = { $maxWidth?: string }; const Content = styled.div`
max-width: 46em;
const Content = styled.div<ContentProps>`
max-width: ${(props) => props.$maxWidth ?? "46em"};
margin: 0 auto; margin: 0 auto;
${breakpoint("desktopLarge")` ${breakpoint("desktopLarge")`
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"}; max-width: 52em;
`}; `};
`; `;
const CenteredContent: React.FC<Props> = ({ const CenteredContent: React.FC<Props> = ({ children, ...rest }: Props) => (
children,
maxWidth,
...rest
}: Props) => (
<Container {...rest}> <Container {...rest}>
<Content $maxWidth={maxWidth}>{children}</Content> <Content>{children}</Content>
</Container> </Container>
); );
-17
View File
@@ -1,17 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { changeLanguage } from "~/utils/language";
type Props = {
locale: string;
};
export default function ChangeLanguage({ locale }: Props) {
const { i18n } = useTranslation();
React.useEffect(() => {
void changeLanguage(locale, i18n);
}, [locale, i18n]);
return null;
}
+42 -67
View File
@@ -1,13 +1,10 @@
import filter from "lodash/filter"; import { sortBy, filter, uniq, isEqual } from "lodash";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover"; import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document"; import Document from "~/models/Document";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar"; import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence";
import DocumentViews from "~/components/DocumentViews"; import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile"; import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
@@ -16,18 +13,10 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
type Props = { type Props = {
/** The document to display live collaborators for */
document: Document; document: Document;
/** The maximum number of collaborators to display, defaults to 6 */
limit?: number;
}; };
/**
* Displays a list of live collaborators for a document, including their avatars
* and presence status.
*/
function Collaborators(props: Props) { function Collaborators(props: Props) {
const { limit = 6 } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const user = useCurrentUser(); const user = useCurrentUser();
const currentUserId = user?.id; const currentUserId = user?.id;
@@ -47,18 +36,17 @@ function Collaborators(props: Props) {
// ensure currently present via websocket are always ordered first // ensure currently present via websocket are always ordered first
const collaborators = React.useMemo( const collaborators = React.useMemo(
() => () =>
orderBy( sortBy(
filter( filter(
users.all, users.orderedData,
(u) => (user) =>
(presentIds.includes(u.id) || (presentIds.includes(user.id) ||
document.collaboratorIds.includes(u.id)) && document.collaboratorIds.includes(user.id)) &&
!u.isSuspended !user.isSuspended
), ),
[(u) => presentIds.includes(u.id), "id"], (user) => presentIds.includes(user.id)
["asc", "asc"]
), ),
[document.collaboratorIds, users.all, presentIds] [document.collaboratorIds, users.orderedData, presentIds]
); );
// load any users we don't yet have in memory // load any users we don't yet have in memory
@@ -78,56 +66,43 @@ function Collaborators(props: Props) {
placement: "bottom-end", 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 ( return (
<> <>
<PopoverDisclosure {...popover}> <PopoverDisclosure {...popover}>
{(popoverProps) => ( {(props) => (
<NudeButton <NudeButton width={collaborators.length * 32} height={32} {...props}>
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
{...popoverProps}
>
<Facepile <Facepile
size={AvatarSize.Large}
limit={limit}
overflow={Math.max(0, collaborators.length - limit)}
users={collaborators} 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> </NudeButton>
)} )}
@@ -1,32 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { CollectionForm, FormData } from "./CollectionForm";
type Props = {
collectionId: string;
onSubmit: () => void;
};
export const CollectionEdit = observer(function CollectionEdit_({
collectionId,
onSubmit,
}: Props) {
const { collections } = useStores();
const collection = collections.get(collectionId);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await collection?.save(data);
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[collection, onSubmit]
);
return <CollectionForm collection={collection} handleSubmit={handleSubmit} />;
});
@@ -1,190 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { EmptySelectValue } from "~/types";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
icon: string;
color: string | null;
sharing: boolean;
permission: CollectionPermission | undefined;
}
export const CollectionForm = observer(function CollectionForm_({
handleSubmit,
collection,
}: {
handleSubmit: (data: FormData) => void;
collection?: Collection;
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
register,
handleSubmit: formHandleSubmit,
formState,
watch,
control,
setValue,
setFocus,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: collection?.name ?? "",
icon: collection?.icon,
sharing: collection?.sharing ?? true,
permission: collection?.permission,
color: iconColor,
},
});
const values = watch();
React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
if (!hasOpenedIconPicker && !collection) {
setValue(
"icon",
IconLibrary.findIconByKeyword(values.name) ??
values.icon ??
"collection"
);
}
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconChange = React.useCallback(
(icon: string, color: string | null) => {
if (icon !== values.icon) {
setFocus("name");
}
setValue("icon", icon);
setValue("color", color);
},
[setFocus, setValue, values.icon]
);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
<Trans>
Collections are used to group documents and choose permissions
</Trans>
.
</Text>
<Flex gap={8}>
<Input
type="text"
placeholder={t("Name")}
{...register("name", {
required: true,
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<React.Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={values.name[0]}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</React.Suspense>
}
autoComplete="off"
autoFocus
flex
/>
</Flex>
{/* Following controls are available in create flow, but moved elsewhere for edit */}
{!collection && (
<Controller
control={control}
name="permission"
render={({ field }) => (
<InputSelectPermission
ref={field.ref}
value={field.value}
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
field.onChange(value === EmptySelectValue ? null : value);
}}
note={t(
"The default access for workspace members, you can share with more users or groups later."
)}
/>
)}
/>
)}
{team.sharing && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{collection
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
const StyledIconPicker = styled(IconPicker)`
margin-left: 4px;
margin-right: 4px;
`;
@@ -1,35 +0,0 @@
import { runInAction } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { CollectionForm, FormData } from "./CollectionForm";
type Props = {
onSubmit: () => void;
};
export const CollectionNew = observer(function CollectionNew_({
onSubmit,
}: Props) {
const { collections } = useStores();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const collection = await collections.save(data);
// Avoid flash of loading state for the new collection, we know it's empty.
runInAction(() => {
collection.documents = [];
});
onSubmit?.();
history.push(collection.path);
} catch (error) {
toast.error(error.message);
}
},
[collections, onSubmit]
);
return <CollectionForm handleSubmit={handleSubmit} />;
});
-45
View File
@@ -1,45 +0,0 @@
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { MenuInternalLink } from "~/types";
import { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
type Props = {
collection: Collection;
};
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const { t } = useTranslation();
const items = React.useMemo(() => {
const collectionNode: MenuInternalLink = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
};
const category: MenuInternalLink | undefined = collection.isArchived
? {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
}
: undefined;
const output = [];
if (category) {
output.push(category);
}
output.push(collectionNode);
return output;
}, [collection, t]);
return <Breadcrumb items={items} highlightFirstItem />;
};
+4 -8
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation, Trans } from "react-i18next"; import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog"; import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text"; import Text from "~/components/Text";
@@ -23,14 +22,11 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
const handleSubmit = async () => { const handleSubmit = async () => {
const redirect = collection.id === ui.activeCollectionId; const redirect = collection.id === ui.activeCollectionId;
await collection.delete();
onSubmit();
if (redirect) { if (redirect) {
history.push(homePath()); history.push(homePath());
} }
await collection.delete();
onSubmit();
toast.success(t("Collection deleted"));
}; };
return ( return (
@@ -41,7 +37,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
danger danger
> >
<> <>
<Text as="p" type="secondary"> <Text type="secondary">
<Trans <Trans
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash." defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash."
values={{ values={{
@@ -53,7 +49,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
/> />
</Text> </Text>
{team.defaultCollectionId === collection.id ? ( {team.defaultCollectionId === collection.id ? (
<Text as="p" type="secondary"> <Text type="secondary">
<Trans <Trans
defaults="Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page." defaults="Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page."
values={{ values={{
+185 -41
View File
@@ -1,22 +1,19 @@
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components"; import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator"; import LoadingIndicator from "~/components/LoadingIndicator";
import { withUIExtensions } from "~/editor/extensions"; import NudeButton from "~/components/NudeButton";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import Text from "./Text"; import useToasts from "~/hooks/useToasts";
const extensions = withUIExtensions(richExtensions);
type Props = { type Props = {
collection: Collection; collection: Collection;
@@ -24,64 +21,211 @@ type Props = {
function CollectionDescription({ collection }: Props) { function CollectionDescription({ collection }: Props) {
const { collections } = useStores(); const { collections } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation(); 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 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( const handleSave = React.useMemo(
() => () =>
debounce(async (getValue) => { debounce(async (getValue) => {
try { try {
await collection.save({ await collection.save({
data: getValue(false), description: getValue(),
}); });
setDirty(false);
} catch (err) { } catch (err) {
toast.error(t("Sorry, an error occurred saving the collection")); showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err; throw err;
} }
}, 1000), }, 1000),
[collection, t] [collection, showToast, t]
); );
const childRef = React.useRef<HTMLDivElement>(null); const handleChange = React.useCallback(
const childOffsetHeight = childRef.current?.offsetHeight || 0; async (getValue) => {
const editorStyle = React.useMemo( setDirty(true);
() => ({ await handleSave(getValue);
padding: "0 32px", },
margin: "0 -32px", [handleSave]
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
); );
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return ( return (
<> <MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
{collections.isSaving && <LoadingIndicator />} <Input data-editing={isEditing} data-expanded={isExpanded}>
{(collection.hasDescription || can.update) && ( <span onClick={can.update ? handleStartEditing : undefined}>
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}> {collections.isSaving && <LoadingIndicator />}
<Editor {collection.hasDescription || isEditing || isDirty ? (
defaultValue={collection.data} <React.Suspense
onChange={handleSave} fallback={
placeholder={`${t("Add a description")}`} <Placeholder
extensions={extensions} onClick={() => {
maxLength={CollectionValidation.maxDescriptionLength} //
canUpdate={can.update} }}
readOnly={!can.update} >
userId={user.id} Loading
editorStyle={editorStyle} </Placeholder>
embedsDisabled }
/> >
<div ref={childRef} /> <Editor
</React.Suspense> key={key}
defaultValue={collection.description || ""}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
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")}; color: ${s("placeholder")};
cursor: text; cursor: text;
min-height: 27px; min-height: 27px;
`; `;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: -12px -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;
transition: ${s("backgroundTransition")};
&: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("secondaryBackground")};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription); export default observer(CollectionDescription);
@@ -6,31 +6,31 @@ import { Portal } from "react-portal";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles"; import { depths, s } from "@shared/styles";
import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions"; import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root"; import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions"; import useCommandBarActions from "~/hooks/useCommandBarActions";
import CommandBarResults from "./CommandBarResults"; import useSettingsActions from "~/hooks/useSettingsActions";
import useRecentDocumentActions from "./useRecentDocumentActions"; import { CommandBarAction } from "~/types";
import useSettingsAction from "./useSettingsAction";
import useTemplatesAction from "./useTemplatesAction";
function CommandBar() { function CommandBar() {
const { t } = useTranslation(); const { t } = useTranslation();
const recentDocumentActions = useRecentDocumentActions(); const settingsActions = useSettingsActions();
const settingsAction = useSettingsAction();
const templatesAction = useTemplatesAction();
const commandBarActions = React.useMemo( const commandBarActions = React.useMemo(
() => [ () => [...rootActions, settingsActions],
...recentDocumentActions, [settingsActions]
...rootActions,
templatesAction,
settingsAction,
],
[recentDocumentActions, settingsAction, templatesAction]
); );
useCommandBarActions(commandBarActions); useCommandBarActions(commandBarActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.currentRootActionId
? (state.actions[
state.currentRootActionId
] as unknown as CommandBarAction)
: undefined,
}));
return ( return (
<> <>
<KBarPortal> <KBarPortal>
@@ -38,7 +38,11 @@ function CommandBar() {
<Animator> <Animator>
<SearchActions /> <SearchActions />
<SearchInput <SearchInput
defaultPlaceholder={`${t("Type a command or search")}`} placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/> />
<CommandBarResults /> <CommandBarResults />
</Animator> </Animator>
@@ -69,23 +73,16 @@ const Positioner = styled(KBarPositioner)`
`; `;
const SearchInput = styled(KBarSearch)` const SearchInput = styled(KBarSearch)`
position: relative; padding: 16px 20px;
padding: 16px 12px; width: 100%;
margin: 0 8px;
width: calc(100% - 16px);
outline: none; outline: none;
border: none; border: none;
background: ${s("menuBackground")}; background: ${s("menuBackground")};
color: ${s("text")}; color: ${s("text")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
&:disabled, &:disabled,
&::placeholder { &::placeholder {
color: ${s("placeholder")}; color: ${s("placeholder")};
opacity: 1;
} }
`; `;
-3
View File
@@ -1,3 +0,0 @@
import CommandBar from "./CommandBar";
export default CommandBar;
@@ -1,35 +0,0 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
const { documents, ui } = useStores();
return React.useMemo(
() =>
documents.recentlyViewed
.filter((document) => document.id !== ui.activeDocumentId)
.slice(0, count)
.map((item) =>
createAction({
name: item.titleWithDefault,
analyticsName: "Recently viewed document",
section: RecentSection,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
perform: () => history.push(documentPath(item)),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed]
);
};
export default useRecentDocumentActions;
@@ -1,89 +0,0 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
DocumentSection,
TeamSection,
} from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { documents } = useStores();
React.useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
const actions = React.useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
createAction({
name: template.titleWithDefault,
analyticsName: "New document",
section: template.isWorkspaceTemplate
? TeamSection
: ActiveCollectionSection,
icon: template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<NewDocumentIcon />
),
keywords: "create",
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (activeCollectionId) {
return (
stores.policies.abilities(activeCollectionId).createDocument &&
(template.collectionId === activeCollectionId ||
template.isWorkspaceTemplate)
);
}
return (
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument &&
template.isWorkspaceTemplate
);
},
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(
newDocumentPath(template.collectionId ?? activeCollectionId, {
templateId: template.id,
}),
{
sidebarContext,
}
),
})
),
[documents.templatesAlphabetical]
);
const newFromTemplate = React.useMemo(
() =>
createAction({
id: "templates",
name: ({ t }) => t("New from template"),
placeholder: ({ t }) => t("Choose a template"),
section: DocumentSection,
icon: <ShapesIcon />,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return (
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
children: () => actions,
}),
[actions]
);
return newFromTemplate;
};
export default useTemplatesAction;
@@ -3,10 +3,9 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import styled, { css, useTheme } from "styled-components"; import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Key from "~/components/Key"; import Key from "~/components/Key";
import Text from "~/components/Text"; import Text from "./Text";
type Props = { type Props = {
action: ActionImpl; action: ActionImpl;
@@ -63,15 +62,15 @@ function CommandBarItem(
{index > 0 ? ( {index > 0 ? (
<> <>
{" "} {" "}
<Text size="xsmall" type="secondary"> <Text size="xsmall" as="span" type="secondary">
then then
</Text>{" "} </Text>{" "}
</> </>
) : ( ) : (
"" ""
)} )}
{sc.split("+").map((key) => ( {sc.split("+").map((s) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key> <Key key={s}>{s}</Key>
))} ))}
</React.Fragment> </React.Fragment>
))} ))}
@@ -1,16 +1,12 @@
import { useMatches, KBarResults } from "kbar"; import { useMatches, KBarResults } from "kbar";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import Text from "~/components/Text"; import { s } from "@shared/styles";
import CommandBarItem from "./CommandBarItem"; import CommandBarItem from "~/components/CommandBarItem";
export default function CommandBarResults() { export default function CommandBarResults() {
const { results, rootActionId } = useMatches(); const { results, rootActionId } = useMatches();
if (results.length === 0) {
return null;
}
return ( return (
<Container> <Container>
<KBarResults <KBarResults
@@ -18,9 +14,7 @@ export default function CommandBarResults() {
maxHeight={400} maxHeight={400}
onRender={({ item, active }) => onRender={({ item, active }) =>
typeof item === "string" ? ( typeof item === "string" ? (
<Header type="tertiary" size="xsmall" ellipsis> <Header>{item}</Header>
{item}
</Header>
) : ( ) : (
<CommandBarItem <CommandBarItem
action={item} action={item}
@@ -41,10 +35,11 @@ const Container = styled.div`
} }
`; `;
const Header = styled(Text).attrs({ as: "h3" })` const Header = styled.h3`
letter-spacing: 0.03em; font-size: 13px;
letter-spacing: 0.04em;
margin: 0; margin: 0;
padding: 16px 0 4px 20px; padding: 16px 0 4px 20px;
color: ${s("textTertiary")};
height: 36px; height: 36px;
cursor: default;
`; `;
+4 -3
View File
@@ -1,11 +1,11 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation, Trans } from "react-i18next"; import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Comment from "~/models/Comment"; import Comment from "~/models/Comment";
import ConfirmationDialog from "~/components/ConfirmationDialog"; import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = { type Props = {
comment: Comment; comment: Comment;
@@ -14,6 +14,7 @@ type Props = {
function CommentDeleteDialog({ comment, onSubmit }: Props) { function CommentDeleteDialog({ comment, onSubmit }: Props) {
const { comments } = useStores(); const { comments } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation(); const { t } = useTranslation();
const hasChildComments = comments.inThread(comment.id).length > 1; const hasChildComments = comments.inThread(comment.id).length > 1;
@@ -22,7 +23,7 @@ function CommentDeleteDialog({ comment, onSubmit }: Props) {
await comment.delete(); await comment.delete();
onSubmit?.(); onSubmit?.();
} catch (err) { } catch (err) {
toast.error(err.message); showToast(err.message, { type: "error" });
} }
}; };
@@ -33,7 +34,7 @@ function CommentDeleteDialog({ comment, onSubmit }: Props) {
savingText={`${t("Deleting")}`} savingText={`${t("Deleting")}`}
danger danger
> >
<Text as="p" type="secondary"> <Text type="secondary">
{hasChildComments ? ( {hasChildComments ? (
<Trans> <Trans>
Are you sure you want to permanently delete this entire comment Are you sure you want to permanently delete this entire comment
-83
View File
@@ -1,83 +0,0 @@
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. */
item: NavigationNode;
/** The collection to move the document to. */
collection: Collection;
/** The parent document to move the document under. */
parentDocumentId?: string | null;
/** The index to move the document to. */
index?: number | null;
};
function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
const { documents, dialogs, collections } = useStores();
const { t } = useTranslation();
const prevCollection = collections.get(item.collectionId!);
const accessMapping: Record<Partial<CollectionPermission> | "null", string> =
{
[CollectionPermission.Admin]: t("manage access"),
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
null: t("no access"),
};
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();
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Move document")}
savingText={`${t("Moving")}`}
>
<Trans
defaults="Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>."
values={{
title: item.title,
prevCollectionName: prevCollection?.name,
newCollectionName: collection.name,
prevPermission: accessMapping[prevCollection?.permission || "null"],
newPermission: accessMapping[collection.permission || "null"],
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(ConfirmMoveDialog);
+21 -26
View File
@@ -1,15 +1,14 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = { type Props = {
/** Callback when the dialog is submitted. Return false to prevent closing. */ /** Callback when the dialog is submitted */
onSubmit: () => Promise<void | boolean> | void; onSubmit: () => Promise<void> | void;
/** Text to display on the submit button */ /** Text to display on the submit button */
submitText?: string; submitText?: string;
/** Text to display while the form is saving */ /** Text to display while the form is saving */
@@ -30,45 +29,41 @@ const ConfirmationDialog: React.FC<Props> = ({
disabled = false, disabled = false,
}: Props) => { }: Props) => {
const [isSaving, setIsSaving] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false);
const { t } = useTranslation();
const { dialogs } = useStores(); const { dialogs } = useStores();
const { showToast } = useToasts();
const handleSubmit = React.useCallback( const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => { async (ev: React.SyntheticEvent) => {
ev.preventDefault(); ev.preventDefault();
setIsSaving(true); setIsSaving(true);
try { try {
const res = await onSubmit(); await onSubmit();
if (res === false) {
return;
}
dialogs.closeAllModals(); dialogs.closeAllModals();
} catch (err) { } catch (err) {
toast.error(err.message); showToast(err.message, {
type: "error",
});
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, },
[onSubmit, dialogs] [onSubmit, dialogs, showToast]
); );
return ( return (
<form onSubmit={handleSubmit}> <Flex column>
<Flex gap={12} column> <form onSubmit={handleSubmit}>
<Text type="secondary">{children}</Text> <Text type="secondary">{children}</Text>
<Button
<Flex justify="flex-end"> type="submit"
<Button disabled={isSaving || disabled}
type="submit" danger={danger}
disabled={isSaving || disabled} autoFocus
danger={danger} >
autoFocus {isSaving && savingText ? savingText : submitText}
> </Button>
{isSaving && savingText ? savingText : submitText ?? t("Confirm")} </form>
</Button> </Flex>
</Flex>
</Flex>
</form>
); );
}; };
+10 -48
View File
@@ -2,14 +2,8 @@ import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons"; import { DisconnectedIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
@@ -17,56 +11,24 @@ import useStores from "~/hooks/useStores";
function ConnectionStatus() { function ConnectionStatus() {
const { ui } = useStores(); const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const codeToMessage = {
[DocumentTooLarge.code]: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
[AuthenticationFailed.code]: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
[AuthorizationFailed.code]: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
[TooManyConnections.code]: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
};
const message = ui.multiplayerErrorCode
? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage]
: undefined;
return ui.multiplayerStatus === "connecting" || return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? ( ui.multiplayerStatus === "disconnected" ? (
<Tooltip <Tooltip
content={ tooltip={
message ? ( <Centered>
<Centered> <strong>{t("Server connection lost")}</strong>
<strong>{message.title}</strong> <br />
<br /> {t("Edits you make will sync once youre online")}
{message.body} </Centered>
</Centered>
) : (
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
)
} }
placement="bottom" placement="bottom"
> >
<Button> <Button>
<Fade> <Fade>
<DisconnectedIcon /> <DisconnectedIcon color={theme.sidebarText} />
</Fade> </Fade>
</Button> </Button>
</Tooltip> </Tooltip>
@@ -77,7 +39,7 @@ const Button = styled(NudeButton)`
display: none; display: none;
position: fixed; position: fixed;
bottom: 0; bottom: 0;
margin: 20px; margin: 24px;
transform: translateX(-32px); transform: translateX(-32px);
${breakpoint("tablet")` ${breakpoint("tablet")`
+3 -5
View File
@@ -9,7 +9,6 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
readOnly?: boolean; readOnly?: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>; onClick?: React.MouseEventHandler<HTMLDivElement>;
onChange?: (text: string) => void; onChange?: (text: string) => void;
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined; onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined; onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined; onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
@@ -36,7 +35,6 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
disabled, disabled,
onChange, onChange,
onInput, onInput,
onFocus,
onBlur, onBlur,
onKeyDown, onKeyDown,
value, value,
@@ -145,13 +143,11 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
); );
return ( return (
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}> <div className={className} dir={dir} onClick={onClick}>
{children}
<Content <Content
ref={contentRef} ref={contentRef}
contentEditable={!disabled && !readOnly} contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)} onInput={wrappedEvent(onInput)}
onFocus={wrappedEvent(onFocus)}
onBlur={wrappedEvent(onBlur)} onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)} onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste} onPaste={handlePaste}
@@ -162,6 +158,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
> >
{innerValue} {innerValue}
</Content> </Content>
{children}
</div> </div>
); );
}); });
@@ -182,6 +179,7 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
const Content = styled.span` const Content = styled.span`
background: ${s("background")}; background: ${s("background")};
transition: ${s("backgroundTransition")};
color: ${s("text")}; color: ${s("text")};
-webkit-text-fill-color: ${s("text")}; -webkit-text-fill-color: ${s("text")};
outline: none; outline: none;
+23 -50
View File
@@ -1,19 +1,15 @@
import { LocationDescriptor } from "history"; import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons"; import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import * as React from "react"; import * as React from "react";
import { mergeRefs } from "react-merge-refs"; import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu"; import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles"; import MenuIconWrapper from "../MenuIconWrapper";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = { type Props = {
id?: string; id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>; onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean; active?: boolean;
selected?: boolean; selected?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -24,7 +20,7 @@ type Props = {
as?: string | React.ComponentType<any>; as?: string | React.ComponentType<any>;
hide?: () => void; hide?: () => void;
level?: number; level?: number;
icon?: React.ReactNode; icon?: React.ReactElement;
children?: React.ReactNode; children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined; ref?: React.LegacyRef<HTMLButtonElement> | undefined;
}; };
@@ -32,7 +28,6 @@ type Props = {
const MenuItem = ( const MenuItem = (
{ {
onClick, onClick,
onPointerMove,
children, children,
active, active,
selected, selected,
@@ -46,43 +41,43 @@ const MenuItem = (
) => { ) => {
const content = React.useCallback( const content = React.useCallback(
(props) => { (props) => {
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const handleClick = async (ev: React.MouseEvent) => { const handleClick = async (ev: React.MouseEvent) => {
hide?.(); hide?.();
if (onClick) { if (onClick) {
preventDefault(ev); ev.preventDefault();
await onClick(ev); await onClick(ev);
} }
}; };
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const handleMouseDown = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
return ( return (
<MenuAnchor <MenuAnchor
{...props} {...props}
$active={active} $active={active}
as={onClick ? "button" : as} as={onClick ? "button" : as}
onClick={handleClick} onClick={handleClick}
onPointerDown={preventDefault} onMouseDown={handleMouseDown}
onMouseDown={preventDefault}
ref={mergeRefs([ ref={mergeRefs([
ref, ref,
props.ref as React.RefObject<HTMLAnchorElement>, props.ref as React.RefObject<HTMLAnchorElement>,
])} ])}
> >
{selected !== undefined && ( {selected !== undefined && (
<SelectedWrapper aria-hidden> <>
{selected ? <CheckmarkIcon /> : <Spacer />} {selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper> &nbsp;
</>
)} )}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>} {icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
<Title>{children}</Title> {children}
</MenuAnchor> </MenuAnchor>
); );
}, },
@@ -92,7 +87,6 @@ const MenuItem = (
return ( return (
<BaseMenuItem <BaseMenuItem
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled} disabled={disabled}
hide={hide} hide={hide}
{...rest} {...rest}
@@ -108,14 +102,6 @@ const Spacer = styled.svg`
flex-shrink: 0; flex-shrink: 0;
`; `;
const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = { type MenuAnchorProps = {
level?: number; level?: number;
disabled?: boolean; disabled?: boolean;
@@ -144,6 +130,10 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
white-space: nowrap; white-space: nowrap;
position: relative; position: relative;
svg:not(:last-child) {
margin-right: 4px;
}
svg { svg {
flex-shrink: 0; flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)}; opacity: ${(props) => (props.disabled ? ".5" : 1)};
@@ -158,23 +148,15 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
@media (hover: hover) { @media (hover: hover) {
&:hover, &:hover,
&:focus, &:focus,
&:focus-visible { &.focus-visible {
color: ${props.theme.accentText}; color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent}; background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none; box-shadow: none;
cursor: var(--pointer); cursor: var(--pointer);
svg { svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText}; fill: ${props.theme.accentText};
} }
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
} }
} }
`} `}
@@ -205,13 +187,4 @@ export const MenuAnchor = styled.a`
${MenuAnchorCSS} ${MenuAnchorCSS}
`; `;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem); export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
+6 -6
View File
@@ -2,17 +2,17 @@ import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition"; import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = { type Positions = {
/** Sub-menu x */ /* Sub-menu x */
x: number; x: number;
/** Sub-menu y */ /* Sub-menu y */
y: number; y: number;
/** Sub-menu height */ /* Sub-menu height */
h: number; h: number;
/** Sub-menu width */ /* Sub-menu width */
w: number; w: number;
/** Mouse x */ /* Mouse x */
mouseX: number; mouseX: number;
/** Mouse y */ /* Mouse y */
mouseY: number; mouseY: number;
}; };
+11 -27
View File
@@ -9,8 +9,8 @@ import {
MenuStateReturn, MenuStateReturn,
} from "reakit/Menu"; } from "reakit/Menu";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import MenuIconWrapper from "~/components/MenuIconWrapper";
import { actionToMenuItem } from "~/actions"; import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext"; import useActionContext from "~/hooks/useActionContext";
import { import {
@@ -20,7 +20,6 @@ import {
MenuHeading, MenuHeading,
MenuItem as TMenuItem, MenuItem as TMenuItem,
} from "~/types"; } from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header"; import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem"; import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea"; import MouseSafeArea from "./MouseSafeArea";
@@ -31,7 +30,6 @@ type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[]; actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>; context?: Partial<ActionContext>;
items?: TMenuItem[]; items?: TMenuItem[];
showIcons?: boolean;
}; };
const Disclosure = styled(ExpandedIcon)` const Disclosure = styled(ExpandedIcon)`
@@ -100,7 +98,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
}); });
} }
function Template({ items, actions, context, showIcons, ...menu }: Props) { function Template({ items, actions, context, ...menu }: Props) {
const ctx = useActionContext({ const ctx = useActionContext({
isContextMenu: true, isContextMenu: true,
}); });
@@ -126,10 +124,9 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
if ( if (
iconIsPresentInAnyMenuItem && iconIsPresentInAnyMenuItem &&
item.type !== "separator" && item.type !== "separator" &&
item.type !== "heading" && item.type !== "heading"
showIcons !== false
) { ) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />; item.icon = item.icon || <MenuIconWrapper />;
} }
if (item.type === "route") { if (item.type === "route") {
@@ -141,7 +138,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
key={index} key={index}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected} selected={item.selected}
icon={showIcons !== false ? item.icon : undefined} icon={item.icon}
{...menu} {...menu}
> >
{item.title} {item.title}
@@ -159,7 +156,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
selected={item.selected} selected={item.selected}
level={item.level} level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"} target={item.href.startsWith("#") ? undefined : "_blank"}
icon={showIcons !== false ? item.icon : undefined} icon={item.icon}
{...menu} {...menu}
> >
{item.title} {item.title}
@@ -168,7 +165,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
} }
if (item.type === "button") { if (item.type === "button") {
const menuItem = ( return (
<MenuItem <MenuItem
as="button" as="button"
id={`${item.title}-${index}`} id={`${item.title}-${index}`}
@@ -177,20 +174,12 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
selected={item.selected} selected={item.selected}
dangerous={item.dangerous} dangerous={item.dangerous}
key={index} key={index}
icon={showIcons !== false ? item.icon : undefined} icon={item.icon}
{...menu} {...menu}
> >
{item.title} {item.title}
</MenuItem> </MenuItem>
); );
return item.tooltip ? (
<Tooltip content={item.tooltip} placement={"bottom"}>
<div>{menuItem}</div>
</Tooltip>
) : (
<>{menuItem}</>
);
} }
if (item.type === "submenu") { if (item.type === "submenu") {
@@ -201,12 +190,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
id={`${item.title}-${index}`} id={`${item.title}-${index}`}
templateItems={item.items} templateItems={item.items}
parentMenuState={menu} parentMenuState={menu}
title={ title={<Title title={item.title} icon={item.icon} />}
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu} {...menu}
/> />
); );
@@ -217,7 +201,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
} }
if (item.type === "heading") { if (item.type === "heading") {
return <Header key={index}>{item.title}</Header>; return <Header>{item.title}</Header>;
} }
const _exhaustiveCheck: never = item; const _exhaustiveCheck: never = item;
@@ -236,7 +220,7 @@ function Title({
}) { }) {
return ( return (
<Flex align="center"> <Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>} {icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title} {title}
</Flex> </Flex>
); );
+64 -144
View File
@@ -6,7 +6,6 @@ import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles"; import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable"; import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext"; import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight"; import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile"; import useMobile from "~/hooks/useMobile";
@@ -39,8 +38,6 @@ export type Placement =
type Props = MenuStateReturn & { type Props = MenuStateReturn & {
"aria-label"?: string; "aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */ /** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">; parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */ /** Called when the context menu is opened. */
@@ -49,15 +46,10 @@ type Props = MenuStateReturn & {
onClose?: () => void; onClose?: () => void;
/** Called when the context menu is clicked. */ /** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void; onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode; children?: React.ReactNode;
}; };
const ContextMenu: React.FC<Props> = ({ const ContextMenu: React.FC<Props> = ({
menuRef,
children, children,
onOpen, onOpen,
onClose, onClose,
@@ -65,6 +57,11 @@ const ContextMenu: React.FC<Props> = ({
...rest ...rest
}: Props) => { }: Props) => {
const previousVisible = usePrevious(rest.visible); const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight({
visible: rest.visible,
elementRef: rest.unstable_disclosureRef,
});
const backgroundRef = React.useRef<HTMLDivElement>(null);
const { ui } = useStores(); const { ui } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext(); const { setIsMenuOpen } = useMenuContext();
@@ -102,6 +99,21 @@ const ContextMenu: React.FC<Props> = ({
t, t,
]); ]);
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (rest.visible && scrollElement && !isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
scrollElement && !isSubMenu && enableBodyScroll(scrollElement);
};
}, [isSubMenu, rest.visible]);
// Perf win don't render anything until the menu has been opened // Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) { if (!rest.visible && !previousVisible) {
return null; return null;
@@ -111,132 +123,52 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window // trigger and the bottom of the window
return ( return (
<> <>
<Menu <Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
ref={menuRef} {(props) => {
hideOnClickOutside={!isMobile} // kind of hacky, but this is an effective way of telling which way
preventBodyScroll={false} // the menu will _actually_ be placed when taking into account screen
{...rest} // positioning.
> const topAnchor = props.style?.top === "0";
{(props) => ( // @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message
<InnerContextMenu const rightAnchor = props.placement === "bottom-end";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any} return (
{...rest} <>
isSubMenu={isSubMenu} {isMobile && (
> <Backdrop
{children} onClick={(ev) => {
</InnerContextMenu> ev.preventDefault();
)} ev.stopPropagation();
rest.hide?.();
}}
/>
)}
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
</>
);
}}
</Menu> </Menu>
</> </>
); );
}; };
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = props;
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor =
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
scrollElement && !props.isSubMenu && enableBodyScroll(scrollElement);
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
maxHeight,
}
: undefined;
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={style}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu; export default ContextMenu;
export const Backdrop = styled.div` export const Backdrop = styled.div`
@@ -254,16 +186,6 @@ export const Position = styled.div`
position: absolute; position: absolute;
z-index: ${depths.menu}; z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
}
/* /*
* overrides make mobile-first coding style challenging * overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here * so we explicitly define mobile breakpoint here
@@ -281,8 +203,6 @@ export const Position = styled.div`
type BackgroundProps = { type BackgroundProps = {
topAnchor?: boolean; topAnchor?: boolean;
rightAnchor?: boolean; rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme; theme: DefaultTheme;
}; };
@@ -294,8 +214,9 @@ export const Background = styled(Scrollable)<BackgroundProps>`
border-radius: 6px; border-radius: 6px;
padding: 6px; padding: 6px;
min-width: 180px; min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px; min-height: 44px;
max-height: 75vh; max-height: 75vh;
pointer-events: all;
font-weight: normal; font-weight: normal;
@media print { @media print {
@@ -307,8 +228,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease; props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) => transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0; props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px; max-width: 276px;
max-height: 100vh;
background: ${(props: BackgroundProps) => props.theme.menuBackground}; background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow}; box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`}; `};
+21 -33
View File
@@ -1,6 +1,5 @@
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import * as React from "react"; import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import env from "~/env"; import env from "~/env";
type Props = { type Props = {
@@ -10,43 +9,32 @@ type Props = {
onCopy?: () => void; onCopy?: () => void;
}; };
function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) { class CopyToClipboard extends React.PureComponent<Props> {
const { text, onCopy, children, ...rest } = props; onClick = (ev: React.SyntheticEvent) => {
const { text, onCopy, children } = this.props;
const elem = React.Children.only(children);
const onClick = React.useCallback( copy(text, {
(ev: React.MouseEvent<HTMLElement>) => { debug: env.ENVIRONMENT !== "production",
const elem = React.Children.only(children); format: "text/plain",
});
copy(text, { onCopy?.();
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
onCopy?.(); if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
}
};
if (elem && elem.props && typeof elem.props.onClick === "function") { render() {
elem.props.onClick(ev); const { text, onCopy, children, ...rest } = this.props;
} else { const elem = React.Children.only(children);
ev.preventDefault(); if (!elem) {
ev.stopPropagation(); return null;
} }
},
[children, onCopy, text]
);
const elem = React.Children.only(children); return React.cloneElement(elem, { ...rest, onClick: this.onClick });
if (!elem) {
return null;
} }
return React.cloneElement(elem, {
...rest,
ref:
"ref" in elem
? mergeRefs([elem.ref as React.MutableRefObject<HTMLElement>, ref])
: ref,
onClick,
});
} }
export default React.forwardRef(CopyToClipboard); export default CopyToClipboard;
+39 -20
View File
@@ -1,12 +1,17 @@
import { HomeIcon } from "outline-icons"; import { HomeIcon } from "outline-icons";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; 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 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"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type DefaultCollectionInputSelectProps = { type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
onSelectCollection: (collection: string) => void; onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null; defaultCollectionId: string | null;
}; };
@@ -14,11 +19,13 @@ type DefaultCollectionInputSelectProps = {
const DefaultCollectionInputSelect = ({ const DefaultCollectionInputSelect = ({
onSelectCollection, onSelectCollection,
defaultCollectionId, defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => { }: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { collections } = useStores(); const { collections } = useStores();
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState(); const [fetchError, setFetchError] = useState();
const { showToast } = useToasts();
React.useEffect(() => { React.useEffect(() => {
async function fetchData() { async function fetchData() {
@@ -29,8 +36,11 @@ const DefaultCollectionInputSelect = ({
limit: 100, limit: 100,
}); });
} catch (error) { } catch (error) {
toast.error( showToast(
t("Collections could not be loaded, please reload the app") t("Collections could not be loaded, please reload the app"),
{
type: "error",
}
); );
setFetchError(error); setFetchError(error);
} finally { } finally {
@@ -39,30 +49,40 @@ const DefaultCollectionInputSelect = ({
} }
} }
void fetchData(); void fetchData();
}, [fetchError, t, fetching, collections]); }, [showToast, fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo( const options = React.useMemo(
() => () =>
collections.nonPrivate.reduce( collections.publicCollections.reduce(
(acc, collection) => [ (acc, collection) => [
...acc, ...acc,
{ {
type: "item", label: (
label: collection.name, <Flex align="center">
<IconWrapper>
<CollectionIcon collection={collection} />
</IconWrapper>
{collection.name}
</Flex>
),
value: collection.id, value: collection.id,
icon: <CollectionIcon collection={collection} />,
}, },
], ],
[ [
{ {
type: "item", label: (
label: t("Home"), <Flex align="center">
<IconWrapper>
<HomeIcon />
</IconWrapper>
{t("Home")}
</Flex>
),
value: "home", value: "home",
icon: <HomeIcon />,
}, },
] satisfies Option[] ]
), ),
[collections.nonPrivate, t] [collections.publicCollections, t]
); );
if (fetching) { if (fetching) {
@@ -70,14 +90,13 @@ const DefaultCollectionInputSelect = ({
} }
return ( return (
<InputSelectNew <InputSelect
options={options}
value={defaultCollectionId ?? "home"} value={defaultCollectionId ?? "home"}
options={options}
onChange={onSelectCollection} onChange={onSelectCollection}
ariaLabel={t("Default collection")} ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short short
{...rest}
/> />
); );
}; };
+7 -12
View File
@@ -1,10 +1,10 @@
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts"; import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar"; import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import Desktop from "~/utils/Desktop"; import Desktop from "~/utils/Desktop";
export default function DesktopEventHandler() { export default function DesktopEventHandler() {
@@ -12,7 +12,7 @@ export default function DesktopEventHandler() {
const { t } = useTranslation(); const { t } = useTranslation();
const history = useHistory(); const history = useHistory();
const { dialogs } = useStores(); const { dialogs } = useStores();
const hasDisabledUpdateMessage = React.useRef(false); const { showToast } = useToasts();
React.useEffect(() => { React.useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => { Desktop.bridge?.redirect((path: string, replace = false) => {
@@ -24,16 +24,11 @@ export default function DesktopEventHandler() {
}); });
Desktop.bridge?.updateDownloaded(() => { Desktop.bridge?.updateDownloaded(() => {
if (hasDisabledUpdateMessage.current) { showToast("An update is ready to install.", {
return; type: "info",
} timeout: Infinity,
hasDisabledUpdateMessage.current = true;
toast.message("An update is ready to install.", {
duration: Infinity,
dismissible: true,
action: { action: {
label: t("Install now"), text: "Install now",
onClick: () => { onClick: () => {
void Desktop.bridge?.restartAndInstall(); void Desktop.bridge?.restartAndInstall();
}, },
@@ -55,7 +50,7 @@ export default function DesktopEventHandler() {
content: <KeyboardShortcuts />, content: <KeyboardShortcuts />,
}); });
}); });
}, [t, history, dialogs]); }, [t, history, dialogs, showToast]);
return null; return null;
} }
+2 -6
View File
@@ -22,13 +22,9 @@ function Dialogs() {
<Modal <Modal
key={id} key={id}
isOpen={modal.isOpen} isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false} isCentered={modal.isCentered}
onRequestClose={() => { onRequestClose={() => dialogs.closeModal(id)}
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title} title={modal.title}
style={modal.style}
> >
{modal.content} {modal.content}
</Modal> </Modal>
+31 -47
View File
@@ -3,16 +3,18 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types"; import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb"; import Breadcrumb from "~/components/Breadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon"; import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types"; import { MenuInternalLink } from "~/types";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers"; import {
archivePath,
collectionPath,
templatesPath,
trashPath,
} from "~/utils/routeHelpers";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
@@ -41,57 +43,53 @@ function useCategory(document: Document): MenuInternalLink | null {
}; };
} }
if (document.template) { if (document.isTemplate) {
return { return {
type: "route", type: "route",
icon: <ShapesIcon />, icon: <ShapesIcon />,
title: t("Templates"), title: t("Templates"),
to: settingsPath("templates"), to: templatesPath(),
}; };
} }
return null; return null;
} }
function DocumentBreadcrumb( const DocumentBreadcrumb: React.FC<Props> = ({
{ document, children, onlyText }: Props, document,
ref: React.RefObject<HTMLDivElement> | null children,
) { onlyText,
}: Props) => {
const { collections } = useStores(); const { collections } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
const category = useCategory(document); const category = useCategory(document);
const sidebarContext = useLocationSidebarContext();
const collection = document.collectionId const collection = document.collectionId
? collections.get(document.collectionId) ? collections.get(document.collectionId)
: undefined; : undefined;
const can = usePolicy(collection);
React.useEffect(() => {
void document.loadRelations({ withoutPolicies: true });
}, [document]);
let collectionNode: MenuInternalLink | undefined; let collectionNode: MenuInternalLink | undefined;
if (collection && can.readDocument) { if (collection) {
collectionNode = { collectionNode = {
type: "route", type: "route",
title: collection.name, title: collection.name,
icon: <CollectionIcon collection={collection} expanded />, icon: <CollectionIcon collection={collection} expanded />,
to: { to: collectionPath(collection.url),
pathname: collection.path,
state: { sidebarContext },
},
}; };
} else if (document.isCollectionDeleted) { } else if (document.collectionId && !collection) {
collectionNode = { collectionNode = {
type: "route", type: "route",
title: t("Deleted Collection"), title: t("Deleted Collection"),
icon: undefined, icon: undefined,
to: "", to: collectionPath("deleted-collection"),
}; };
} }
const path = document.pathTo; const path = React.useMemo(
() => collection?.pathToDocument(document.id).slice(0, -1) || [],
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection, document, document.collectionId, document.parentDocumentId]
);
const items = React.useMemo(() => { const items = React.useMemo(() => {
const output = []; const output = [];
@@ -104,25 +102,15 @@ function DocumentBreadcrumb(
output.push(collectionNode); output.push(collectionNode);
} }
path.slice(0, -1).forEach((node: NavigationNode) => { path.forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
output.push({ output.push({
type: "route", type: "route",
title: node.icon ? ( title: node.title,
<> to: node.url,
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
title
),
to: {
pathname: node.url,
state: { sidebarContext },
},
}); });
}); });
return output; return output;
}, [t, path, category, sidebarContext, collectionNode]); }, [path, category, collectionNode]);
if (!collections.isLoaded) { if (!collections.isLoaded) {
return null; return null;
@@ -132,10 +120,10 @@ function DocumentBreadcrumb(
return ( return (
<> <>
{collection?.name} {collection?.name}
{path.slice(0, -1).map((node: NavigationNode) => ( {path.map((node: NavigationNode) => (
<React.Fragment key={node.id}> <React.Fragment key={node.id}>
<SmallSlash /> <SmallSlash />
{node.title || t("Untitled")} {node.title}
</React.Fragment> </React.Fragment>
))} ))}
</> </>
@@ -143,15 +131,11 @@ function DocumentBreadcrumb(
} }
return ( return (
<Breadcrumb items={items} ref={ref} highlightFirstItem> <Breadcrumb items={items} highlightFirstItem>
{children} {children}
</Breadcrumb> </Breadcrumb>
); );
} };
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)` const SmallSlash = styled(GoToIcon)`
width: 12px; width: 12px;
@@ -163,4 +147,4 @@ const SmallSlash = styled(GoToIcon)`
opacity: 0.5; opacity: 0.5;
`; `;
export default observer(React.forwardRef(DocumentBreadcrumb)); export default observer(DocumentBreadcrumb);
+23 -74
View File
@@ -1,26 +1,23 @@
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion"; import { m } from "framer-motion";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons"; import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon"; import { s, ellipsis } from "@shared/styles";
import Squircle from "@shared/components/Squircle";
import { s, hover, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Pin from "~/models/Pin"; import Pin from "~/models/Pin";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time"; import Time from "~/components/Time";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats"; import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon"; import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Squircle from "./Squircle";
import Text from "./Text"; import Text from "./Text";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@@ -40,7 +37,6 @@ function DocumentCard(props: Props) {
const { collections } = useStores(); const { collections } = useStores();
const theme = useTheme(); const theme = useTheme();
const { document, pin, canUpdatePin, isDraggable } = props; const { document, pin, canUpdatePin, isDraggable } = props;
const pinnedToHome = React.useRef(!pin?.collectionId).current;
const collection = document.collectionId const collection = document.collectionId
? collections.get(document.collectionId) ? collections.get(document.collectionId)
: undefined; : undefined;
@@ -56,8 +52,6 @@ function DocumentCard(props: Props) {
disabled: !isDraggable || !canUpdatePin, disabled: !isDraggable || !canUpdatePin,
}); });
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@@ -72,10 +66,6 @@ function DocumentCard(props: Props) {
[pin] [pin]
); );
// If the document was updated within the last 7 days, show a timestamp instead of reading time
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
return ( return (
<Reorderable <Reorderable
ref={setNodeRef} ref={setNodeRef}
@@ -119,22 +109,15 @@ function DocumentCard(props: Props) {
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" /> <path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold> </Fold>
{document.icon ? ( {document.emoji ? (
<DocumentSquircle <Squircle color={theme.slateLight}>
icon={document.icon} <EmojiIcon emoji={document.emoji} size={26} />
color={document.color ?? undefined} </Squircle>
/>
) : ( ) : (
<Squircle <Squircle color={collection?.color}>
color={
collection?.color ??
(pinnedToHome ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon && {collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" && collection?.icon !== "collection" &&
pinnedToHome ? ( !pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" /> <CollectionIcon collection={collection} color="white" />
) : ( ) : (
<DocumentIcon color="white" /> <DocumentIcon color="white" />
@@ -143,26 +126,25 @@ function DocumentCard(props: Props) {
)} )}
<div> <div>
<Heading dir={document.dir}> <Heading dir={document.dir}>
{hasEmojiInTitle {document.emoji
? document.titleWithDefault.replace(document.icon!, "") ? document.titleWithDefault.replace(document.emoji, "")
: document.titleWithDefault} : document.titleWithDefault}
</Heading> </Heading>
<DocumentMeta size="xsmall"> <DocumentMeta size="xsmall">
{isRecentlyUpdated ? ( <Clock size={18} />
<> <Time
<Clock size={18} /> dateTime={document.updatedAt}
<Time dateTime={document.updatedAt} addSuffix shorten /> tooltipDelay={500}
</> addSuffix
) : ( shorten
<ReadingTime document={document} /> />
)}
</DocumentMeta> </DocumentMeta>
</div> </div>
</Content> </Content>
{canUpdatePin && ( {canUpdatePin && (
<Actions dir={document.dir} gap={4}> <Actions dir={document.dir} gap={4}>
{!isDragging && pin && ( {!isDragging && pin && (
<Tooltip content={t("Unpin")}> <Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}> <PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon /> <CloseIcon />
</PinButton> </PinButton>
@@ -176,39 +158,6 @@ function DocumentCard(props: Props) {
); );
} }
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
}: {
icon: string;
color?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
const squircleColor = iconType === IconType.SVG ? color : theme.slateLight;
return (
<Squircle color={squircleColor}>
<Icon value={icon} color={theme.white} forceColor />
</Squircle>
);
};
const Clock = styled(ClockIcon)` const Clock = styled(ClockIcon)`
flex-shrink: 0; flex-shrink: 0;
`; `;
@@ -330,8 +279,8 @@ const Heading = styled.h3`
overflow: hidden; overflow: hidden;
color: ${s("text")}; color: ${s("text")};
font-family: ${s("fontFamily")}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
font-weight: 500; Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`; `;
export default observer(DocumentCard); export default observer(DocumentCard);
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { Editor } from "~/editor";
export type DocumentContextValue = {
/** The current editor instance for this document. */
editor: Editor | null;
/** Set the current editor instance for this document. */
setEditor: (editor: Editor) => void;
};
const DocumentContext = React.createContext<DocumentContextValue>({
editor: null,
// eslint-disable-next-line @typescript-eslint/no-empty-function
setEditor() {},
});
export const useDocumentContext = () => React.useContext(DocumentContext);
export default DocumentContext;
-84
View File
@@ -1,84 +0,0 @@
import { action, computed, observable } from "mobx";
import React, { PropsWithChildren } from "react";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import Document from "~/models/Document";
import { Editor } from "~/editor";
class DocumentContext {
/** The current document */
document?: Document;
/** The editor instance for this document */
editor?: Editor;
@observable
isEditorInitialized: boolean = false;
@observable
headings: Heading[] = [];
@computed
get hasHeadings() {
return this.headings.length > 0;
}
@action
setDocument = (document: Document) => {
this.document = document;
this.updateState();
};
@action
setEditor = (editor: Editor) => {
this.editor = editor;
this.updateState();
};
@action
setEditorInitialized = (initialized: boolean) => {
this.isEditorInitialized = initialized;
};
@action
updateState = () => {
this.updateHeadings();
this.updateTasks();
};
private updateHeadings() {
const currHeadings = this.editor?.getHeadings() ?? [];
const hasChanged =
currHeadings.map((h) => h.level + h.title).join("") !==
this.headings.map((h) => h.level + h.title).join("");
if (hasChanged) {
this.headings = currHeadings;
}
}
private updateTasks() {
const tasks = this.editor?.getTasks() ?? [];
const total = tasks.length ?? 0;
const completed = tasks.filter((t) => t.completed).length ?? 0;
this.document?.updateTasks(total, completed);
}
}
const Context = React.createContext<DocumentContext | null>(null);
export const useDocumentContext = () => {
const ctx = React.useContext(Context);
if (!ctx) {
throw new Error(
"useDocumentContext must be used within DocumentContextProvider"
);
}
return ctx;
};
export const DocumentContextProvider = ({
children,
}: PropsWithChildren<unknown>) => {
const context = React.useMemo(() => new DocumentContext(), []);
return <Context.Provider value={context}>{children}</Context.Provider>;
};
-149
View File
@@ -1,149 +0,0 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DocumentCopy({ document, onSubmit }: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const collectionTrees = useCollectionTrees();
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
null
);
const items = React.useMemo(() => {
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
return;
}
try {
const result = await document.duplicate({
publish,
recursive,
title: document.title,
collectionId: selectedPath.collectionId,
...(selectedPath.type === "document"
? { parentDocumentId: selectedPath.id }
: {}),
});
toast.success(t("Document copied"));
onSubmit(result);
} catch (err) {
toast.error(t("Couldnt copy the document, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={copy}
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<OptionsContainer>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
values={{ location: selectedPath.title }}
components={{ em: <strong /> }}
/>
) : (
t("Select a location to copy")
)}
</StyledText>
<Button disabled={!selectedPath} onClick={copy}>
{t("Copy")}
</Button>
</Footer>
</FlexContainer>
);
}
const OptionsContainer = styled.div`
margin: 16px 0 8px 0;
padding-left: 24px;
padding-right: 24px;
`;
export default observer(DocumentCopy);
+86 -132
View File
@@ -1,45 +1,41 @@
import FuzzySearch from "fuzzy-search"; import FuzzySearch from "fuzzy-search";
import concat from "lodash/concat"; import { includes, difference, concat, filter, map, fill } from "lodash";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import includes from "lodash/includes";
import map from "lodash/map";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons"; import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer"; import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window"; import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed"; import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon"; import { NavigationNode } from "@shared/types";
import { NavigationNode, NavigationNodeType } from "@shared/types"; import parseTitle from "@shared/utils/parseTitle";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode"; import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult"; import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon"; import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { Outline } from "~/components/Input"; import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch"; import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile"; import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
import { ancestors, descendants } from "~/utils/tree"; import { ancestors, descendants } from "~/utils/tree";
type Props = { type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */ /** Action taken upon submission of selected item, could be publish, move etc. */
onSubmit: () => void; onSubmit: () => void;
/** A side-effect of item selection */ /** A side-effect of item selection */
onSelect: (item: NavigationNode | null) => void; onSelect: (item: NavigationNode | null) => void;
/** Items to be shown in explorer */ /** Items to be shown in explorer */
items: NavigationNode[]; items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
}; };
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) { function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const isMobile = useMobile(); const isMobile = useMobile();
const { collections, documents } = useStores(); const { collections, documents } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -47,25 +43,12 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>(); const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>( const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
() => { null
const node =
defaultValue && items.find((item) => item.id === defaultValue);
return node || null;
}
); );
const [initialScrollOffset, setInitialScrollOffset] = const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0); React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0); const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => { const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
if (defaultValue) {
const node = items.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((node) => node.id);
}
}
return [];
});
const [itemRefs, setItemRefs] = React.useState< const [itemRefs, setItemRefs] = React.useState<
React.RefObject<HTMLSpanElement>[] React.RefObject<HTMLSpanElement>[]
>([]); >([]);
@@ -78,10 +61,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const VERTICAL_PADDING = 6; const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24; const HORIZONTAL_PADDING = 24;
const recentlyViewedItemIds = documents.recentlyViewed
.slice(0, 5)
.map((item) => item.id);
const searchIndex = React.useMemo( const searchIndex = React.useMemo(
() => () =>
new FuzzySearch(items, ["title"], { new FuzzySearch(items, ["title"], {
@@ -111,15 +90,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
onSelect(selectedNode); onSelect(selectedNode);
}, [selectedNode, onSelect]); }, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, []);
function getNodes() { function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] { function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id) return expandedNodes.includes(item.id)
@@ -130,18 +100,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm return searchTerm
? searchIndex.search(searchTerm) ? searchIndex.search(searchTerm)
: items : items
.filter((item) => recentlyViewedItemIds.includes(item.id)) .filter((item) => item.type === "collection")
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.flatMap(includeDescendants); .flatMap(includeDescendants);
} }
const nodes = getNodes(); const nodes = getNodes();
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const scrollNodeIntoView = React.useCallback( const scrollNodeIntoView = React.useCallback(
(node: number) => { (node: number) => {
@@ -237,91 +200,84 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
} }
}; };
const ListItem = observer( const ListItem = ({
({ index,
index, data,
data, style,
style, }: {
}: { index: number;
index: number; data: NavigationNode[];
data: NavigationNode[]; style: React.CSSProperties;
style: React.CSSProperties; }) => {
}) => { const node = data[index];
const node = data[index]; const isCollection = node.type === "collection";
const isCollection = node.type === "collection"; let icon, title, path;
let renderedIcon,
title: string,
icon: string | undefined,
color: string | undefined,
path;
if (isCollection) { if (isCollection) {
const col = collections.get(node.collectionId as string); const col = collections.get(node.collectionId as string);
renderedIcon = col && ( icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} /> <CollectionIcon collection={col} expanded={isExpanded(index)} />
); );
title = node.title; title = node.title;
} else {
const doc = documents.get(node.id);
const { strippedTitle, emoji } = parseTitle(node.title);
title = strippedTitle;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else { } else {
const doc = documents.get(node.id); icon = <DocumentIcon color={theme.textSecondary} />;
icon = doc?.icon ?? node.icon ?? node.emoji;
color = doc?.color ?? node.color;
title = doc?.title ?? node.title;
if (icon) {
renderedIcon = <Icon value={icon} color={color} />;
} else if (doc?.isStarred) {
renderedIcon = <StarredIcon color={theme.yellow} />;
} else {
renderedIcon = <DocumentIcon color={theme.textSecondary} />;
}
path = ancestors(node)
.map((a) => a.title)
.join(" / ");
} }
return searchTerm ? ( path = ancestors(node)
<DocumentExplorerSearchResult .map((a) => parseTitle(a.title).strippedTitle)
selected={isSelected(index)} .join(" / ");
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={renderedIcon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
}}
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={(node.depth ?? 0) - baseDepth}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
);
} }
);
return searchTerm ? (
<DocumentExplorerSearchResult
selected={isSelected(index)}
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
}}
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={icon}
title={title}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
);
};
const focusSearchInput = () => { const focusSearchInput = () => {
inputSearchRef.current?.focus(); inputSearchRef.current?.focus();
@@ -427,9 +383,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
</AutoSizer> </AutoSizer>
) : ( ) : (
<FlexContainer> <FlexContainer>
<Text as="p" type="secondary"> <Text type="secondary">{t("No results found")}.</Text>
{t("No results found")}.
</Text>
</FlexContainer> </FlexContainer>
)} )}
</ListContainer> </ListContainer>
+2 -3
View File
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const OFFSET = 12; 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 ( return (
<Node <Node
@@ -120,7 +120,6 @@ export const Node = styled.span<{
color: ${props.theme.white}; color: ${props.theme.white};
svg { svg {
color: ${props.theme.white};
fill: ${props.theme.white}; fill: ${props.theme.white};
} }
`} `}
@@ -1,7 +1,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed"; import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled from "styled-components"; import styled from "styled-components";
import { ellipsis } from "@shared/styles"; import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode"; import { Node as SearchResult } from "~/components/DocumentExplorerNode";
+56 -54
View File
@@ -1,30 +1,29 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary"; import { s } from "@shared/styles";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Badge from "~/components/Badge"; import Badge from "~/components/Badge";
import Button from "~/components/Button";
import DocumentMeta from "~/components/DocumentMeta"; import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight"; import Highlight from "~/components/Highlight";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star"; import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; import usePolicy from "~/hooks/usePolicy";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers"; import { hover } from "~/styles";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext"; import { newDocumentPath } from "~/utils/routeHelpers";
type Props = { type Props = {
document: Document; document: Document;
@@ -36,13 +35,14 @@ type Props = {
showPin?: boolean; showPin?: boolean;
showDraft?: boolean; showDraft?: boolean;
showTemplate?: boolean; showTemplate?: boolean;
}; } & CompositeStateReturn;
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi; const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) { function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX directly here as it causes an infinite loop // don't use SEARCH_RESULT_REGEX here as it causes
return tag.replace(new RegExp(SEARCH_RESULT_REGEX.source), "$1"); // an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
} }
function DocumentListItem( function DocumentListItem(
@@ -51,18 +51,9 @@ function DocumentListItem(
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const user = useCurrentUser(); const user = useCurrentUser();
const locationSidebarContext = useLocationSidebarContext(); const team = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
const { const {
document, document,
showParentDocuments, showParentDocuments,
@@ -78,57 +69,51 @@ function DocumentListItem(
const queryIsInTitle = const queryIsInTitle =
!!highlight && !!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase()); !!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate; const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const sidebarContext = determineSidebarContext({ const can = usePolicy(team);
document, const canCollection = usePolicy(document.collectionId);
user,
currentContext: locationSidebarContext,
});
return ( return (
<DocumentLink <CompositeItem
ref={itemRef} as={DocumentLink}
ref={ref}
dir={document.dir} dir={document.dir}
role="menuitem" role="menuitem"
$isStarred={document.isStarred} $isStarred={document.isStarred}
$menuOpen={menuOpen} $menuOpen={menuOpen}
to={{ to={{
pathname: documentPath(document), pathname: document.url,
state: { state: {
title: document.titleWithDefault, title: document.titleWithDefault,
sidebarContext,
}, },
}} }}
{...rest} {...rest}
{...rovingTabIndex}
> >
<Content> <Content>
<Heading dir={document.dir}> <Heading dir={document.dir}>
{document.icon && (
<>
<Icon value={document.icon} color={document.color ?? undefined} />
&nbsp;
</>
)}
<Title <Title
text={document.titleWithDefault} text={document.titleWithDefault}
highlight={highlight} highlight={highlight}
dir={document.dir} dir={document.dir}
/> />
{document.isBadgedNew && document.createdBy?.id !== user.id && ( {document.isBadgedNew && document.createdBy.id !== user.id && (
<Badge yellow>{t("New")}</Badge> <Badge yellow>{t("New")}</Badge>
)} )}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && ( {canStar && (
<StarPositioner> <StarPositioner>
<StarButton document={document} /> <StarButton document={document} />
</StarPositioner> </StarPositioner>
)} )}
{document.isDraft && showDraft && (
<Tooltip
tooltip={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{document.isTemplate && showTemplate && ( {document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge> <Badge primary>{t("Template")}</Badge>
)} )}
@@ -150,6 +135,25 @@ function DocumentListItem(
/> />
</Content> </Content>
<Actions> <Actions>
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument &&
canCollection.update && (
<>
<Button
as={Link}
to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
&nbsp;
</>
)}
<DocumentMenu <DocumentMenu
document={document} document={document}
showPin={showPin} showPin={showPin}
@@ -158,7 +162,7 @@ function DocumentListItem(
modal={false} modal={false}
/> />
</Actions> </Actions>
</DocumentLink> </CompositeItem>
); );
} }
@@ -258,8 +262,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
margin-bottom: 0.25em; margin-bottom: 0.25em;
white-space: nowrap; white-space: nowrap;
color: ${s("text")}; color: ${s("text")};
font-family: ${s("fontFamily")}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
font-weight: 500; Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`; `;
const StarPositioner = styled(Flex)` const StarPositioner = styled(Flex)`
@@ -275,12 +279,10 @@ const Title = styled(Highlight)`
const ResultContext = styled(Highlight)` const ResultContext = styled(Highlight)`
display: block; display: block;
color: ${s("textSecondary")}; color: ${s("textTertiary")};
font-size: 15px; font-size: 14px;
margin-top: -0.25em; margin-top: -0.25em;
margin-bottom: 0.25em; margin-bottom: 0.25em;
max-height: 90px;
overflow: hidden;
`; `;
export default observer(React.forwardRef(DocumentListItem)); export default observer(React.forwardRef(DocumentListItem));
+13 -29
View File
@@ -95,21 +95,6 @@ const DocumentMeta: React.FC<Props> = ({
<Time dateTime={archivedAt} addSuffix /> <Time dateTime={archivedAt} addSuffix />
</span> </span>
); );
} else if (
document.sourceMetadata &&
document.sourceMetadata?.importedAt &&
document.sourceMetadata.importedAt >= updatedAt
) {
content = (
<span>
{document.sourceMetadata.createdByName
? t("{{ userName }} updated", {
userName: document.sourceMetadata.createdByName,
})
: t("Imported")}{" "}
<Time dateTime={createdAt} addSuffix />
</span>
);
} else if (createdAt === updatedAt) { } else if (createdAt === updatedAt) {
content = ( content = (
<span> <span>
@@ -128,6 +113,15 @@ const DocumentMeta: React.FC<Props> = ({
<Time dateTime={publishedAt} addSuffix /> <Time dateTime={publishedAt} addSuffix />
</span> </span>
); );
} else if (isDraft) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You saved")
: t("{{ userName }} saved", { userName })}{" "}
<Time dateTime={updatedAt} addSuffix />
</span>
);
} else { } else {
content = ( content = (
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}> <Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
@@ -140,7 +134,7 @@ const DocumentMeta: React.FC<Props> = ({
} }
const nestedDocumentsCount = collection const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length ? collection.getDocumentChildren(document.id).length
: 0; : 0;
const canShowProgressBar = isTasks && !isTemplate; const canShowProgressBar = isTasks && !isTemplate;
@@ -168,13 +162,7 @@ const DocumentMeta: React.FC<Props> = ({
}; };
return ( return (
<Container <Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
align="center"
rtl={document.dir === "rtl"}
{...rest}
dir="ltr"
lang=""
>
{to ? ( {to ? (
<Link to={to} replace={replace}> <Link to={to} replace={replace}>
{content} {content}
@@ -185,9 +173,9 @@ const DocumentMeta: React.FC<Props> = ({
{showCollection && collection && ( {showCollection && collection && (
<span> <span>
&nbsp;{t("in")}&nbsp; &nbsp;{t("in")}&nbsp;
<Strong> <strong>
<DocumentBreadcrumb document={document} onlyText /> <DocumentBreadcrumb document={document} onlyText />
</Strong> </strong>
</span> </span>
)} )}
{showParentDocuments && nestedDocumentsCount > 0 && ( {showParentDocuments && nestedDocumentsCount > 0 && (
@@ -210,10 +198,6 @@ const DocumentMeta: React.FC<Props> = ({
); );
}; };
const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ rtl?: boolean }>` const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")}; justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")}; color: ${s("textTertiary")};
@@ -0,0 +1,52 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { documentPath } from "~/utils/routeHelpers";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { showToast } = useToasts();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize();
if (template) {
history.push(documentPath(template));
showToast(t("Template created, go ahead and customize it"), {
type: "info",
});
}
}, [document, showToast, history, t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
+9 -24
View File
@@ -1,15 +1,13 @@
import compact from "lodash/compact"; import { sortBy } from "lodash";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date"; import { dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document"; import Document from "~/models/Document";
import User from "~/models/User"; import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import ListItem from "~/components/List/Item"; import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList"; import PaginatedList from "~/components/PaginatedList";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
type Props = { type Props = {
@@ -20,9 +18,6 @@ type Props = {
function DocumentViews({ document, isOpen }: Props) { function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { views, presence } = useStores(); const { views, presence } = useStores();
const user = useCurrentUser();
const locale = dateLocale(user.language);
const documentPresence = presence.get(document.id); const documentPresence = presence.get(document.id);
const documentPresenceArray = documentPresence const documentPresenceArray = documentPresence
? Array.from(documentPresence.values()) ? Array.from(documentPresence.values())
@@ -36,10 +31,10 @@ function DocumentViews({ document, isOpen }: Props) {
const documentViews = views.inDocument(document.id); const documentViews = views.inDocument(document.id);
const sortedViews = sortBy( const sortedViews = sortBy(
documentViews, documentViews,
(view) => !presentIds.includes(view.userId) (view) => !presentIds.includes(view.user.id)
); );
const users = React.useMemo( const users = React.useMemo(
() => compact(sortedViews.map((v) => v.user)), () => sortedViews.map((v) => v.user),
[sortedViews] [sortedViews]
); );
@@ -50,20 +45,16 @@ function DocumentViews({ document, isOpen }: Props) {
aria-label={t("Viewers")} aria-label={t("Viewers")}
items={users} items={users}
renderItem={(model: User) => { renderItem={(model: User) => {
const view = documentViews.find((v) => v.userId === model.id); const view = documentViews.find((v) => v.user.id === model.id);
const isPresent = presentIds.includes(model.id); const isPresent = presentIds.includes(model.id);
const isEditing = editingIds.includes(model.id); const isEditing = editingIds.includes(model.id);
const subtitle = isPresent const subtitle = isPresent
? isEditing ? isEditing
? t("Currently editing") ? t("Currently editing")
: t("Currently viewing") : t("Currently viewing")
: t("Viewed {{ timeAgo }}", { : t("Viewed {{ timeAgo }} ago", {
timeAgo: dateToRelative( timeAgo: dateToRelative(
view ? Date.parse(view.lastViewedAt) : new Date(), view ? Date.parse(view.lastViewedAt) : new Date()
{
addSuffix: true,
locale,
}
), ),
}); });
return ( return (
@@ -71,13 +62,7 @@ function DocumentViews({ document, isOpen }: Props) {
key={model.id} key={model.id}
title={model.name} title={model.name}
subtitle={subtitle} subtitle={subtitle}
image={ image={<Avatar key={model.id} model={model} size={32} />}
<Avatar
key={model.id}
model={model}
size={AvatarSize.Large}
/>
}
border={false} border={false}
small small
/> />
+190 -65
View File
@@ -1,26 +1,36 @@
import difference from "lodash/difference"; import { deburr, difference, sortBy } from "lodash";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state"; import { TextSelection } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import { mergeRefs } from "react-merge-refs"; import { mergeRefs } from "react-merge-refs";
import { useHistory } from "react-router-dom";
import { Optional } from "utility-types"; import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles"; import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types"; import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { Heading } from "@shared/utils/ProsemirrorHelper";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { getDataTransferFiles } from "@shared/utils/files"; import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations"; import { AttachmentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding"; import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary"; import ErrorBoundary from "~/components/ErrorBoundary";
import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useCurrentUser from "~/hooks/useCurrentUser";
import useDictionary from "~/hooks/useDictionary"; import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds"; import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { uploadFile, uploadFileFromUrl } from "~/utils/files"; import useToasts from "~/hooks/useToasts";
import useUserLocale from "~/hooks/useUserLocale";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import { isModKey } from "~/utils/keyboard";
import lazyWithRetry from "~/utils/lazyWithRetry"; import lazyWithRetry from "~/utils/lazyWithRetry";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor")); const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -31,41 +41,153 @@ export type Props = Optional<
| "onClickLink" | "onClickLink"
| "embeds" | "embeds"
| "dictionary" | "dictionary"
| "onShowToast"
| "extensions" | "extensions"
> & { > & {
shareId?: string | undefined; shareId?: string | undefined;
embedsDisabled?: boolean; embedsDisabled?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>; onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => void; onPublish?: (event: React.MouseEvent) => any;
editorStyle?: React.CSSProperties; editorStyle?: React.CSSProperties;
}; };
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) { function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } = const {
props; id,
const { comments } = useStores(); shareId,
onChange,
onHeadingsChange,
onCreateCommentMark,
onDeleteCommentMark,
} = props;
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const { auth, comments, documents } = useStores();
const { showToast } = useToasts();
const dictionary = useDictionary(); const dictionary = useDictionary();
const embeds = useEmbeds(!shareId); const embeds = useEmbeds(!shareId);
const history = useHistory();
const localRef = React.useRef<SharedEditor>(); const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences; const preferences = auth.user?.preferences;
const previousHeadings = React.useRef<Heading[] | null>(null);
const [activeLinkElement, setActiveLink] =
React.useState<HTMLAnchorElement | null>(null);
const previousCommentIds = React.useRef<string[]>(); const previousCommentIds = React.useRef<string[]>();
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
setActiveLink(element);
return false;
}, []);
const handleLinkInactive = React.useCallback(() => {
setActiveLink(null);
}, []);
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
if (!slug) {
return [];
}
try {
const document = await documents.fetch(slug);
const time = dateToRelative(Date.parse(document.updatedAt), {
addSuffix: true,
shorten: true,
locale,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles(term);
return sortBy(
results.map((document: Document) => ({
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
})),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
},
[documents]
);
const handleUploadFile = React.useCallback( const handleUploadFile = React.useCallback(
async (file: File | string) => { async (file: File) => {
const options = { const result = await uploadFile(file, {
documentId: id, documentId: id,
preset: AttachmentPreset.DocumentAttachment, preset: AttachmentPreset.DocumentAttachment,
}; });
const result =
file instanceof File
? await uploadFile(file, options)
: await uploadFileFromUrl(file, options);
return result.url; return result.url;
}, },
[id] [id]
); );
const { handleClickLink } = useEditorClickHandlers({ shareId }); const handleClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (isHash(href)) {
window.location.href = href;
return;
}
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
// relative
let navigateTo = href;
// probably absolute
if (href[0] !== "/") {
try {
const url = new URL(href);
navigateTo = url.pathname + url.hash;
} catch (err) {
navigateTo = href;
}
}
// Link to our own API should be opened in a new tab, not in the app
if (navigateTo.startsWith("/api/")) {
window.open(href, "_blank");
return;
}
// If we're navigating to an internal document link then prepend the
// share route to the URL so that the document is loaded in context
if (shareId && navigateTo.includes("/doc/")) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
}
history.push(navigateTo);
} else if (href) {
window.open(href, "_blank");
}
},
[history, shareId]
);
const focusAtEnd = React.useCallback(() => { const focusAtEnd = React.useCallback(() => {
localRef?.current?.focusAtEnd(); localRef?.current?.focusAtEnd();
@@ -111,10 +233,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
(file) => !AttachmentValidation.imageContentTypes.includes(file.type) (file) => !AttachmentValidation.imageContentTypes.includes(file.type)
); );
return insertFiles(view, event, pos, files, { insertFiles(view, event, pos, files, {
uploadFile: handleUploadFile, uploadFile: handleUploadFile,
onFileUploadStart: props.onFileUploadStart, onFileUploadStart: props.onFileUploadStart,
onFileUploadStop: props.onFileUploadStop, onFileUploadStop: props.onFileUploadStop,
onShowToast: showToast,
dictionary, dictionary,
isAttachment, isAttachment,
}); });
@@ -125,6 +248,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
props.onFileUploadStop, props.onFileUploadStop,
dictionary, dictionary,
handleUploadFile, handleUploadFile,
showToast,
] ]
); );
@@ -137,9 +261,24 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[] []
); );
// Calculate if headings have changed and trigger callback if so
const updateHeadings = React.useCallback(() => {
if (onHeadingsChange) {
const headings = localRef?.current?.getHeadings();
if (
headings &&
headings.map((h) => h.level + h.title).join("") !==
previousHeadings.current?.map((h) => h.level + h.title).join("")
) {
previousHeadings.current = headings;
onHeadingsChange(headings);
}
}
}, [localRef, onHeadingsChange]);
const updateComments = React.useCallback(() => { const updateComments = React.useCallback(() => {
if (onCreateCommentMark && onDeleteCommentMark && localRef.current) { if (onCreateCommentMark && onDeleteCommentMark) {
const commentMarks = localRef.current.getComments(); const commentMarks = localRef.current?.getComments();
const commentIds = comments.orderedData.map((c) => c.id); const commentIds = comments.orderedData.map((c) => c.id);
const commentMarkIds = commentMarks?.map((c) => c.id); const commentMarkIds = commentMarks?.map((c) => c.id);
const newCommentIds = difference( const newCommentIds = difference(
@@ -149,7 +288,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
); );
newCommentIds.forEach((commentId) => { newCommentIds.forEach((commentId) => {
const mark = commentMarks.find((c) => c.id === commentId); const mark = commentMarks?.find((c) => c.id === commentId);
if (mark) { if (mark) {
onCreateCommentMark(mark.id, mark.userId); onCreateCommentMark(mark.id, mark.userId);
} }
@@ -171,68 +310,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const handleChange = React.useCallback( const handleChange = React.useCallback(
(event) => { (event) => {
onChange?.(event); onChange?.(event);
updateHeadings();
updateComments(); updateComments();
}, },
[onChange, updateComments] [onChange, updateComments, updateHeadings]
); );
const handleRefChanged = React.useCallback( const handleRefChanged = React.useCallback(
(node: SharedEditor | null) => { (node: SharedEditor | null) => {
if (node) { if (node) {
updateHeadings();
updateComments(); updateComments();
} }
}, },
[updateComments] [updateComments, updateHeadings]
); );
const paragraphs = React.useMemo(() => {
if (props.readOnly && typeof props.value === "object") {
return ProsemirrorHelper.getPlainParagraphs(props.value);
}
return undefined;
}, [props.readOnly, props.value]);
return ( return (
<ErrorBoundary component="div" reloadOnChunkMissing> <ErrorBoundary component="div" reloadOnChunkMissing>
<> <>
{paragraphs ? ( <LazyLoadedEditor
<EditorContainer ref={mergeRefs([ref, localRef, handleRefChanged])}
rtl={props.dir === "rtl"} uploadFile={handleUploadFile}
grow={props.grow} onShowToast={showToast}
style={props.style} embeds={embeds}
editorStyle={props.editorStyle} userPreferences={preferences}
> dictionary={dictionary}
<div className="ProseMirror"> {...props}
{paragraphs.map((paragraph, index) => ( onHoverLink={handleLinkActive}
<p key={index} dir="auto"> onClickLink={handleClickLink}
{paragraph.content?.map((content) => content.text)} onSearchLink={handleSearchLink}
</p> onChange={handleChange}
))} placeholder={props.placeholder || ""}
</div> defaultValue={props.defaultValue || ""}
</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 && ( {props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding <ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd} onClick={focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop} onDrop={handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver} onDragOver={handleDragOver}
minHeight={props.editorStyle.paddingBottom} minHeight={props.editorStyle.paddingBottom}
/> />
)} )}
{activeLinkElement && !shareId && (
<HoverPreview
element={activeLinkElement}
onClose={handleLinkInactive}
/>
)}
</> </>
</ErrorBoundary> </ErrorBoundary>
); );
-20
View File
@@ -1,20 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** Width of the containing element. */
width?: number | string;
/** Height of the containing element. */
height?: number | string;
/** Controls the rendered emoji size. */
size?: number;
};
export const Emoji = styled.span<Props>`
font-family: ${s("fontFamilyEmoji")};
width: ${({ width }) =>
typeof width === "string" ? width : width ? `${width}px` : "auto"};
height: ${({ height }) =>
typeof height === "string" ? height : height ? `${height}px` : "auto"};
font-size: ${({ size }) => size && `${size}px`};
`;
+5 -5
View File
@@ -1,9 +1,9 @@
import styled from "styled-components"; import styled from "styled-components";
import Text from "~/components/Text"; import { s } from "@shared/styles";
const Empty = styled(Text).attrs({ const Empty = styled.p`
type: "tertiary", color: ${s("textTertiary")};
selectable: false, user-select: none;
})``; `;
export default Empty; export default Empty;
+11 -12
View File
@@ -4,10 +4,9 @@ import * as React from "react";
import { withTranslation, Trans, WithTranslation } from "react-i18next"; import { withTranslation, Trans, WithTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper"; import { githubIssuesUrl, feedbackUrl } from "@shared/utils/urlHelpers";
import Button from "~/components/Button"; import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent"; import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle"; import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text"; import Text from "~/components/Text";
import env from "~/env"; import env from "~/env";
@@ -58,7 +57,7 @@ class ErrorBoundary extends React.Component<Props> {
}; };
handleReportBug = () => { handleReportBug = () => {
window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github); window.open(isCloudHosted ? feedbackUrl() : githubIssuesUrl());
}; };
render() { render() {
@@ -78,12 +77,12 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && ( {showTitle && (
<> <>
<PageTitle title={t("Module failed to load")} /> <PageTitle title={t("Module failed to load")} />
<Heading> <h1>
<Trans>Loading Failed</Trans> <Trans>Loading Failed</Trans>
</Heading> </h1>
</> </>
)} )}
<Text as="p" type="secondary"> <Text type="secondary">
<Trans> <Trans>
Sorry, part of the application failed to load. This may be Sorry, part of the application failed to load. This may be
because it was updated since you opened the tab or because of a because it was updated since you opened the tab or because of a
@@ -102,12 +101,12 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && ( {showTitle && (
<> <>
<PageTitle title={t("Something Unexpected Happened")} /> <PageTitle title={t("Something Unexpected Happened")} />
<Heading> <h1>
<Trans>Something Unexpected Happened</Trans> <Trans>Something Unexpected Happened</Trans>
</Heading> </h1>
</> </>
)} )}
<Text as="p" type="secondary"> <Text type="secondary">
<Trans <Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch." defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{ values={{
@@ -122,11 +121,11 @@ class ErrorBoundary extends React.Component<Props> {
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "} <Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
{this.showDetails ? ( {this.showDetails ? (
<Button onClick={this.handleReportBug} neutral> <Button onClick={this.handleReportBug} neutral>
<Trans>Report a bug</Trans> <Trans>Report a Bug</Trans>
</Button> </Button>
) : ( ) : (
<Button onClick={this.handleShowDetails} neutral> <Button onClick={this.handleShowDetails} neutral>
<Trans>Show detail</Trans> <Trans>Show Detail</Trans>
</Button> </Button>
)} )}
</p> </p>
@@ -139,7 +138,7 @@ class ErrorBoundary extends React.Component<Props> {
} }
const Pre = styled.pre` const Pre = styled.pre`
background: ${s("backgroundSecondary")}; background: ${s("secondaryBackground")};
padding: 16px; padding: 16px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
type Props = {
children?: React.ReactNode;
className?: string;
};
const EventBoundary: React.FC<Props> = ({ children, className }: Props) => {
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
return (
<span onClick={handleClick} className={className}>
{children}
</span>
);
};
export default EventBoundary;

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